diff --git a/.eslintrc.js b/.eslintrc.js index 2d59d36d..19fcf230 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,6 +1,10 @@ module.exports = { root: true, - extends: ['@react-native-community', 'plugin:react-native-a11y/ios'], + extends: [ + '@react-native-community', + 'plugin:react-native-a11y/ios', + 'prettier', + ], parser: '@typescript-eslint/parser', plugins: ['@typescript-eslint', 'detox'], ignorePatterns: [ diff --git a/app.json b/app.json index 8a2fe039..9016a364 100644 --- a/app.json +++ b/app.json @@ -4,7 +4,7 @@ "slug": "bluesky", "scheme": "bluesky", "owner": "blueskysocial", - "version": "1.28.0", + "version": "1.29.0", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", @@ -40,7 +40,7 @@ "backgroundColor": "#ffffff" }, "android": { - "versionCode": 14, + "versionCode": 15, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#ffffff" diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 0b9f3450..6daec493 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -107,6 +107,8 @@ func serve(cctx *cli.Context) error { // generic routes e.GET("/search", server.WebGeneric) + e.GET("/search/feeds", server.WebGeneric) + e.GET("/feeds", server.WebGeneric) e.GET("/notifications", server.WebGeneric) e.GET("/moderation", server.WebGeneric) e.GET("/moderation/mute-lists", server.WebGeneric) @@ -114,6 +116,7 @@ func serve(cctx *cli.Context) error { e.GET("/moderation/blocked-accounts", server.WebGeneric) e.GET("/settings", server.WebGeneric) e.GET("/settings/app-passwords", server.WebGeneric) + e.GET("/settings/saved-feeds", server.WebGeneric) e.GET("/sys/debug", server.WebGeneric) e.GET("/sys/log", server.WebGeneric) e.GET("/support", server.WebGeneric) @@ -127,6 +130,8 @@ func serve(cctx *cli.Context) error { e.GET("/profile/:handle/follows", server.WebGeneric) e.GET("/profile/:handle/followers", server.WebGeneric) e.GET("/profile/:handle/lists/:rkey", server.WebGeneric) + e.GET("/profile/:handle/feed/:rkey", server.WebGeneric) + e.GET("/profile/:handle/feed/:rkey/liked-by", server.WebGeneric) // post endpoints; only first populates info e.GET("/profile/:handle/post/:rkey", server.WebPost) diff --git a/jest/test-pds.ts b/jest/test-pds.ts index a75a0034..3c14c553 100644 --- a/jest/test-pds.ts +++ b/jest/test-pds.ts @@ -13,6 +13,7 @@ const ADMIN_PASSWORD = 'admin-pass' const SECOND = 1000 const MINUTE = SECOND * 60 const HOUR = MINUTE * 60 +const DAY = HOUR * 24 export interface TestUser { email: string @@ -66,6 +67,8 @@ export async function createServer( adminPassword: ADMIN_PASSWORD, inviteRequired, didPlcUrl: plcUrl, + didCacheMaxTTL: DAY, + didCacheStaleTTL: HOUR, jwtSecret: 'jwt-secret', availableUserDomains: ['.test'], appUrlPasswordReset: 'app://forgot-password', diff --git a/package.json b/package.json index aeeac30a..253c3b78 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.28.0", + "version": "1.29.0", "private": true, "scripts": { "postinstall": "patch-package", @@ -22,7 +22,7 @@ "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all" }, "dependencies": { - "@atproto/api": "0.3.3", + "@atproto/api": "0.3.8", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@expo/webpack-config": "^18.0.1", @@ -111,6 +111,7 @@ "react-native": "0.71.7", "react-native-appstate-hook": "^1.0.6", "react-native-background-fetch": "^4.1.8", + "react-native-draggable-flatlist": "^4.0.1", "react-native-drawer-layout": "^3.2.0", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "~2.9.0", @@ -140,7 +141,7 @@ "zod": "^3.20.2" }, "devDependencies": { - "@atproto/pds": "^0.1.8", + "@atproto/pds": "^0.1.10", "@babel/core": "^7.20.0", "@babel/preset-env": "^7.20.0", "@babel/runtime": "^7.20.0", diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 09631701..7da77b87 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -14,6 +14,7 @@ import {createBottomTabNavigator} from '@react-navigation/bottom-tabs' import { HomeTabNavigatorParams, SearchTabNavigatorParams, + FeedsTabNavigatorParams, NotificationsTabNavigatorParams, FlatNavigatorParams, AllNavigatorParams, @@ -32,14 +33,18 @@ import {useStores} from './state' import {HomeScreen} from './view/screens/Home' import {SearchScreen} from './view/screens/Search' +import {FeedsScreen} from './view/screens/Feeds' import {NotificationsScreen} from './view/screens/Notifications' import {ModerationScreen} from './view/screens/Moderation' import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists' +import {DiscoverFeedsScreen} from 'view/screens/DiscoverFeeds' import {NotFoundScreen} from './view/screens/NotFound' import {SettingsScreen} from './view/screens/Settings' import {ProfileScreen} from './view/screens/Profile' import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' import {ProfileFollowsScreen} from './view/screens/ProfileFollows' +import {CustomFeedScreen} from './view/screens/CustomFeed' +import {CustomFeedLikedByScreen} from './view/screens/CustomFeedLikedBy' import {ProfileListScreen} from './view/screens/ProfileList' import {PostThreadScreen} from './view/screens/PostThread' import {PostLikedByScreen} from './view/screens/PostLikedBy' @@ -54,6 +59,7 @@ import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy' import {AppPasswords} from 'view/screens/AppPasswords' import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts' import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' +import {SavedFeeds} from 'view/screens/SavedFeeds' import {getRoutingInstrumentation} from 'lib/sentry' import {bskyTitle} from 'lib/strings/headings' @@ -61,6 +67,7 @@ const navigationRef = createNavigationContainerRef() const HomeTab = createNativeStackNavigator() const SearchTab = createNativeStackNavigator() +const FeedsTab = createNativeStackNavigator() const NotificationsTab = createNativeStackNavigator() const MyProfileTab = createNativeStackNavigator() @@ -100,6 +107,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { component={ModerationBlockedAccounts} options={{title: title('Blocked Accounts')}} /> + ({title: title(`Post by @${route.params.name}`)})} /> + + + ) } @@ -201,11 +228,12 @@ function TabsNavigator() { screenOptions={{headerShown: false}} tabBar={tabBar}> + + - ) @@ -245,6 +273,23 @@ function SearchTabNavigator() { ) } +function FeedsTabNavigator() { + const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) + return ( + + + {commonScreens(FeedsTab as typeof HomeTab)} + + ) +} + function NotificationsTabNavigator() { const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) return ( @@ -318,6 +363,11 @@ const FlatNavigator = observer(() => { component={SearchScreen} options={{title: title('Search')}} /> + b.ts.localeCompare(a.ts)) - for (const slice of slices) { for (const item of slice.items) { this.seenUris.add(item.post.uri) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 3877b3ef..81b61a44 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -18,6 +18,7 @@ export interface ExternalEmbedDraft { uri: string isLoading: boolean meta?: LinkMeta + embed?: AppBskyEmbedRecord.Main localThumb?: ImageModel } @@ -135,40 +136,54 @@ export async function post(store: RootStoreModel, opts: PostOpts) { } if (opts.extLink && !opts.images?.length) { - let thumb - if (opts.extLink.localThumb) { - opts.onStateChange?.('Uploading link thumbnail...') - let encoding - if (opts.extLink.localThumb.mime) { - encoding = opts.extLink.localThumb.mime - } else if (opts.extLink.localThumb.path.endsWith('.png')) { - encoding = 'image/png' - } else if ( - opts.extLink.localThumb.path.endsWith('.jpeg') || - opts.extLink.localThumb.path.endsWith('.jpg') - ) { - encoding = 'image/jpeg' - } else { - store.log.warn( - 'Unexpected image format for thumbnail, skipping', - opts.extLink.localThumb.path, - ) + if (opts.extLink.embed) { + embed = opts.extLink.embed + } else { + let thumb + if (opts.extLink.localThumb) { + opts.onStateChange?.('Uploading link thumbnail...') + let encoding + if (opts.extLink.localThumb.mime) { + encoding = opts.extLink.localThumb.mime + } else if (opts.extLink.localThumb.path.endsWith('.png')) { + encoding = 'image/png' + } else if ( + opts.extLink.localThumb.path.endsWith('.jpeg') || + opts.extLink.localThumb.path.endsWith('.jpg') + ) { + encoding = 'image/jpeg' + } else { + store.log.warn( + 'Unexpected image format for thumbnail, skipping', + opts.extLink.localThumb.path, + ) + } + if (encoding) { + const thumbUploadRes = await uploadBlob( + store, + opts.extLink.localThumb.path, + encoding, + ) + thumb = thumbUploadRes.data.blob + } } - if (encoding) { - const thumbUploadRes = await uploadBlob( - store, - opts.extLink.localThumb.path, - encoding, - ) - thumb = thumbUploadRes.data.blob - } - } - if (opts.quote) { - embed = { - $type: 'app.bsky.embed.recordWithMedia', - record: embed, - media: { + if (opts.quote) { + embed = { + $type: 'app.bsky.embed.recordWithMedia', + record: embed, + media: { + $type: 'app.bsky.embed.external', + external: { + uri: opts.extLink.uri, + title: opts.extLink.meta?.title || '', + description: opts.extLink.meta?.description || '', + thumb, + }, + } as AppBskyEmbedExternal.Main, + } as AppBskyEmbedRecordWithMedia.Main + } else { + embed = { $type: 'app.bsky.embed.external', external: { uri: opts.extLink.uri, @@ -176,18 +191,8 @@ export async function post(store: RootStoreModel, opts: PostOpts) { description: opts.extLink.meta?.description || '', thumb, }, - } as AppBskyEmbedExternal.Main, - } as AppBskyEmbedRecordWithMedia.Main - } else { - embed = { - $type: 'app.bsky.embed.external', - external: { - uri: opts.extLink.uri, - title: opts.extLink.meta?.title || '', - description: opts.extLink.meta?.description || '', - thumb, - }, - } as AppBskyEmbedExternal.Main + } as AppBskyEmbedExternal.Main + } } } diff --git a/src/lib/async/revertible.ts b/src/lib/async/revertible.ts index 3c8e3e8f..43383b61 100644 --- a/src/lib/async/revertible.ts +++ b/src/lib/async/revertible.ts @@ -4,6 +4,22 @@ import set from 'lodash.set' const ongoingActions = new Set() +/** + * This is a TypeScript function that optimistically updates data on the client-side before sending a + * request to the server and rolling back changes if the request fails. + * @param {T} model - The object or record that needs to be updated optimistically. + * @param preUpdate - `preUpdate` is a function that is called before the server update is executed. It + * can be used to perform any necessary actions or updates on the model or UI before the server update + * is initiated. + * @param serverUpdate - `serverUpdate` is a function that returns a Promise representing the server + * update operation. This function is called after the previous state of the model has been recorded + * and the `preUpdate` function has been executed. If the server update is successful, the `postUpdate` + * function is called with the result + * @param [postUpdate] - `postUpdate` is an optional callback function that will be called after the + * server update is successful. It takes in the response from the server update as its parameter. If + * this parameter is not provided, nothing will happen after the server update. + * @returns A Promise that resolves to `void`. + */ export const updateDataOptimistically = async < T extends Record, U, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 6d0d4797..41aab143 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -94,6 +94,49 @@ export function SUGGESTED_FOLLOWS(serviceUrl: string) { } } +export const STAGING_DEFAULT_FEED = (rkey: string) => + `at://did:plc:wqzurwm3kmaig6e6hnc2gqwo/app.bsky.feed.generator/${rkey}` +export const PROD_DEFAULT_FEED = (rkey: string) => + `at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}` +export async function DEFAULT_FEEDS( + serviceUrl: string, + resolveHandle: (name: string) => Promise, +) { + if (serviceUrl.includes('localhost')) { + // local dev + const aliceDid = await resolveHandle('alice.test') + return { + pinned: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`], + saved: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`], + } + } else if (serviceUrl.includes('staging')) { + // staging + return { + pinned: [STAGING_DEFAULT_FEED('whats-hot')], + saved: [ + STAGING_DEFAULT_FEED('bsky-team'), + STAGING_DEFAULT_FEED('with-friends'), + STAGING_DEFAULT_FEED('whats-hot'), + STAGING_DEFAULT_FEED('hot-classic'), + ], + } + } else { + // production + return { + pinned: [ + PROD_DEFAULT_FEED('whats-hot'), + PROD_DEFAULT_FEED('with-friends'), + ], + saved: [ + PROD_DEFAULT_FEED('bsky-team'), + PROD_DEFAULT_FEED('with-friends'), + PROD_DEFAULT_FEED('whats-hot'), + PROD_DEFAULT_FEED('hot-classic'), + ], + } + } +} + export const POST_IMG_MAX = { width: 2000, height: 2000, diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts new file mode 100644 index 00000000..516940c1 --- /dev/null +++ b/src/lib/haptics.ts @@ -0,0 +1,40 @@ +import {isIOS, isWeb} from 'platform/detection' +import ReactNativeHapticFeedback, { + HapticFeedbackTypes, +} from 'react-native-haptic-feedback' + +const hapticImpact: HapticFeedbackTypes = isIOS ? 'impactMedium' : 'impactLight' // Users said the medium impact was too strong on Android; see APP-537s + +export class Haptics { + static default() { + if (isWeb) { + return + } + ReactNativeHapticFeedback.trigger(hapticImpact) + } + static impact(type: HapticFeedbackTypes = hapticImpact) { + if (isWeb) { + return + } + ReactNativeHapticFeedback.trigger(type) + } + static selection() { + if (isWeb) { + return + } + ReactNativeHapticFeedback.trigger('selection') + } + static notification = (type: 'success' | 'warning' | 'error') => { + if (isWeb) { + return + } + switch (type) { + case 'success': + return ReactNativeHapticFeedback.trigger('notificationSuccess') + case 'warning': + return ReactNativeHapticFeedback.trigger('notificationWarning') + case 'error': + return ReactNativeHapticFeedback.trigger('notificationError') + } + } +} diff --git a/src/lib/hooks/useCustomFeed.ts b/src/lib/hooks/useCustomFeed.ts new file mode 100644 index 00000000..d7a27050 --- /dev/null +++ b/src/lib/hooks/useCustomFeed.ts @@ -0,0 +1,27 @@ +import {useEffect, useState} from 'react' +import {useStores} from 'state/index' +import {CustomFeedModel} from 'state/models/feeds/custom-feed' + +export function useCustomFeed(uri: string): CustomFeedModel | undefined { + const store = useStores() + const [item, setItem] = useState() + useEffect(() => { + async function fetchView() { + const res = await store.agent.app.bsky.feed.getFeedGenerator({ + feed: uri, + }) + const view = res.data.view + return view + } + async function buildFeedItem() { + const view = await fetchView() + if (view) { + const temp = new CustomFeedModel(store, view) + setItem(temp) + } + } + buildFeedItem() + }, [store, uri]) + + return item +} diff --git a/src/lib/hooks/useDraggableScrollView.ts b/src/lib/hooks/useDraggableScrollView.ts new file mode 100644 index 00000000..b0f7465d --- /dev/null +++ b/src/lib/hooks/useDraggableScrollView.ts @@ -0,0 +1,84 @@ +import {useEffect, useRef, useMemo, ForwardedRef} from 'react' +import {Platform, findNodeHandle} from 'react-native' +import type {ScrollView} from 'react-native' +import {mergeRefs} from 'lib/merge-refs' + +type Props = { + cursor?: string + outerRef?: ForwardedRef +} + +export function useDraggableScroll({ + outerRef, + cursor = 'grab', +}: Props = {}) { + const ref = useRef(null) + + useEffect(() => { + if (Platform.OS !== 'web' || !ref.current) { + return + } + const slider = findNodeHandle(ref.current) as unknown as HTMLDivElement + if (!slider) { + return + } + let isDragging = false + let isMouseDown = false + let startX = 0 + let scrollLeft = 0 + + const mouseDown = (e: MouseEvent) => { + isMouseDown = true + startX = e.pageX - slider.offsetLeft + scrollLeft = slider.scrollLeft + + slider.style.cursor = cursor + } + + const mouseUp = () => { + if (isDragging) { + slider.addEventListener('click', e => e.stopPropagation(), {once: true}) + } + + isMouseDown = false + isDragging = false + slider.style.cursor = 'default' + } + + const mouseMove = (e: MouseEvent) => { + if (!isMouseDown) { + return + } + + // Require n pixels momement before start of drag (3 in this case ) + const x = e.pageX - slider.offsetLeft + if (Math.abs(x - startX) < 3) { + return + } + + isDragging = true + e.preventDefault() + const walk = x - startX + slider.scrollLeft = scrollLeft - walk + } + + slider.addEventListener('mousedown', mouseDown) + window.addEventListener('mouseup', mouseUp) + window.addEventListener('mousemove', mouseMove) + + return () => { + slider.removeEventListener('mousedown', mouseDown) + window.removeEventListener('mouseup', mouseUp) + window.removeEventListener('mousemove', mouseMove) + } + }, [cursor]) + + const refs = useMemo( + () => mergeRefs(outerRef ? [ref, outerRef] : [ref]), + [ref, outerRef], + ) + + return { + refs, + } +} diff --git a/src/lib/hooks/useNavigationTabState.ts b/src/lib/hooks/useNavigationTabState.ts index fb366215..3a05fe52 100644 --- a/src/lib/hooks/useNavigationTabState.ts +++ b/src/lib/hooks/useNavigationTabState.ts @@ -6,14 +6,16 @@ export function useNavigationTabState() { const res = { isAtHome: getTabState(state, 'Home') !== TabState.Outside, isAtSearch: getTabState(state, 'Search') !== TabState.Outside, + isAtFeeds: getTabState(state, 'Feeds') !== TabState.Outside, isAtNotifications: getTabState(state, 'Notifications') !== TabState.Outside, isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside, } if ( !res.isAtHome && - !res.isAtNotifications && !res.isAtSearch && + !res.isAtFeeds && + !res.isAtNotifications && !res.isAtMyProfile ) { // HACK for some reason useNavigationState will give us pre-hydration results diff --git a/src/lib/hooks/useOnMainScroll.ts b/src/lib/hooks/useOnMainScroll.ts index 41b35dd4..12e42aca 100644 --- a/src/lib/hooks/useOnMainScroll.ts +++ b/src/lib/hooks/useOnMainScroll.ts @@ -1,25 +1,56 @@ -import {useState} from 'react' +import {useState, useCallback, useRef} from 'react' import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' import {RootStoreModel} from 'state/index' +import {s} from 'lib/styles' +import {isDesktopWeb} from 'platform/detection' + +const DY_LIMIT = isDesktopWeb ? 30 : 10 export type OnScrollCb = ( event: NativeSyntheticEvent, ) => void +export type ResetCb = () => void -export function useOnMainScroll(store: RootStoreModel) { - let [lastY, setLastY] = useState(0) - let isMinimal = store.shell.minimalShellMode - return function onMainScroll(event: NativeSyntheticEvent) { - const y = event.nativeEvent.contentOffset.y - const dy = y - (lastY || 0) - setLastY(y) +export function useOnMainScroll( + store: RootStoreModel, +): [OnScrollCb, boolean, ResetCb] { + let lastY = useRef(0) + let [isScrolledDown, setIsScrolledDown] = useState(false) + return [ + useCallback( + (event: NativeSyntheticEvent) => { + const y = event.nativeEvent.contentOffset.y + const dy = y - (lastY.current || 0) + lastY.current = y - if (!isMinimal && y > 10 && dy > 10) { - store.shell.setMinimalShellMode(true) - isMinimal = true - } else if (isMinimal && (y <= 10 || dy < -10)) { + if (!store.shell.minimalShellMode && y > 10 && dy > DY_LIMIT) { + store.shell.setMinimalShellMode(true) + } else if ( + store.shell.minimalShellMode && + (y <= 10 || dy < DY_LIMIT * -1) + ) { + store.shell.setMinimalShellMode(false) + } + + if ( + !isScrolledDown && + event.nativeEvent.contentOffset.y > s.window.height + ) { + setIsScrolledDown(true) + } else if ( + isScrolledDown && + event.nativeEvent.contentOffset.y < s.window.height + ) { + setIsScrolledDown(false) + } + }, + [store, isScrolledDown], + ), + isScrolledDown, + useCallback(() => { + setIsScrolledDown(false) store.shell.setMinimalShellMode(false) - isMinimal = false - } - } + lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf + }, [store, setIsScrolledDown]), + ] } diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index 06f19501..47a38fb6 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -1,6 +1,6 @@ import React from 'react' import {StyleProp, TextStyle, ViewStyle} from 'react-native' -import Svg, {Path, Rect, Line, Ellipse} from 'react-native-svg' +import Svg, {Path, Rect, Line, Ellipse, Circle} from 'react-native-svg' export function GridIcon({ style, @@ -472,7 +472,7 @@ export function HeartIcon({ size = 24, strokeWidth = 1.5, }: { - style?: StyleProp + style?: StyleProp size?: string | number strokeWidth: number }) { @@ -493,7 +493,7 @@ export function HeartIconSolid({ style, size = 24, }: { - style?: StyleProp + style?: StyleProp size?: string | number }) { return ( @@ -883,3 +883,77 @@ export function HandIcon({ ) } + +export function SatelliteDishIconSolid({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + + + + ) +} + +export function SatelliteDishIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + + + + ) +} diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts index f4a96a22..cf43feca 100644 --- a/src/lib/link-meta/bsky.ts +++ b/src/lib/link-meta/bsky.ts @@ -1,3 +1,4 @@ +import * as apilib from 'lib/api/index' import {LikelyType, LinkMeta} from './link-meta' // import {match as matchRoute} from 'view/routes' import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers' @@ -128,3 +129,29 @@ export async function getPostAsQuote( }, } } + +export async function getFeedAsEmbed( + store: RootStoreModel, + url: string, +): Promise { + url = convertBskyAppUrlIfNeeded(url) + const [_0, user, _1, rkey] = url.split('/').filter(Boolean) + const feed = makeRecordUri(user, 'app.bsky.feed.generator', rkey) + const res = await store.agent.app.bsky.feed.getFeedGenerator({feed}) + return { + isLoading: false, + uri: feed, + meta: { + url: feed, + likelyType: LikelyType.AtpData, + title: res.data.view.displayName, + }, + embed: { + $type: 'app.bsky.embed.record', + record: { + uri: res.data.view.uri, + cid: res.data.view.cid, + }, + }, + } +} diff --git a/src/lib/merge-refs.ts b/src/lib/merge-refs.ts new file mode 100644 index 00000000..4617b526 --- /dev/null +++ b/src/lib/merge-refs.ts @@ -0,0 +1,27 @@ +/** + * This TypeScript function merges multiple React refs into a single ref callback. + * When developing low level UI components, it is common to have to use a local ref + * but also support an external one using React.forwardRef. + * Natively, React does not offer a way to set two refs inside the ref property. This is the goal of this small utility. + * Today a ref can be a function or an object, tomorrow it could be another thing, who knows. + * This utility handles compatibility for you. + * This function is inspired by https://github.com/gregberge/react-merge-refs + * @param refs - An array of React refs, which can be either `React.MutableRefObject` or + * `React.LegacyRef`. These refs are used to store references to DOM elements or React components. + * The `mergeRefs` function takes in an array of these refs and returns a callback function that + * @returns The function `mergeRefs` is being returned. It takes an array of mutable or legacy refs and + * returns a ref callback function that can be used to merge multiple refs into a single ref. + */ +export function mergeRefs( + refs: Array | React.LegacyRef>, +): React.RefCallback { + return value => { + refs.forEach(ref => { + if (typeof ref === 'function') { + ref(value) + } else if (ref != null) { + ;(ref as React.MutableRefObject).current = value + } + }) + } +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 56775dee..4eb5e29d 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -9,6 +9,7 @@ export type CommonNavigatorParams = { ModerationMuteLists: undefined ModerationMutedAccounts: undefined ModerationBlockedAccounts: undefined + DiscoverFeeds: undefined Settings: undefined Profile: {name: string; hideBackButton?: boolean} ProfileFollowers: {name: string} @@ -17,6 +18,8 @@ export type CommonNavigatorParams = { PostThread: {name: string; rkey: string} PostLikedBy: {name: string; rkey: string} PostRepostedBy: {name: string; rkey: string} + CustomFeed: {name: string; rkey: string} + CustomFeedLikedBy: {name: string; rkey: string} Debug: undefined Log: undefined Support: undefined @@ -25,11 +28,13 @@ export type CommonNavigatorParams = { CommunityGuidelines: undefined CopyrightPolicy: undefined AppPasswords: undefined + SavedFeeds: undefined } export type BottomTabNavigatorParams = CommonNavigatorParams & { HomeTab: undefined SearchTab: undefined + FeedsTab: undefined NotificationsTab: undefined MyProfileTab: undefined } @@ -42,6 +47,10 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & { Search: {q?: string} } +export type FeedsTabNavigatorParams = CommonNavigatorParams & { + Feeds: undefined +} + export type NotificationsTabNavigatorParams = CommonNavigatorParams & { Notifications: undefined } @@ -53,6 +62,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & { export type FlatNavigatorParams = CommonNavigatorParams & { Home: undefined Search: {q?: string} + Feeds: undefined Notifications: undefined } @@ -61,6 +71,8 @@ export type AllNavigatorParams = CommonNavigatorParams & { Home: undefined SearchTab: undefined Search: {q?: string} + FeedsTab: undefined + Feeds: undefined NotificationsTab: undefined Notifications: undefined MyProfileTab: undefined diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index a5412920..d6d43b89 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -82,6 +82,18 @@ export function isBskyPostUrl(url: string): boolean { return false } +export function isBskyCustomFeedUrl(url: string): boolean { + if (isBskyAppUrl(url)) { + try { + const urlp = new URL(url) + return /profile\/(?[^/]+)\/feed\/(?[^/]+)/i.test( + urlp.pathname, + ) + } catch {} + } + return false +} + export function convertBskyAppUrlIfNeeded(url: string): string { if (isBskyAppUrl(url)) { try { diff --git a/src/lib/styles.ts b/src/lib/styles.ts index 00a8638f..fb631c0b 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -1,4 +1,4 @@ -import {StyleProp, StyleSheet, TextStyle} from 'react-native' +import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native' import {Theme, TypographyVariant} from './ThemeContext' import {isMobileWeb} from 'platform/detection' @@ -52,6 +52,7 @@ export const colors = { green5: '#082b03', unreadNotifBg: '#ebf6ff', + brandBlue: '#0066FF', } export const gradients = { @@ -169,6 +170,10 @@ export const s = StyleSheet.create({ w100pct: {width: '100%'}, h100pct: {height: '100%'}, hContentRegion: isMobileWeb ? {flex: 1} : {height: '100%'}, + window: { + width: Dimensions.get('window').width, + height: Dimensions.get('window').height, + }, // text align textLeft: {textAlign: 'left'}, @@ -214,6 +219,8 @@ export const s = StyleSheet.create({ green3: {color: colors.green3}, green4: {color: colors.green4}, green5: {color: colors.green5}, + + brandBlue: {color: colors.brandBlue}, }) export function lh( diff --git a/src/routes.ts b/src/routes.ts index 571aca7f..54faba22 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -3,6 +3,8 @@ import {Router} from 'lib/routes/router' export const router = new Router({ Home: '/', Search: '/search', + Feeds: '/feeds', + DiscoverFeeds: '/search/feeds', Notifications: '/notifications', Settings: '/settings', Moderation: '/moderation', @@ -16,9 +18,12 @@ export const router = new Router({ PostThread: '/profile/:name/post/:rkey', PostLikedBy: '/profile/:name/post/:rkey/liked-by', PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', + CustomFeed: '/profile/:name/feed/:rkey', + CustomFeedLikedBy: '/profile/:name/feed/:rkey/liked-by', Debug: '/sys/debug', Log: '/sys/log', AppPasswords: '/settings/app-passwords', + SavedFeeds: '/settings/saved-feeds', Support: '/support', PrivacyPolicy: '/support/privacy', TermsOfService: '/support/tos', diff --git a/src/state/models/discovery/feeds.ts b/src/state/models/discovery/feeds.ts new file mode 100644 index 00000000..26a8d650 --- /dev/null +++ b/src/state/models/discovery/feeds.ts @@ -0,0 +1,97 @@ +import {makeAutoObservable} from 'mobx' +import {AppBskyUnspeccedGetPopularFeedGenerators} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {bundleAsync} from 'lib/async/bundle' +import {cleanError} from 'lib/strings/errors' +import {CustomFeedModel} from '../feeds/custom-feed' + +export class FeedsDiscoveryModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + + // data + feeds: CustomFeedModel[] = [] + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasMore() { + return false + } + + get hasContent() { + return this.feeds.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + refresh = bundleAsync(async () => { + this._xLoading() + try { + const res = + await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators( + {}, + ) + this._replaceAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + clear() { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = false + this.error = '' + this.feeds = [] + } + + // state transitions + // = + + _xLoading() { + this.isLoading = true + this.isRefreshing = true + this.error = '' + } + + _xIdle(err?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + if (err) { + this.rootStore.log.error('Failed to fetch popular feeds', err) + } + } + + // helper functions + // = + + _replaceAll(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) { + this.feeds = [] + for (const f of res.data.feeds) { + this.feeds.push(new CustomFeedModel(this.rootStore, f)) + } + } +} diff --git a/src/state/models/feeds/custom-feed.ts b/src/state/models/feeds/custom-feed.ts new file mode 100644 index 00000000..8fc1eb1e --- /dev/null +++ b/src/state/models/feeds/custom-feed.ts @@ -0,0 +1,120 @@ +import {AppBskyFeedDefs} from '@atproto/api' +import {makeAutoObservable, runInAction} from 'mobx' +import {RootStoreModel} from 'state/models/root-store' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {updateDataOptimistically} from 'lib/async/revertible' + +export class CustomFeedModel { + // data + _reactKey: string + data: AppBskyFeedDefs.GeneratorView + isOnline: boolean + isValid: boolean + + constructor( + public rootStore: RootStoreModel, + view: AppBskyFeedDefs.GeneratorView, + isOnline?: boolean, + isValid?: boolean, + ) { + this._reactKey = view.uri + this.data = view + this.isOnline = isOnline ?? true + this.isValid = isValid ?? true + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + // local actions + // = + + get uri() { + return this.data.uri + } + + get displayName() { + if (this.data.displayName) { + return sanitizeDisplayName(this.data.displayName) + } + return `Feed by @${this.data.creator.handle}` + } + + get isSaved() { + return this.rootStore.preferences.savedFeeds.includes(this.uri) + } + + get isLiked() { + return this.data.viewer?.like + } + + // public apis + // = + + async save() { + await this.rootStore.preferences.addSavedFeed(this.uri) + } + + async unsave() { + await this.rootStore.preferences.removeSavedFeed(this.uri) + } + + async like() { + try { + await updateDataOptimistically( + this.data, + () => { + this.data.viewer = this.data.viewer || {} + this.data.viewer.like = 'pending' + this.data.likeCount = (this.data.likeCount || 0) + 1 + }, + () => this.rootStore.agent.like(this.data.uri, this.data.cid), + res => { + this.data.viewer = this.data.viewer || {} + this.data.viewer.like = res.uri + }, + ) + } catch (e: any) { + this.rootStore.log.error('Failed to like feed', e) + } + } + + async unlike() { + if (!this.data.viewer?.like) { + return + } + try { + const likeUri = this.data.viewer.like + await updateDataOptimistically( + this.data, + () => { + this.data.viewer = this.data.viewer || {} + this.data.viewer.like = undefined + this.data.likeCount = (this.data.likeCount || 1) - 1 + }, + () => this.rootStore.agent.deleteLike(likeUri), + ) + } catch (e: any) { + this.rootStore.log.error('Failed to unlike feed', e) + } + } + + async reload() { + const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ + feed: this.data.uri, + }) + runInAction(() => { + this.data = res.data.view + this.isOnline = res.data.isOnline + this.isValid = res.data.isValid + }) + } + + serialize() { + return JSON.stringify(this.data) + } +} diff --git a/src/state/models/feeds/multi-feed.ts b/src/state/models/feeds/multi-feed.ts new file mode 100644 index 00000000..3c13041c --- /dev/null +++ b/src/state/models/feeds/multi-feed.ts @@ -0,0 +1,216 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {AtUri} from '@atproto/api' +import {bundleAsync} from 'lib/async/bundle' +import {RootStoreModel} from '../root-store' +import {CustomFeedModel} from './custom-feed' +import {PostsFeedModel} from './posts' +import {PostsFeedSliceModel} from './post' + +const FEED_PAGE_SIZE = 5 +const FEEDS_PAGE_SIZE = 3 + +export type MultiFeedItem = + | { + _reactKey: string + type: 'header' + } + | { + _reactKey: string + type: 'feed-header' + avatar: string | undefined + title: string + } + | { + _reactKey: string + type: 'feed-slice' + slice: PostsFeedSliceModel + } + | { + _reactKey: string + type: 'feed-loading' + } + | { + _reactKey: string + type: 'feed-error' + error: string + } + | { + _reactKey: string + type: 'feed-footer' + title: string + uri: string + } + | { + _reactKey: string + type: 'footer' + } + +export class PostsMultiFeedModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + hasMore = true + + // data + feedInfos: CustomFeedModel[] = [] + feeds: PostsFeedModel[] = [] + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable(this, {rootStore: false}, {autoBind: true}) + } + + get hasContent() { + return this.feeds.length !== 0 + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + get items() { + const items: MultiFeedItem[] = [{_reactKey: '__header__', type: 'header'}] + for (let i = 0; i < this.feedInfos.length; i++) { + if (!this.feeds[i]) { + break + } + const feed = this.feeds[i] + const feedInfo = this.feedInfos[i] + const urip = new AtUri(feedInfo.uri) + items.push({ + _reactKey: `__feed_header_${i}__`, + type: 'feed-header', + avatar: feedInfo.data.avatar, + title: feedInfo.displayName, + }) + if (feed.isLoading) { + items.push({ + _reactKey: `__feed_loading_${i}__`, + type: 'feed-loading', + }) + } else if (feed.hasError) { + items.push({ + _reactKey: `__feed_error_${i}__`, + type: 'feed-error', + error: feed.error, + }) + } else { + for (let j = 0; j < feed.slices.length; j++) { + items.push({ + _reactKey: `__feed_slice_${i}_${j}__`, + type: 'feed-slice', + slice: feed.slices[j], + }) + } + } + items.push({ + _reactKey: `__feed_footer_${i}__`, + type: 'feed-footer', + title: feedInfo.displayName, + uri: `/profile/${feedInfo.data.creator.did}/feed/${urip.rkey}`, + }) + } + if (!this.hasMore) { + items.push({_reactKey: '__footer__', type: 'footer'}) + } + return items + } + + // public api + // = + + /** + * Nuke all data + */ + clear() { + this.rootStore.log.debug('MultiFeedModel:clear') + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = false + this.hasMore = true + this.feeds = [] + } + + /** + * Register any event listeners. Returns a cleanup function. + */ + registerListeners() { + const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this)) + return () => sub.remove() + } + + /** + * Reset and load + */ + async refresh() { + this.feedInfos = this.rootStore.me.savedFeeds.all.slice() // capture current feeds + await this.loadMore(true) + } + + /** + * Load more posts to the end of the feed + */ + loadMore = bundleAsync(async (isRefreshing: boolean = false) => { + if (!isRefreshing && !this.hasMore) { + return + } + if (isRefreshing) { + this.isRefreshing = true // set optimistically for UI + this.feeds = [] + } + this._xLoading(isRefreshing) + const start = this.feeds.length + const newFeeds: PostsFeedModel[] = [] + for ( + let i = start; + i < start + FEEDS_PAGE_SIZE && i < this.feedInfos.length; + i++ + ) { + const feed = new PostsFeedModel(this.rootStore, 'custom', { + feed: this.feedInfos[i].uri, + }) + feed.pageSize = FEED_PAGE_SIZE + await feed.setup() + newFeeds.push(feed) + } + runInAction(() => { + this.feeds = this.feeds.concat(newFeeds) + this.hasMore = this.feeds.length < this.feedInfos.length + }) + this._xIdle() + }) + + /** + * Attempt to load more again after a failure + */ + async retryLoadMore() { + this.hasMore = true + return this.loadMore() + } + + /** + * Removes posts from the feed upon deletion. + */ + onPostDeleted(uri: string) { + for (const f of this.feeds) { + f.onPostDeleted(uri) + } + } + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + } + + _xIdle() { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + } + + // helper functions + // = +} diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 73424f03..3777abb9 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -290,7 +290,9 @@ export class NotificationsFeedModel { } get hasNewLatest() { - return this.queuedNotifications && this.queuedNotifications?.length > 0 + return Boolean( + this.queuedNotifications && this.queuedNotifications?.length > 0, + ) } get unreadCountLabel(): string { diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts new file mode 100644 index 00000000..0c411d44 --- /dev/null +++ b/src/state/models/feeds/post.ts @@ -0,0 +1,265 @@ +import {makeAutoObservable} from 'mobx' +import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {updateDataOptimistically} from 'lib/async/revertible' +import {PostLabelInfo, PostModeration} from 'lib/labeling/types' +import {FeedViewPostsSlice} from 'lib/api/feed-manip' +import { + getEmbedLabels, + getEmbedMuted, + getEmbedMutedByList, + getEmbedBlocking, + getEmbedBlockedBy, + getPostModeration, + filterAccountLabels, + filterProfileLabels, + mergePostModerations, +} from 'lib/labeling/helpers' + +type FeedViewPost = AppBskyFeedDefs.FeedViewPost +type ReasonRepost = AppBskyFeedDefs.ReasonRepost +type PostView = AppBskyFeedDefs.PostView + +let _idCounter = 0 + +export class PostsFeedItemModel { + // ui state + _reactKey: string = '' + + // data + post: PostView + postRecord?: AppBskyFeedPost.Record + reply?: FeedViewPost['reply'] + reason?: FeedViewPost['reason'] + richText?: RichText + + constructor( + public rootStore: RootStoreModel, + reactKey: string, + v: FeedViewPost, + ) { + this._reactKey = reactKey + this.post = v.post + if (AppBskyFeedPost.isRecord(this.post.record)) { + const valid = AppBskyFeedPost.validateRecord(this.post.record) + if (valid.success) { + this.postRecord = this.post.record + this.richText = new RichText(this.postRecord, {cleanNewlines: true}) + } else { + this.postRecord = undefined + this.richText = undefined + rootStore.log.warn( + 'Received an invalid app.bsky.feed.post record', + valid.error, + ) + } + } else { + this.postRecord = undefined + this.richText = undefined + rootStore.log.warn( + 'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type', + this.post.record, + ) + } + this.reply = v.reply + this.reason = v.reason + makeAutoObservable(this, {rootStore: false}) + } + + get rootUri(): string { + if (this.reply?.root.uri) { + return this.reply.root.uri + } + return this.post.uri + } + + get isThreadMuted() { + return this.rootStore.mutedThreads.uris.has(this.rootUri) + } + + get labelInfo(): PostLabelInfo { + return { + postLabels: (this.post.labels || []).concat( + getEmbedLabels(this.post.embed), + ), + accountLabels: filterAccountLabels(this.post.author.labels), + profileLabels: filterProfileLabels(this.post.author.labels), + isMuted: + this.post.author.viewer?.muted || + getEmbedMuted(this.post.embed) || + false, + mutedByList: + this.post.author.viewer?.mutedByList || + getEmbedMutedByList(this.post.embed), + isBlocking: + !!this.post.author.viewer?.blocking || + getEmbedBlocking(this.post.embed) || + false, + isBlockedBy: + !!this.post.author.viewer?.blockedBy || + getEmbedBlockedBy(this.post.embed) || + false, + } + } + + get moderation(): PostModeration { + return getPostModeration(this.rootStore, this.labelInfo) + } + + copy(v: FeedViewPost) { + this.post = v.post + this.reply = v.reply + this.reason = v.reason + } + + copyMetrics(v: FeedViewPost) { + this.post.replyCount = v.post.replyCount + this.post.repostCount = v.post.repostCount + this.post.likeCount = v.post.likeCount + this.post.viewer = v.post.viewer + } + + get reasonRepost(): ReasonRepost | undefined { + if (this.reason?.$type === 'app.bsky.feed.defs#reasonRepost') { + return this.reason as ReasonRepost + } + } + + async toggleLike() { + this.post.viewer = this.post.viewer || {} + if (this.post.viewer.like) { + const url = this.post.viewer.like + await updateDataOptimistically( + this.post, + () => { + this.post.likeCount = (this.post.likeCount || 0) - 1 + this.post.viewer!.like = undefined + }, + () => this.rootStore.agent.deleteLike(url), + ) + } else { + await updateDataOptimistically( + this.post, + () => { + this.post.likeCount = (this.post.likeCount || 0) + 1 + this.post.viewer!.like = 'pending' + }, + () => this.rootStore.agent.like(this.post.uri, this.post.cid), + res => { + this.post.viewer!.like = res.uri + }, + ) + } + } + + async toggleRepost() { + this.post.viewer = this.post.viewer || {} + if (this.post.viewer?.repost) { + const url = this.post.viewer.repost + await updateDataOptimistically( + this.post, + () => { + this.post.repostCount = (this.post.repostCount || 0) - 1 + this.post.viewer!.repost = undefined + }, + () => this.rootStore.agent.deleteRepost(url), + ) + } else { + await updateDataOptimistically( + this.post, + () => { + this.post.repostCount = (this.post.repostCount || 0) + 1 + this.post.viewer!.repost = 'pending' + }, + () => this.rootStore.agent.repost(this.post.uri, this.post.cid), + res => { + this.post.viewer!.repost = res.uri + }, + ) + } + } + + async toggleThreadMute() { + if (this.isThreadMuted) { + this.rootStore.mutedThreads.uris.delete(this.rootUri) + } else { + this.rootStore.mutedThreads.uris.add(this.rootUri) + } + } + + async delete() { + await this.rootStore.agent.deletePost(this.post.uri) + this.rootStore.emitPostDeleted(this.post.uri) + } +} + +export class PostsFeedSliceModel { + // ui state + _reactKey: string = '' + + // data + items: PostsFeedItemModel[] = [] + + constructor( + public rootStore: RootStoreModel, + reactKey: string, + slice: FeedViewPostsSlice, + ) { + this._reactKey = reactKey + for (const item of slice.items) { + this.items.push( + new PostsFeedItemModel(rootStore, `slice-${_idCounter++}`, item), + ) + } + makeAutoObservable(this, {rootStore: false}) + } + + get uri() { + if (this.isReply) { + return this.items[1].post.uri + } + return this.items[0].post.uri + } + + get isThread() { + return ( + this.items.length > 1 && + this.items.every( + item => item.post.author.did === this.items[0].post.author.did, + ) + ) + } + + get isReply() { + return this.items.length > 1 && !this.isThread + } + + get rootItem() { + if (this.isReply) { + return this.items[1] + } + return this.items[0] + } + + get moderation() { + return mergePostModerations(this.items.map(item => item.moderation)) + } + + containsUri(uri: string) { + return !!this.items.find(item => item.post.uri === uri) + } + + isThreadParentAt(i: number) { + if (this.items.length === 1) { + return false + } + return i < this.items.length - 1 + } + + isThreadChildAt(i: number) { + if (this.items.length === 1) { + return false + } + return i > 0 + } +} diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index b2dffdc6..911cc630 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -1,11 +1,8 @@ import {makeAutoObservable, runInAction} from 'mobx' import { AppBskyFeedGetTimeline as GetTimeline, - AppBskyFeedDefs, - AppBskyFeedPost, AppBskyFeedGetAuthorFeed as GetAuthorFeed, - RichText, - jsonToLex, + AppBskyFeedGetFeed as GetCustomFeed, } from '@atproto/api' import AwaitLock from 'await-lock' import {bundleAsync} from 'lib/async/bundle' @@ -19,269 +16,11 @@ import { mergePosts, } from 'lib/api/build-suggested-posts' import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' -import {updateDataOptimistically} from 'lib/async/revertible' -import {PostLabelInfo, PostModeration} from 'lib/labeling/types' -import { - getEmbedLabels, - getEmbedMuted, - getEmbedMutedByList, - getEmbedBlocking, - getEmbedBlockedBy, - getPostModeration, - mergePostModerations, - filterAccountLabels, - filterProfileLabels, -} from 'lib/labeling/helpers' - -type FeedViewPost = AppBskyFeedDefs.FeedViewPost -type ReasonRepost = AppBskyFeedDefs.ReasonRepost -type PostView = AppBskyFeedDefs.PostView +import {PostsFeedSliceModel} from './post' const PAGE_SIZE = 30 let _idCounter = 0 -export class PostsFeedItemModel { - // ui state - _reactKey: string = '' - - // data - post: PostView - postRecord?: AppBskyFeedPost.Record - reply?: FeedViewPost['reply'] - reason?: FeedViewPost['reason'] - richText?: RichText - - constructor( - public rootStore: RootStoreModel, - reactKey: string, - v: FeedViewPost, - ) { - this._reactKey = reactKey - this.post = v.post - if (AppBskyFeedPost.isRecord(this.post.record)) { - const valid = AppBskyFeedPost.validateRecord(this.post.record) - if (valid.success) { - this.postRecord = this.post.record - this.richText = new RichText(this.postRecord, {cleanNewlines: true}) - } else { - this.postRecord = undefined - this.richText = undefined - rootStore.log.warn( - 'Received an invalid app.bsky.feed.post record', - valid.error, - ) - } - } else { - this.postRecord = undefined - this.richText = undefined - rootStore.log.warn( - 'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type', - this.post.record, - ) - } - this.reply = v.reply - this.reason = v.reason - makeAutoObservable(this, {rootStore: false}) - } - - get rootUri(): string { - if (this.reply?.root.uri) { - return this.reply.root.uri - } - return this.post.uri - } - - get isThreadMuted() { - return this.rootStore.mutedThreads.uris.has(this.rootUri) - } - - get labelInfo(): PostLabelInfo { - return { - postLabels: (this.post.labels || []).concat( - getEmbedLabels(this.post.embed), - ), - accountLabels: filterAccountLabels(this.post.author.labels), - profileLabels: filterProfileLabels(this.post.author.labels), - isMuted: - this.post.author.viewer?.muted || - getEmbedMuted(this.post.embed) || - false, - mutedByList: - this.post.author.viewer?.mutedByList || - getEmbedMutedByList(this.post.embed), - isBlocking: - !!this.post.author.viewer?.blocking || - getEmbedBlocking(this.post.embed) || - false, - isBlockedBy: - !!this.post.author.viewer?.blockedBy || - getEmbedBlockedBy(this.post.embed) || - false, - } - } - - get moderation(): PostModeration { - return getPostModeration(this.rootStore, this.labelInfo) - } - - copy(v: FeedViewPost) { - this.post = v.post - this.reply = v.reply - this.reason = v.reason - } - - copyMetrics(v: FeedViewPost) { - this.post.replyCount = v.post.replyCount - this.post.repostCount = v.post.repostCount - this.post.likeCount = v.post.likeCount - this.post.viewer = v.post.viewer - } - - get reasonRepost(): ReasonRepost | undefined { - if (this.reason?.$type === 'app.bsky.feed.defs#reasonRepost') { - return this.reason as ReasonRepost - } - } - - async toggleLike() { - this.post.viewer = this.post.viewer || {} - if (this.post.viewer.like) { - const url = this.post.viewer.like - await updateDataOptimistically( - this.post, - () => { - this.post.likeCount = (this.post.likeCount || 0) - 1 - this.post.viewer!.like = undefined - }, - () => this.rootStore.agent.deleteLike(url), - ) - } else { - await updateDataOptimistically( - this.post, - () => { - this.post.likeCount = (this.post.likeCount || 0) + 1 - this.post.viewer!.like = 'pending' - }, - () => this.rootStore.agent.like(this.post.uri, this.post.cid), - res => { - this.post.viewer!.like = res.uri - }, - ) - } - } - - async toggleRepost() { - this.post.viewer = this.post.viewer || {} - if (this.post.viewer?.repost) { - const url = this.post.viewer.repost - await updateDataOptimistically( - this.post, - () => { - this.post.repostCount = (this.post.repostCount || 0) - 1 - this.post.viewer!.repost = undefined - }, - () => this.rootStore.agent.deleteRepost(url), - ) - } else { - await updateDataOptimistically( - this.post, - () => { - this.post.repostCount = (this.post.repostCount || 0) + 1 - this.post.viewer!.repost = 'pending' - }, - () => this.rootStore.agent.repost(this.post.uri, this.post.cid), - res => { - this.post.viewer!.repost = res.uri - }, - ) - } - } - - async toggleThreadMute() { - if (this.isThreadMuted) { - this.rootStore.mutedThreads.uris.delete(this.rootUri) - } else { - this.rootStore.mutedThreads.uris.add(this.rootUri) - } - } - - async delete() { - await this.rootStore.agent.deletePost(this.post.uri) - this.rootStore.emitPostDeleted(this.post.uri) - } -} - -export class PostsFeedSliceModel { - // ui state - _reactKey: string = '' - - // data - items: PostsFeedItemModel[] = [] - - constructor( - public rootStore: RootStoreModel, - reactKey: string, - slice: FeedViewPostsSlice, - ) { - this._reactKey = reactKey - for (const item of slice.items) { - this.items.push( - new PostsFeedItemModel(rootStore, `item-${_idCounter++}`, item), - ) - } - makeAutoObservable(this, {rootStore: false}) - } - - get uri() { - if (this.isReply) { - return this.items[1].post.uri - } - return this.items[0].post.uri - } - - get isThread() { - return ( - this.items.length > 1 && - this.items.every( - item => item.post.author.did === this.items[0].post.author.did, - ) - ) - } - - get isReply() { - return this.items.length > 1 && !this.isThread - } - - get rootItem() { - if (this.isReply) { - return this.items[1] - } - return this.items[0] - } - - get moderation() { - return mergePostModerations(this.items.map(item => item.moderation)) - } - - containsUri(uri: string) { - return !!this.items.find(item => item.post.uri === uri) - } - - isThreadParentAt(i: number) { - if (this.items.length === 1) { - return false - } - return i < this.items.length - 1 - } - - isThreadChildAt(i: number) { - if (this.items.length === 1) { - return false - } - return i > 0 - } -} - export class PostsFeedModel { // state isLoading = false @@ -297,6 +36,7 @@ export class PostsFeedModel { loadMoreCursor: string | undefined pollCursor: string | undefined tuner = new FeedTuner() + pageSize = PAGE_SIZE // used to linearize async modifications to state lock = new AwaitLock() @@ -309,8 +49,11 @@ export class PostsFeedModel { constructor( public rootStore: RootStoreModel, - public feedType: 'home' | 'author' | 'suggested' | 'goodstuff', - params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams, + public feedType: 'home' | 'author' | 'suggested' | 'custom', + params: + | GetTimeline.QueryParams + | GetAuthorFeed.QueryParams + | GetCustomFeed.QueryParams, ) { makeAutoObservable( this, @@ -387,10 +130,9 @@ export class PostsFeedModel { } get feedTuners() { - if (this.feedType === 'goodstuff') { + if (this.feedType === 'custom') { return [ FeedTuner.dedupReposts, - FeedTuner.likedRepliesOnly, FeedTuner.preferredLangOnly( this.rootStore.preferences.contentLanguages, ), @@ -416,7 +158,7 @@ export class PostsFeedModel { this.tuner.reset() this._xLoading(isRefreshing) try { - const res = await this._getFeed({limit: PAGE_SIZE}) + const res = await this._getFeed({limit: this.pageSize}) await this._replaceAll(res) this._xIdle() } catch (e: any) { @@ -455,7 +197,7 @@ export class PostsFeedModel { try { const res = await this._getFeed({ cursor: this.loadMoreCursor, - limit: PAGE_SIZE, + limit: this.pageSize, }) await this._appendAll(res) this._xIdle() @@ -524,7 +266,7 @@ export class PostsFeedModel { if (this.hasNewLatest || this.feedType === 'suggested') { return } - const res = await this._getFeed({limit: PAGE_SIZE}) + const res = await this._getFeed({limit: this.pageSize}) const tuner = new FeedTuner() const slices = tuner.tune(res.data.feed, this.feedTuners) this.setHasNewLatest(slices[0]?.uri !== this.slices[0]?.uri) @@ -599,13 +341,15 @@ export class PostsFeedModel { // helper functions // = - async _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) { + async _replaceAll( + res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, + ) { this.pollCursor = res.data.feed[0]?.post.uri return this._appendAll(res, true) } async _appendAll( - res: GetTimeline.Response | GetAuthorFeed.Response, + res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, replace = false, ) { this.loadMoreCursor = res.data.cursor @@ -644,7 +388,9 @@ export class PostsFeedModel { }) } - _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) { + _updateAll( + res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, + ) { for (const item of res.data.feed) { const existingSlice = this.slices.find(slice => slice.containsUri(item.post.uri), @@ -661,8 +407,13 @@ export class PostsFeedModel { } protected async _getFeed( - params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {}, - ): Promise { + params: + | GetTimeline.QueryParams + | GetAuthorFeed.QueryParams + | GetCustomFeed.QueryParams, + ): Promise< + GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response + > { params = Object.assign({}, this.params, params) if (this.feedType === 'suggested') { const responses = await getMultipleAuthorsPosts( @@ -684,61 +435,31 @@ export class PostsFeedModel { } } else if (this.feedType === 'home') { return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) - } else if (this.feedType === 'goodstuff') { - const res = await getGoodStuff( - this.rootStore.session.currentSession?.accessJwt || '', - params as GetTimeline.QueryParams, + } else if (this.feedType === 'custom') { + this.checkIfCustomFeedIsOnlineAndValid( + params as GetCustomFeed.QueryParams, ) - res.data.feed = (res.data.feed || []).filter( - item => !item.post.author.viewer?.muted, + return this.rootStore.agent.app.bsky.feed.getFeed( + params as GetCustomFeed.QueryParams, ) - return res } else { return this.rootStore.agent.getAuthorFeed( params as GetAuthorFeed.QueryParams, ) } } -} -// HACK -// temporary off-spec route to get the good stuff -// -prf -async function getGoodStuff( - accessJwt: string, - params: GetTimeline.QueryParams, -): Promise { - const controller = new AbortController() - const to = setTimeout(() => controller.abort(), 15e3) - - const uri = new URL('https://bsky.social/xrpc/app.bsky.unspecced.getPopular') - let k: keyof GetTimeline.QueryParams - for (k in params) { - if (typeof params[k] !== 'undefined') { - uri.searchParams.set(k, String(params[k])) + private async checkIfCustomFeedIsOnlineAndValid( + params: GetCustomFeed.QueryParams, + ) { + const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ + feed: params.feed, + }) + if (!res.data.isOnline || !res.data.isValid) { + runInAction(() => { + this.error = + 'This custom feed is not online or may be experiencing issues.' + }) } } - - const res = await fetch(String(uri), { - method: 'get', - headers: { - accept: 'application/json', - authorization: `Bearer ${accessJwt}`, - }, - signal: controller.signal, - }) - - const resHeaders: Record = {} - res.headers.forEach((value: string, key: string) => { - resHeaders[key] = value - }) - let resBody = await res.json() - - clearTimeout(to) - - return { - success: res.status === 200, - headers: resHeaders, - data: jsonToLex(resBody), - } } diff --git a/src/state/models/lists/actor-feeds.ts b/src/state/models/lists/actor-feeds.ts new file mode 100644 index 00000000..0f206058 --- /dev/null +++ b/src/state/models/lists/actor-feeds.ts @@ -0,0 +1,120 @@ +import {makeAutoObservable} from 'mobx' +import {AppBskyFeedGetActorFeeds as GetActorFeeds} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {bundleAsync} from 'lib/async/bundle' +import {cleanError} from 'lib/strings/errors' +import {CustomFeedModel} from '../feeds/custom-feed' + +const PAGE_SIZE = 30 + +export class ActorFeedsModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + hasMore = true + loadMoreCursor?: string + + // data + feeds: CustomFeedModel[] = [] + + constructor( + public rootStore: RootStoreModel, + public params: GetActorFeeds.QueryParams, + ) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.feeds.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async refresh() { + return this.loadMore(true) + } + + clear() { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = false + this.error = '' + this.hasMore = true + this.loadMoreCursor = undefined + this.feeds = [] + } + + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + this._xLoading(replace) + try { + const res = await this.rootStore.agent.app.bsky.feed.getActorFeeds({ + actor: this.params.actor, + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + _xIdle(err?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + if (err) { + this.rootStore.log.error('Failed to fetch user followers', err) + } + } + + // helper functions + // = + + _replaceAll(res: GetActorFeeds.Response) { + this.feeds = [] + this._appendAll(res) + } + + _appendAll(res: GetActorFeeds.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + for (const f of res.data.feeds) { + this.feeds.push(new CustomFeedModel(this.rootStore, f)) + } + } +} diff --git a/src/state/models/log.ts b/src/state/models/log.ts index d8061713..7c9c37c0 100644 --- a/src/state/models/log.ts +++ b/src/state/models/log.ts @@ -27,6 +27,7 @@ function genId(): string { export class LogModel { entries: LogEntry[] = [] + timers = new Map() constructor() { makeAutoObservable(this) @@ -74,6 +75,21 @@ export class LogModel { ts: Date.now(), }) } + + time = (label = 'default') => { + this.timers.set(label, performance.now()) + } + + timeEnd = (label = 'default', warn = false) => { + const endTime = performance.now() + if (this.timers.has(label)) { + const elapsedTime = endTime - this.timers.get(label)! + console.log(`${label}: ${elapsedTime.toFixed(3)}ms`) + this.timers.delete(label) + } else { + warn && console.warn(`Timer with label '${label}' does not exist.`) + } + } } function detailsToStr(details?: any) { diff --git a/src/state/models/me.ts b/src/state/models/me.ts index ba2dc6f3..81504485 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -8,6 +8,7 @@ import {PostsFeedModel} from './feeds/posts' import {NotificationsFeedModel} from './feeds/notifications' import {MyFollowsCache} from './cache/my-follows' import {isObj, hasProp} from 'lib/type-guards' +import {SavedFeedsModel} from './ui/saved-feeds' const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec @@ -21,6 +22,7 @@ export class MeModel { followsCount: number | undefined followersCount: number | undefined mainFeed: PostsFeedModel + savedFeeds: SavedFeedsModel notifications: NotificationsFeedModel follows: MyFollowsCache invites: ComAtprotoServerDefs.InviteCode[] = [] @@ -43,12 +45,14 @@ export class MeModel { }) this.notifications = new NotificationsFeedModel(this.rootStore) this.follows = new MyFollowsCache(this.rootStore) + this.savedFeeds = new SavedFeedsModel(this.rootStore) } clear() { this.mainFeed.clear() this.notifications.clear() this.follows.clear() + this.savedFeeds.clear() this.did = '' this.handle = '' this.displayName = '' @@ -110,6 +114,7 @@ export class MeModel { /* dont await */ this.notifications.setup().catch(e => { this.rootStore.log.error('Failed to setup notifications model', e) }) + /* dont await */ this.savedFeeds.refresh(true) this.rootStore.emitSessionLoaded() await this.fetchInviteCodes() await this.fetchAppPasswords() @@ -119,6 +124,7 @@ export class MeModel { } async updateIfNeeded() { + /* dont await */ this.savedFeeds.refresh(true) if (Date.now() - this.lastProfileStateUpdate > PROFILE_UPDATE_INTERVAL) { this.rootStore.log.debug('Updating me profile information') this.lastProfileStateUpdate = Date.now() diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts index ec93bf5b..6edf88d9 100644 --- a/src/state/models/media/image.ts +++ b/src/state/models/media/image.ts @@ -135,7 +135,7 @@ export class ImageModel implements RNImage { // Only for mobile async crop() { try { - const cropped = await openCropper({ + const cropped = await openCropper(this.rootStore, { mediaType: 'photo', path: this.path, freeStyleCropEnabled: true, diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index 1471420f..dcf6b9a7 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -11,6 +11,7 @@ import { ALWAYS_FILTER_LABEL_GROUP, ALWAYS_WARN_LABEL_GROUP, } from 'lib/labeling/const' +import {DEFAULT_FEEDS} from 'lib/constants' import {isIOS} from 'platform/detection' const deviceLocales = getLocales() @@ -25,6 +26,7 @@ const LABEL_GROUPS = [ 'spam', 'impersonation', ] +const VISIBILITY_VALUES = ['show', 'warn', 'hide'] export class LabelPreferencesModel { nsfw: LabelPreference = 'hide' @@ -45,6 +47,8 @@ export class PreferencesModel { contentLanguages: string[] = deviceLocales?.map?.(locale => locale.languageCode) || [] contentLabels = new LabelPreferencesModel() + savedFeeds: string[] = [] + pinnedFeeds: string[] = [] constructor(public rootStore: RootStoreModel) { makeAutoObservable(this, {}, {autoBind: true}) @@ -54,9 +58,16 @@ export class PreferencesModel { return { contentLanguages: this.contentLanguages, contentLabels: this.contentLabels, + savedFeeds: this.savedFeeds, + pinnedFeeds: this.pinnedFeeds, } } + /** + * The function hydrates an object with properties related to content languages, labels, saved feeds, + * and pinned feeds that it gets from the parameter `v` (probably local storage) + * @param {unknown} v - the data object to hydrate from + */ hydrate(v: unknown) { if (isObj(v)) { if ( @@ -72,10 +83,29 @@ export class PreferencesModel { // default to the device languages this.contentLanguages = deviceLocales.map(locale => locale.languageCode) } + if ( + hasProp(v, 'savedFeeds') && + Array.isArray(v.savedFeeds) && + typeof v.savedFeeds.every(item => typeof item === 'string') + ) { + this.savedFeeds = v.savedFeeds + } + if ( + hasProp(v, 'pinnedFeeds') && + Array.isArray(v.pinnedFeeds) && + typeof v.pinnedFeeds.every(item => typeof item === 'string') + ) { + this.pinnedFeeds = v.pinnedFeeds + } } } + /** + * This function fetches preferences and sets defaults for missing items. + */ async sync() { + // fetch preferences + let hasSavedFeedsPref = false const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) runInAction(() => { for (const pref of res.data.preferences) { @@ -88,22 +118,83 @@ export class PreferencesModel { AppBskyActorDefs.isContentLabelPref(pref) && AppBskyActorDefs.validateAdultContentPref(pref).success ) { - if (LABEL_GROUPS.includes(pref.label)) { - this.contentLabels[pref.label] = pref.visibility + if ( + LABEL_GROUPS.includes(pref.label) && + VISIBILITY_VALUES.includes(pref.visibility) + ) { + this.contentLabels[pref.label as keyof LabelPreferencesModel] = + pref.visibility as LabelPreference } + } else if ( + AppBskyActorDefs.isSavedFeedsPref(pref) && + AppBskyActorDefs.validateSavedFeedsPref(pref).success + ) { + this.savedFeeds = pref.saved + this.pinnedFeeds = pref.pinned + hasSavedFeedsPref = true } } }) + + // set defaults on missing items + if (!hasSavedFeedsPref) { + const {saved, pinned} = await DEFAULT_FEEDS( + this.rootStore.agent.service.toString(), + (handle: string) => + this.rootStore.agent + .resolveHandle({handle}) + .then(({data}) => data.did), + ) + runInAction(() => { + this.savedFeeds = saved + this.pinnedFeeds = pinned + }) + res.data.preferences.push({ + $type: 'app.bsky.actor.defs#savedFeedsPref', + saved, + pinned, + }) + await this.rootStore.agent.app.bsky.actor.putPreferences({ + preferences: res.data.preferences, + }) + /* dont await */ this.rootStore.me.savedFeeds.refresh() + } } - async update(cb: (prefs: AppBskyActorDefs.Preferences) => void) { + /** + * This function updates the preferences of a user and allows for a callback function to be executed + * before the update. + * @param cb - cb is a callback function that takes in a single parameter of type + * AppBskyActorDefs.Preferences and returns either a boolean or void. This callback function is used to + * update the preferences of the user. The function is called with the current preferences as an + * argument and if the callback returns false, the preferences are not updated. + * @returns void + */ + async update(cb: (prefs: AppBskyActorDefs.Preferences) => boolean | void) { const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) - cb(res.data.preferences) + if (cb(res.data.preferences) === false) { + return + } await this.rootStore.agent.app.bsky.actor.putPreferences({ preferences: res.data.preferences, }) } + /** + * This function resets the preferences to an empty array of no preferences. + */ + async reset() { + runInAction(() => { + this.contentLabels = new LabelPreferencesModel() + this.contentLanguages = deviceLocales.map(locale => locale.languageCode) + this.savedFeeds = [] + this.pinnedFeeds = [] + }) + await this.rootStore.agent.app.bsky.actor.putPreferences({ + preferences: [], + }) + } + hasContentLanguage(code2: string) { return this.contentLanguages.includes(code2) } @@ -200,4 +291,62 @@ export class PreferencesModel { } return res } + + setFeeds(saved: string[], pinned: string[]) { + this.savedFeeds = saved + this.pinnedFeeds = pinned + } + + async setSavedFeeds(saved: string[], pinned: string[]) { + const oldSaved = this.savedFeeds + const oldPinned = this.pinnedFeeds + this.setFeeds(saved, pinned) + try { + await this.update((prefs: AppBskyActorDefs.Preferences) => { + const existing = prefs.find( + pref => + AppBskyActorDefs.isSavedFeedsPref(pref) && + AppBskyActorDefs.validateSavedFeedsPref(pref).success, + ) + if (existing) { + existing.saved = saved + existing.pinned = pinned + } else { + prefs.push({ + $type: 'app.bsky.actor.defs#savedFeedsPref', + saved, + pinned, + }) + } + }) + } catch (e) { + runInAction(() => { + this.savedFeeds = oldSaved + this.pinnedFeeds = oldPinned + }) + throw e + } + } + + async addSavedFeed(v: string) { + return this.setSavedFeeds([...this.savedFeeds, v], this.pinnedFeeds) + } + + async removeSavedFeed(v: string) { + return this.setSavedFeeds( + this.savedFeeds.filter(uri => uri !== v), + this.pinnedFeeds.filter(uri => uri !== v), + ) + } + + async addPinnedFeed(v: string) { + return this.setSavedFeeds(this.savedFeeds, [...this.pinnedFeeds, v]) + } + + async removePinnedFeed(v: string) { + return this.setSavedFeeds( + this.savedFeeds, + this.pinnedFeeds.filter(uri => uri !== v), + ) + } } diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index 861b3df0..81daf797 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -2,20 +2,16 @@ import {makeAutoObservable} from 'mobx' import {RootStoreModel} from '../root-store' import {ProfileModel} from '../content/profile' import {PostsFeedModel} from '../feeds/posts' +import {ActorFeedsModel} from '../lists/actor-feeds' import {ListsListModel} from '../lists/lists-list' export enum Sections { Posts = 'Posts', PostsWithReplies = 'Posts & replies', + CustomAlgorithms = 'Feeds', Lists = 'Lists', } -const USER_SELECTOR_ITEMS = [ - Sections.Posts, - Sections.PostsWithReplies, - Sections.Lists, -] - export interface ProfileUiParams { user: string } @@ -28,6 +24,7 @@ export class ProfileUiModel { // data profile: ProfileModel feed: PostsFeedModel + algos: ActorFeedsModel lists: ListsListModel // ui state @@ -50,10 +47,11 @@ export class ProfileUiModel { actor: params.user, limit: 10, }) + this.algos = new ActorFeedsModel(rootStore, {actor: params.user}) this.lists = new ListsListModel(rootStore, params.user) } - get currentView(): PostsFeedModel | ListsListModel { + get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel { if ( this.selectedView === Sections.Posts || this.selectedView === Sections.PostsWithReplies @@ -62,6 +60,9 @@ export class ProfileUiModel { } else if (this.selectedView === Sections.Lists) { return this.lists } + if (this.selectedView === Sections.CustomAlgorithms) { + return this.algos + } throw new Error(`Invalid selector value: ${this.selectedViewIndex}`) } @@ -75,7 +76,14 @@ export class ProfileUiModel { } get selectorItems() { - return USER_SELECTOR_ITEMS + const items = [Sections.Posts, Sections.PostsWithReplies] + if (this.algos.hasLoaded && !this.algos.isEmpty) { + items.push(Sections.CustomAlgorithms) + } + if (this.lists.hasLoaded && !this.lists.isEmpty) { + items.push(Sections.Lists) + } + return items } get selectedView() { @@ -84,9 +92,11 @@ export class ProfileUiModel { get uiItems() { let arr: any[] = [] + // if loading, return loading item to show loading spinner if (this.isInitialLoading) { arr = arr.concat([ProfileUiModel.LOADING_ITEM]) } else if (this.currentView.hasError) { + // if error, return error item to show error message arr = arr.concat([ { _reactKey: '__error__', @@ -94,12 +104,16 @@ export class ProfileUiModel { }, ]) } else { + // not loading, no error, show content if ( this.selectedView === Sections.Posts || - this.selectedView === Sections.PostsWithReplies + this.selectedView === Sections.PostsWithReplies || + this.selectedView === Sections.CustomAlgorithms ) { if (this.feed.hasContent) { - if (this.selectedView === Sections.Posts) { + if (this.selectedView === Sections.CustomAlgorithms) { + arr = this.algos.feeds + } else if (this.selectedView === Sections.Posts) { arr = this.feed.nonReplyFeed } else { arr = this.feed.slices.slice() @@ -117,6 +131,7 @@ export class ProfileUiModel { arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) } } else { + // fallback, add empty item, to show empty message arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) } } @@ -151,6 +166,7 @@ export class ProfileUiModel { .setup() .catch(err => this.rootStore.log.error('Failed to fetch feed', err)), ]) + this.algos.refresh() // HACK: need to use the DID as a param, not the username -prf this.lists.source = this.profile.did this.lists diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts new file mode 100644 index 00000000..979fddf4 --- /dev/null +++ b/src/state/models/ui/saved-feeds.ts @@ -0,0 +1,185 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {AppBskyFeedDefs} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {bundleAsync} from 'lib/async/bundle' +import {cleanError} from 'lib/strings/errors' +import {CustomFeedModel} from '../feeds/custom-feed' + +export class SavedFeedsModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + + // data + feeds: CustomFeedModel[] = [] + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.feeds.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + get pinned() { + return this.rootStore.preferences.pinnedFeeds + .map(uri => this.feeds.find(f => f.uri === uri) as CustomFeedModel) + .filter(Boolean) + } + + get unpinned() { + return this.feeds.filter(f => !this.isPinned(f)) + } + + get all() { + return this.pinned.concat(this.unpinned) + } + + get pinnedFeedNames() { + return this.pinned.map(f => f.displayName) + } + + // public api + // = + + clear() { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = false + this.error = '' + this.feeds = [] + } + + refresh = bundleAsync(async (quietRefresh = false) => { + this._xLoading(!quietRefresh) + try { + let feeds: AppBskyFeedDefs.GeneratorView[] = [] + for ( + let i = 0; + i < this.rootStore.preferences.savedFeeds.length; + i += 25 + ) { + const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({ + feeds: this.rootStore.preferences.savedFeeds.slice(i, 25), + }) + feeds = feeds.concat(res.data.feeds) + } + runInAction(() => { + this.feeds = feeds.map(f => new CustomFeedModel(this.rootStore, f)) + }) + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + async save(feed: CustomFeedModel) { + try { + await feed.save() + runInAction(() => { + this.feeds = [ + ...this.feeds, + new CustomFeedModel(this.rootStore, feed.data), + ] + }) + } catch (e: any) { + this.rootStore.log.error('Failed to save feed', e) + } + } + + async unsave(feed: CustomFeedModel) { + const uri = feed.uri + try { + if (this.isPinned(feed)) { + await this.rootStore.preferences.removePinnedFeed(uri) + } + await feed.unsave() + runInAction(() => { + this.feeds = this.feeds.filter(f => f.data.uri !== uri) + }) + } catch (e: any) { + this.rootStore.log.error('Failed to unsave feed', e) + } + } + + async togglePinnedFeed(feed: CustomFeedModel) { + if (!this.isPinned(feed)) { + return this.rootStore.preferences.addPinnedFeed(feed.uri) + } else { + return this.rootStore.preferences.removePinnedFeed(feed.uri) + } + } + + async reorderPinnedFeeds(feeds: CustomFeedModel[]) { + return this.rootStore.preferences.setSavedFeeds( + this.rootStore.preferences.savedFeeds, + feeds.filter(feed => this.isPinned(feed)).map(feed => feed.uri), + ) + } + + isPinned(feedOrUri: CustomFeedModel | string) { + let uri: string + if (typeof feedOrUri === 'string') { + uri = feedOrUri + } else { + uri = feedOrUri.uri + } + return this.rootStore.preferences.pinnedFeeds.includes(uri) + } + + async movePinnedFeed(item: CustomFeedModel, direction: 'up' | 'down') { + const pinned = this.rootStore.preferences.pinnedFeeds.slice() + const index = pinned.indexOf(item.uri) + if (index === -1) { + return + } + if (direction === 'up' && index !== 0) { + const temp = pinned[index] + pinned[index] = pinned[index - 1] + pinned[index - 1] = temp + } else if (direction === 'down' && index < pinned.length - 1) { + const temp = pinned[index] + pinned[index] = pinned[index + 1] + pinned[index + 1] = temp + } + await this.rootStore.preferences.setSavedFeeds( + this.rootStore.preferences.savedFeeds, + pinned, + ) + } + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + _xIdle(err?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + if (err) { + this.rootStore.log.error('Failed to fetch user feeds', err) + } + } +} diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 9b9a176b..95b66624 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -119,7 +119,7 @@ export type Modal = // Moderation | ReportAccountModal | ReportPostModal - | CreateMuteListModal + | CreateOrEditMuteListModal | ListAddRemoveUserModal // Posts diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts index 45c2dfd0..8d3b8cac 100644 --- a/src/view/com/composer/useExternalLinkFetch.ts +++ b/src/view/com/composer/useExternalLinkFetch.ts @@ -2,9 +2,9 @@ import {useState, useEffect} from 'react' import {useStores} from 'state/index' import * as apilib from 'lib/api/index' import {getLinkMeta} from 'lib/link-meta/link-meta' -import {getPostAsQuote} from 'lib/link-meta/bsky' +import {getPostAsQuote, getFeedAsEmbed} from 'lib/link-meta/bsky' import {downloadAndResize} from 'lib/media/manip' -import {isBskyPostUrl} from 'lib/strings/url-helpers' +import {isBskyPostUrl, isBskyCustomFeedUrl} from 'lib/strings/url-helpers' import {ComposerOpts} from 'state/models/ui/shell' import {POST_IMG_MAX} from 'lib/constants' @@ -41,6 +41,24 @@ export function useExternalLinkFetch({ setExtLink(undefined) }, ) + } else if (isBskyCustomFeedUrl(extLink.uri)) { + getFeedAsEmbed(store, extLink.uri).then( + ({embed, meta}) => { + if (aborted) { + return + } + setExtLink({ + uri: extLink.uri, + isLoading: false, + meta, + embed, + }) + }, + err => { + store.log.error('Failed to fetch feed for embedding', {err}) + setExtLink(undefined) + }, + ) } else { getLinkMeta(store, extLink.uri).then(meta => { if (aborted) { diff --git a/src/view/com/feeds/CustomFeed.tsx b/src/view/com/feeds/CustomFeed.tsx new file mode 100644 index 00000000..748b89c0 --- /dev/null +++ b/src/view/com/feeds/CustomFeed.tsx @@ -0,0 +1,162 @@ +import React from 'react' +import { + Pressable, + StyleProp, + StyleSheet, + View, + ViewStyle, + TouchableOpacity, +} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {Text} from '../util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' +import {UserAvatar} from '../util/UserAvatar' +import {observer} from 'mobx-react-lite' +import {CustomFeedModel} from 'state/models/feeds/custom-feed' +import {useNavigation} from '@react-navigation/native' +import {NavigationProp} from 'lib/routes/types' +import {useStores} from 'state/index' +import {pluralize} from 'lib/strings/helpers' +import {AtUri} from '@atproto/api' +import * as Toast from 'view/com/util/Toast' + +export const CustomFeed = observer( + ({ + item, + style, + showSaveBtn = false, + showDescription = false, + showLikes = false, + }: { + item: CustomFeedModel + style?: StyleProp + showSaveBtn?: boolean + showDescription?: boolean + showLikes?: boolean + }) => { + const store = useStores() + const pal = usePalette('default') + const navigation = useNavigation() + + const onToggleSaved = React.useCallback(async () => { + if (item.isSaved) { + store.shell.openModal({ + name: 'confirm', + title: 'Remove from my feeds', + message: `Remove ${item.displayName} from my feeds?`, + onPressConfirm: async () => { + try { + await store.me.savedFeeds.unsave(item) + Toast.show('Removed from my feeds') + } catch (e) { + Toast.show('There was an issue contacting your server') + store.log.error('Failed to unsave feed', {e}) + } + }, + }) + } else { + try { + await store.me.savedFeeds.save(item) + Toast.show('Added to my feeds') + } catch (e) { + Toast.show('There was an issue contacting your server') + store.log.error('Failed to save feed', {e}) + } + } + }, [store, item]) + + return ( + { + navigation.navigate('CustomFeed', { + name: item.data.creator.did, + rkey: new AtUri(item.data.uri).rkey, + }) + }} + key={item.data.uri}> + + + + + + + {item.displayName} + + + by @{item.data.creator.handle} + + + {showSaveBtn && ( + + + {item.isSaved ? ( + + ) : ( + + )} + + + )} + + + {showDescription && item.data.description ? ( + + {item.data.description} + + ) : null} + + {showLikes ? ( + + Liked by {item.data.likeCount || 0}{' '} + {pluralize(item.data.likeCount || 0, 'user')} + + ) : null} + + ) + }, +) + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 18, + paddingVertical: 20, + flexDirection: 'column', + flex: 1, + borderTopWidth: 1, + gap: 14, + }, + headerContainer: { + flexDirection: 'row', + }, + headerTextContainer: { + flexDirection: 'column', + columnGap: 4, + flex: 1, + }, + description: { + flex: 1, + flexWrap: 'wrap', + }, + btn: { + paddingVertical: 6, + }, +}) diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx index 7cbdaaf6..0e13ca33 100644 --- a/src/view/com/lists/ListCard.tsx +++ b/src/view/com/lists/ListCard.tsx @@ -60,7 +60,7 @@ export const ListCard = ({ anchorNoUnderline> - + - + diff --git a/src/view/com/modals/ContentLanguagesSettings.tsx b/src/view/com/modals/ContentLanguagesSettings.tsx index 0c750fe0..700f1cbc 100644 --- a/src/view/com/modals/ContentLanguagesSettings.tsx +++ b/src/view/com/modals/ContentLanguagesSettings.tsx @@ -41,8 +41,8 @@ export function Component({}: {}) { Content Languages - Which languages would you like to see in the What's Hot feed? (Leave - them all unchecked to see any language.) + Which languages would you like to see in the your feed? (Leave them all + unchecked to see any language.) {languages.map(lang => ( diff --git a/src/view/com/modals/CreateOrEditMuteList.tsx b/src/view/com/modals/CreateOrEditMuteList.tsx index 0c13f243..736deae7 100644 --- a/src/view/com/modals/CreateOrEditMuteList.tsx +++ b/src/view/com/modals/CreateOrEditMuteList.tsx @@ -143,6 +143,7 @@ export function Component({ List Avatar ) : null} diff --git a/src/view/com/pager/DraggableScrollView.tsx b/src/view/com/pager/DraggableScrollView.tsx new file mode 100644 index 00000000..4b7396ea --- /dev/null +++ b/src/view/com/pager/DraggableScrollView.tsx @@ -0,0 +1,15 @@ +import {useDraggableScroll} from 'lib/hooks/useDraggableScrollView' +import React, {ComponentProps} from 'react' +import {ScrollView} from 'react-native' + +export const DraggableScrollView = React.forwardRef< + ScrollView, + ComponentProps +>(function DraggableScrollView(props, ref) { + const {refs} = useDraggableScroll({ + outerRef: ref, + cursor: 'grab', // optional, default + }) + + return +}) diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index 0fc1b731..0df91595 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useMemo} from 'react' import {Animated, StyleSheet} from 'react-native' import {observer} from 'mobx-react-lite' import {TabBar} from 'view/com/pager/TabBar' @@ -27,6 +27,10 @@ const FeedsTabBarDesktop = observer( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) => { const store = useStores() + const items = useMemo( + () => ['Following', ...store.me.savedFeeds.pinnedFeedNames], + [store.me.savedFeeds.pinnedFeedNames], + ) const pal = usePalette('default') const interp = useAnimatedValue(0) @@ -44,13 +48,14 @@ const FeedsTabBarDesktop = observer( {translateY: Animated.multiply(interp, -100)}, ], } + return ( // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index b42ffe72..9c713881 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -1,12 +1,17 @@ -import React from 'react' -import {Animated, StyleSheet, TouchableOpacity} from 'react-native' +import React, {useMemo} from 'react' +import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' import {TabBar} from 'view/com/pager/TabBar' import {RenderTabBarFnProps} from 'view/com/pager/Pager' -import {UserAvatar} from '../util/UserAvatar' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' +import {Link} from '../util/Link' +import {Text} from '../util/text/Text' +import {CogIcon} from 'lib/icons' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {s} from 'lib/styles' export const FeedsTabBar = observer( ( @@ -28,25 +33,51 @@ export const FeedsTabBar = observer( transform: [{translateY: Animated.multiply(interp, -100)}], } + const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3) + const onPressAvi = React.useCallback(() => { store.shell.openDrawer() }, [store]) + const items = useMemo( + () => ['Following', ...store.me.savedFeeds.pinnedFeedNames], + [store.me.savedFeeds.pinnedFeedNames], + ) + return ( - - - + + + + + + + Bluesky + + + + + + @@ -61,13 +92,20 @@ const styles = StyleSheet.create({ left: 0, right: 0, top: 0, - flexDirection: 'row', + flexDirection: 'column', alignItems: 'center', - paddingHorizontal: 18, borderBottomWidth: 1, }, - tabBarAvi: { - marginTop: 1, - marginRight: 18, + topBar: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 18, + paddingTop: 8, + paddingBottom: 2, + width: '100%', + }, + title: { + fontSize: 21, }, }) diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index 34747db6..e2c8bf6d 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -1,16 +1,17 @@ -import React from 'react' +import React, {forwardRef} from 'react' import {Animated, View} from 'react-native' import PagerView, {PagerViewOnPageSelectedEvent} from 'react-native-pager-view' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {s} from 'lib/styles' export type PageSelectedEvent = PagerViewOnPageSelectedEvent const AnimatedPagerView = Animated.createAnimatedComponent(PagerView) +export interface PagerRef { + setPage: (index: number) => void +} + export interface RenderTabBarFnProps { selectedPage: number - position: Animated.Value - offset: Animated.Value onSelect?: (index: number) => void } export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element @@ -22,68 +23,60 @@ interface Props { onPageSelected?: (index: number) => void testID?: string } -export const Pager = ({ - children, - tabBarPosition = 'top', - initialPage = 0, - renderTabBar, - onPageSelected, - testID, -}: React.PropsWithChildren) => { - const [selectedPage, setSelectedPage] = React.useState(0) - const position = useAnimatedValue(0) - const offset = useAnimatedValue(0) - const pagerView = React.useRef() +export const Pager = forwardRef>( + ( + { + children, + tabBarPosition = 'top', + initialPage = 0, + renderTabBar, + onPageSelected, + testID, + }: React.PropsWithChildren, + ref, + ) => { + const [selectedPage, setSelectedPage] = React.useState(0) + const pagerView = React.useRef() - const onPageSelectedInner = React.useCallback( - (e: PageSelectedEvent) => { - setSelectedPage(e.nativeEvent.position) - onPageSelected?.(e.nativeEvent.position) - }, - [setSelectedPage, onPageSelected], - ) + React.useImperativeHandle(ref, () => ({ + setPage: (index: number) => pagerView.current?.setPage(index), + })) - const onTabBarSelect = React.useCallback( - (index: number) => { - pagerView.current?.setPage(index) - }, - [pagerView], - ) + const onPageSelectedInner = React.useCallback( + (e: PageSelectedEvent) => { + setSelectedPage(e.nativeEvent.position) + onPageSelected?.(e.nativeEvent.position) + }, + [setSelectedPage, onPageSelected], + ) - return ( - - {tabBarPosition === 'top' && - renderTabBar({ - selectedPage, - position, - offset, - onSelect: onTabBarSelect, - })} - - {children} - - {tabBarPosition === 'bottom' && - renderTabBar({ - selectedPage, - position, - offset, - onSelect: onTabBarSelect, - })} - - ) -} + const onTabBarSelect = React.useCallback( + (index: number) => { + pagerView.current?.setPage(index) + }, + [pagerView], + ) + + return ( + + {tabBarPosition === 'top' && + renderTabBar({ + selectedPage, + onSelect: onTabBarSelect, + })} + + {children} + + {tabBarPosition === 'bottom' && + renderTabBar({ + selectedPage, + onSelect: onTabBarSelect, + })} + + ) + }, +) diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx index 107497f6..7be2b11e 100644 --- a/src/view/com/pager/Pager.web.tsx +++ b/src/view/com/pager/Pager.web.tsx @@ -1,12 +1,9 @@ import React from 'react' -import {Animated, View} from 'react-native' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {View} from 'react-native' import {s} from 'lib/styles' export interface RenderTabBarFnProps { selectedPage: number - position: Animated.Value - offset: Animated.Value onSelect?: (index: number) => void } export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element @@ -17,53 +14,51 @@ interface Props { renderTabBar: RenderTabBarFn onPageSelected?: (index: number) => void } -export const Pager = ({ - children, - tabBarPosition = 'top', - initialPage = 0, - renderTabBar, - onPageSelected, -}: React.PropsWithChildren) => { - const [selectedPage, setSelectedPage] = React.useState(initialPage) - const position = useAnimatedValue(0) - const offset = useAnimatedValue(0) +export const Pager = React.forwardRef( + ( + { + children, + tabBarPosition = 'top', + initialPage = 0, + renderTabBar, + onPageSelected, + }: React.PropsWithChildren, + ref, + ) => { + const [selectedPage, setSelectedPage] = React.useState(initialPage) - const onTabBarSelect = React.useCallback( - (index: number) => { - setSelectedPage(index) - onPageSelected?.(index) - Animated.timing(position, { - toValue: index, - duration: 200, - useNativeDriver: true, - }).start() - }, - [setSelectedPage, onPageSelected, position], - ) + React.useImperativeHandle(ref, () => ({ + setPage: (index: number) => setSelectedPage(index), + })) - return ( - - {tabBarPosition === 'top' && - renderTabBar({ - selectedPage, - position, - offset, - onSelect: onTabBarSelect, - })} - {React.Children.map(children, (child, i) => ( - - {child} - - ))} - {tabBarPosition === 'bottom' && - renderTabBar({ - selectedPage, - position, - offset, - onSelect: onTabBarSelect, - })} - - ) -} + const onTabBarSelect = React.useCallback( + (index: number) => { + setSelectedPage(index) + onPageSelected?.(index) + }, + [setSelectedPage, onPageSelected], + ) + + return ( + + {tabBarPosition === 'top' && + renderTabBar({ + selectedPage, + onSelect: onTabBarSelect, + })} + {React.Children.map(children, (child, i) => ( + + {child} + + ))} + {tabBarPosition === 'bottom' && + renderTabBar({ + selectedPage, + onSelect: onTabBarSelect, + })} + + ) + }, +) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index a0b72a93..d7121fde 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -1,22 +1,22 @@ -import React, {createRef, useState, useMemo, useRef} from 'react' -import {Animated, StyleSheet, View} from 'react-native' +import React, { + useRef, + createRef, + useMemo, + useEffect, + useState, + useCallback, +} from 'react' +import {StyleSheet, View, ScrollView} from 'react-native' import {Text} from '../util/text/Text' import {PressableWithHover} from '../util/PressableWithHover' import {usePalette} from 'lib/hooks/usePalette' -import {isDesktopWeb} from 'platform/detection' - -interface Layout { - x: number - width: number -} +import {isDesktopWeb, isMobileWeb} from 'platform/detection' +import {DraggableScrollView} from './DraggableScrollView' export interface TabBarProps { testID?: string selectedPage: number items: string[] - position: Animated.Value - offset: Animated.Value - indicatorPosition?: 'top' | 'bottom' indicatorColor?: string onSelect?: (index: number) => void onPressSelected?: () => void @@ -26,69 +26,27 @@ export function TabBar({ testID, selectedPage, items, - position, - offset, - indicatorPosition = 'bottom', indicatorColor, onSelect, onPressSelected, }: TabBarProps) { const pal = usePalette('default') - const [itemLayouts, setItemLayouts] = useState( - items.map(() => ({x: 0, width: 0})), - ) + const scrollElRef = useRef(null) + const [itemXs, setItemXs] = useState([]) const itemRefs = useMemo( () => Array.from({length: items.length}).map(() => createRef()), [items.length], ) - const panX = Animated.add(position, offset) - const containerRef = useRef(null) + const indicatorStyle = useMemo( + () => ({borderBottomColor: indicatorColor || pal.colors.link}), + [indicatorColor, pal], + ) - const indicatorStyle = { - backgroundColor: indicatorColor || pal.colors.link, - bottom: - indicatorPosition === 'bottom' ? (isDesktopWeb ? 0 : -1) : undefined, - top: indicatorPosition === 'top' ? (isDesktopWeb ? 0 : -1) : undefined, - transform: [ - { - translateX: panX.interpolate({ - inputRange: items.map((_item, i) => i), - outputRange: itemLayouts.map(l => l.x + l.width / 2), - }), - }, - { - scaleX: panX.interpolate({ - inputRange: items.map((_item, i) => i), - outputRange: itemLayouts.map(l => l.width), - }), - }, - ], - } + useEffect(() => { + scrollElRef.current?.scrollTo({x: itemXs[selectedPage] || 0}) + }, [scrollElRef, itemXs, selectedPage]) - const onLayout = React.useCallback(() => { - const promises = [] - for (let i = 0; i < items.length; i++) { - promises.push( - new Promise(resolve => { - if (!containerRef.current || !itemRefs[i].current) { - return resolve({x: 0, width: 0}) - } - - itemRefs[i].current?.measureLayout( - containerRef.current, - (x: number, _y: number, width: number) => { - resolve({x, width}) - }, - ) - }), - ) - } - Promise.all(promises).then((layouts: Layout[]) => { - setItemLayouts(layouts) - }) - }, [containerRef, itemRefs, setItemLayouts, items.length]) - - const onPressItem = React.useCallback( + const onPressItem = useCallback( (index: number) => { onSelect?.(index) if (index === selectedPage) { @@ -98,33 +56,51 @@ export function TabBar({ [onSelect, onPressSelected, selectedPage], ) + const onLayout = React.useCallback(() => { + const promises = [] + for (let i = 0; i < items.length; i++) { + promises.push( + new Promise(resolve => { + if (!itemRefs[i].current) { + return resolve(0) + } + + itemRefs[i].current?.measure((x: number) => resolve(x)) + }), + ) + } + Promise.all(promises).then((Xs: number[]) => { + setItemXs(Xs) + }) + }, [itemRefs, setItemXs, items.length]) + return ( - - - {items.map((item, i) => { - const selected = i === selectedPage - return ( - onPressItem(i)}> - - {item} - - - ) - })} + + + {items.map((item, i) => { + const selected = i === selectedPage + return ( + onPressItem(i)}> + + {item} + + + ) + })} + ) } @@ -133,45 +109,39 @@ const styles = isDesktopWeb ? StyleSheet.create({ outer: { flexDirection: 'row', - paddingHorizontal: 18, + width: 598, }, - itemTop: { - paddingTop: 16, - paddingBottom: 14, - paddingHorizontal: 12, + contentContainer: { + columnGap: 8, + marginLeft: 14, + paddingRight: 14, + backgroundColor: 'transparent', }, - itemBottom: { + item: { paddingTop: 14, - paddingBottom: 16, - paddingHorizontal: 12, - }, - indicator: { - position: 'absolute', - left: 0, - width: 1, - height: 3, - zIndex: 1, + paddingBottom: 12, + paddingHorizontal: 10, + borderBottomWidth: 3, + borderBottomColor: 'transparent', }, }) : StyleSheet.create({ outer: { + flex: 1, flexDirection: 'row', - paddingHorizontal: 14, + backgroundColor: 'transparent', }, - itemTop: { + contentContainer: { + columnGap: isMobileWeb ? 0 : 20, + marginLeft: isMobileWeb ? 0 : 18, + paddingRight: isMobileWeb ? 0 : 36, + backgroundColor: 'transparent', + }, + item: { paddingTop: 10, paddingBottom: 10, - marginRight: 24, - }, - itemBottom: { - paddingTop: 8, - paddingBottom: 12, - marginRight: 24, - }, - indicator: { - position: 'absolute', - left: 0, - width: 1, - height: 3, + paddingHorizontal: isMobileWeb ? 8 : 0, + borderBottomWidth: 3, + borderBottomColor: 'transparent', }, }) diff --git a/src/view/com/posts/WhatsHotEmptyState.tsx b/src/view/com/posts/CustomFeedEmptyState.tsx similarity index 72% rename from src/view/com/posts/WhatsHotEmptyState.tsx rename to src/view/com/posts/CustomFeedEmptyState.tsx index ade94ca3..e51794e7 100644 --- a/src/view/com/posts/WhatsHotEmptyState.tsx +++ b/src/view/com/posts/CustomFeedEmptyState.tsx @@ -1,5 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' +import {useNavigation} from '@react-navigation/native' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -7,18 +8,19 @@ import { import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import {MagnifyingGlassIcon} from 'lib/icons' -import {useStores} from 'state/index' +import {NavigationProp} from 'lib/routes/types' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' -export function WhatsHotEmptyState() { +export function CustomFeedEmptyState() { const pal = usePalette('default') const palInverted = usePalette('inverted') - const store = useStores() + const navigation = useNavigation() - const onPressSettings = React.useCallback(() => { - store.shell.openModal({name: 'content-languages-settings'}) - }, [store]) + const onPressFindAccounts = React.useCallback(() => { + navigation.navigate('SearchTab') + navigation.popToTop() + }, [navigation]) return ( @@ -26,12 +28,15 @@ export function WhatsHotEmptyState() { - Your What's Hot feed is empty! This is because there aren't enough users - posting in your selected language. + This feed is empty! You may need to follow more users or tune your + language settings. - diff --git a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx b/src/view/com/util/load-latest/LoadLatestBtn.web.tsx index 83968502..fefc540c 100644 --- a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtn.web.tsx @@ -1,8 +1,8 @@ import React from 'react' import {StyleSheet, TouchableOpacity} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../text/Text' import {usePalette} from 'lib/hooks/usePalette' -import {UpIcon} from 'lib/icons' import {LoadLatestBtn as LoadLatestBtnMobile} from './LoadLatestBtnMobile' import {isMobileWeb} from 'platform/detection' @@ -11,51 +11,97 @@ const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} export const LoadLatestBtn = ({ onPress, label, + showIndicator, + minimalShellMode, }: { onPress: () => void label: string + showIndicator: boolean + minimalShellMode?: boolean }) => { const pal = usePalette('default') if (isMobileWeb) { - return + return ( + + ) } return ( - - - - Load new {label} - - + <> + {showIndicator && ( + + + {label} + + + )} + + + + + + ) } const styles = StyleSheet.create({ loadLatest: { flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', position: 'absolute', left: '50vw', // @ts-ignore web only -prf - transform: 'translateX(-50%)', - top: 60, - shadowColor: '#000', - shadowOpacity: 0.2, - shadowOffset: {width: 0, height: 2}, - shadowRadius: 4, - paddingLeft: 20, - paddingRight: 24, - paddingVertical: 10, + transform: 'translateX(-282px)', + bottom: 40, + width: 54, + height: 54, borderRadius: 30, borderWidth: 1, }, icon: { position: 'relative', top: 2, - marginRight: 5, + }, + loadLatestCentered: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + position: 'absolute', + left: '50vw', + // @ts-ignore web only -prf + transform: 'translateX(-50%)', + top: 60, + paddingHorizontal: 24, + paddingVertical: 14, + borderRadius: 30, + borderWidth: 1, + }, + loadLatestCenteredMinimal: { + top: 20, }, }) diff --git a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx index 5279696a..412b9b80 100644 --- a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx @@ -1,23 +1,35 @@ import React from 'react' -import {StyleSheet, TouchableOpacity} from 'react-native' +import {StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' -import LinearGradient from 'react-native-linear-gradient' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {Text} from '../text/Text' -import {colors, gradients} from 'lib/styles' import {clamp} from 'lodash' import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {colors} from 'lib/styles' const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} export const LoadLatestBtn = observer( - ({onPress, label}: {onPress: () => void; label: string}) => { + ({ + onPress, + label, + showIndicator, + }: { + onPress: () => void + label: string + showIndicator: boolean + minimalShellMode?: boolean // NOTE not used on mobile -prf + }) => { const store = useStores() + const pal = usePalette('default') const safeAreaInsets = useSafeAreaInsets() return ( - - - Load new {label} - - + accessibilityLabel={label} + accessibilityHint=""> + + {showIndicator && } ) }, @@ -44,19 +49,24 @@ export const LoadLatestBtn = observer( const styles = StyleSheet.create({ loadLatest: { position: 'absolute', - left: 20, + left: 18, bottom: 35, - shadowColor: '#000', - shadowOpacity: 0.3, - shadowOffset: {width: 0, height: 1}, - }, - loadLatestInner: { + borderWidth: 1, + width: 52, + height: 52, + borderRadius: 26, flexDirection: 'row', - paddingHorizontal: 14, - paddingVertical: 10, - borderRadius: 30, + alignItems: 'center', + justifyContent: 'center', }, - loadLatestText: { - color: colors.white, + indicator: { + position: 'absolute', + top: 3, + right: 3, + backgroundColor: colors.blue3, + width: 12, + height: 12, + borderRadius: 6, + borderWidth: 1, }, }) diff --git a/src/view/com/util/moderation/ImageHider.tsx b/src/view/com/util/moderation/ImageHider.tsx index b42c6397..40add5b6 100644 --- a/src/view/com/util/moderation/ImageHider.tsx +++ b/src/view/com/util/moderation/ImageHider.tsx @@ -27,6 +27,10 @@ export function ImageHider({ setOverride(false) }, [setOverride]) + if (moderation.behavior === ModerationBehaviorCode.Hide) { + return null + } + if (moderation.behavior !== ModerationBehaviorCode.WarnImages) { return ( @@ -35,10 +39,6 @@ export function ImageHider({ ) } - if (moderation.behavior === ModerationBehaviorCode.Hide) { - return null - } - return ( diff --git a/src/view/com/util/numeric/format.ts b/src/view/com/util/numeric/format.ts index f0e90217..7aa5a4f4 100644 --- a/src/view/com/util/numeric/format.ts +++ b/src/view/com/util/numeric/format.ts @@ -3,3 +3,13 @@ export const formatCount = (num: number) => notation: 'compact', maximumFractionDigits: 1, }).format(num) + +export function formatCountShortOnly(num: number): string { + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'M' + } + if (num >= 1000) { + return (num / 1000).toFixed(1) + 'K' + } + return String(num) +} diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 5c0296e2..41d66641 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -10,9 +10,6 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import ReactNativeHapticFeedback, { - HapticFeedbackTypes, -} from 'react-native-haptic-feedback' // DISABLED see #135 // import { // TriggerableAnimated, @@ -24,8 +21,8 @@ import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons' import {s, colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' import {useStores} from 'state/index' -import {isIOS, isNative} from 'platform/detection' import {RepostButton} from './RepostButton' +import {Haptics} from 'lib/haptics' interface PostCtrlsOpts { itemUri: string @@ -58,7 +55,6 @@ interface PostCtrlsOpts { } const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5} -const hapticImpact: HapticFeedbackTypes = isIOS ? 'impactMedium' : 'impactLight' // Users said the medium impact was too strong on Android; see APP-537 // DISABLED see #135 /* @@ -111,9 +107,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { const onRepost = useCallback(() => { store.shell.closeModal() if (!opts.isReposted) { - if (isNative) { - ReactNativeHapticFeedback.trigger(hapticImpact) - } + Haptics.default() opts.onPressToggleRepost().catch(_e => undefined) // DISABLED see #135 // repostRef.current?.trigger( @@ -139,10 +133,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { indexedAt: opts.indexedAt, }, }) - - if (isNative) { - ReactNativeHapticFeedback.trigger(hapticImpact) - } + Haptics.default() }, [ opts.author, opts.indexedAt, @@ -154,7 +145,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { const onPressToggleLikeWrapper = async () => { if (!opts.isLiked) { - ReactNativeHapticFeedback.trigger(hapticImpact) + Haptics.default() await opts.onPressToggleLike().catch(_e => undefined) // DISABLED see #135 // likeRef.current?.trigger( @@ -201,11 +192,11 @@ export function PostCtrls(opts: PostCtrlsOpts) { accessibilityRole="button" accessibilityLabel={opts.isLiked ? 'Unlike' : 'Like'} accessibilityHint={ - opts.isReposted ? `Removes like from the post` : `Like the post` + opts.isReposted ? 'Removes like from the post' : 'Like the post' }> {opts.isLiked ? ( } + style={styles.ctrlIconLiked} size={opts.big ? 22 : 16} /> ) : ( diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index a55ff905..9eb140c2 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -13,6 +13,7 @@ import { AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, AppBskyFeedPost, + AppBskyFeedDefs, } from '@atproto/api' import {Link} from '../Link' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' @@ -24,6 +25,8 @@ import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {getYoutubeVideoId} from 'lib/strings/url-helpers' import QuoteEmbed from './QuoteEmbed' import {AutoSizedImage} from '../images/AutoSizedImage' +import {CustomFeed} from 'view/com/feeds/CustomFeed' +import {CustomFeedModel} from 'state/models/feeds/custom-feed' type Embed = | AppBskyEmbedRecord.View @@ -42,6 +45,8 @@ export function PostEmbeds({ const pal = usePalette('default') const store = useStores() + // quote post with media + // = if ( AppBskyEmbedRecordWithMedia.isView(embed) && AppBskyEmbedRecord.isViewRecord(embed.record.record) && @@ -65,6 +70,8 @@ export function PostEmbeds({ ) } + // quote post + // = if (AppBskyEmbedRecord.isView(embed)) { if ( AppBskyEmbedRecord.isViewRecord(embed.record) && @@ -87,6 +94,8 @@ export function PostEmbeds({ } } + // image embed + // = if (AppBskyEmbedImages.isView(embed)) { const {images} = embed @@ -132,10 +141,11 @@ export function PostEmbeds({ /> ) - // } } } + // external link embed + // = if (AppBskyEmbedExternal.isView(embed)) { const link = embed.external const youtubeVideoId = getYoutubeVideoId(link.uri) @@ -153,9 +163,35 @@ export function PostEmbeds({ ) } + + // custom feed embed (i.e. generator view) + // = + if ( + AppBskyEmbedRecord.isView(embed) && + AppBskyFeedDefs.isGeneratorView(embed.record) + ) { + return + } + return } +function CustomFeedEmbed({record}: {record: AppBskyFeedDefs.GeneratorView}) { + const pal = usePalette('default') + const store = useStores() + const item = React.useMemo( + () => new CustomFeedModel(store, record), + [store, record], + ) + return ( + + ) +} + const styles = StyleSheet.create({ stackContainer: { gap: 6, @@ -172,6 +208,13 @@ const styles = StyleSheet.create({ borderRadius: 8, marginTop: 4, }, + customFeedOuter: { + borderWidth: 1, + borderRadius: 8, + marginTop: 4, + paddingHorizontal: 12, + paddingVertical: 12, + }, alt: { backgroundColor: 'rgba(0, 0, 0, 0.75)', borderRadius: 6, diff --git a/src/view/index.ts b/src/view/index.ts index b8a13f7f..f06bdacc 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -8,6 +8,7 @@ import {faAngleUp} from '@fortawesome/free-solid-svg-icons/faAngleUp' import {faArrowLeft} from '@fortawesome/free-solid-svg-icons/faArrowLeft' import {faArrowRight} from '@fortawesome/free-solid-svg-icons/faArrowRight' import {faArrowUp} from '@fortawesome/free-solid-svg-icons/faArrowUp' +import {faArrowDown} from '@fortawesome/free-solid-svg-icons/faArrowDown' import {faArrowRightFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowRightFromBracket' import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket' import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare' @@ -59,14 +60,17 @@ import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib' import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare' import {faPlus} from '@fortawesome/free-solid-svg-icons/faPlus' import {faQuoteLeft} from '@fortawesome/free-solid-svg-icons/faQuoteLeft' +import {faReply} from '@fortawesome/free-solid-svg-icons/faReply' +import {faRetweet} from '@fortawesome/free-solid-svg-icons/faRetweet' +import {faRss} from '@fortawesome/free-solid-svg-icons/faRss' +import {faSatelliteDish} from '@fortawesome/free-solid-svg-icons/faSatelliteDish' import {faShare} from '@fortawesome/free-solid-svg-icons/faShare' import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare' import {faShield} from '@fortawesome/free-solid-svg-icons/faShield' import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus' import {faSignal} from '@fortawesome/free-solid-svg-icons/faSignal' -import {faReply} from '@fortawesome/free-solid-svg-icons/faReply' -import {faRetweet} from '@fortawesome/free-solid-svg-icons/faRetweet' -import {faRss} from '@fortawesome/free-solid-svg-icons/faRss' +import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket' +import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan' import {faUser} from '@fortawesome/free-regular-svg-icons/faUser' import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers' import {faUserCheck} from '@fortawesome/free-solid-svg-icons/faUserCheck' @@ -74,12 +78,11 @@ import {faUserSlash} from '@fortawesome/free-solid-svg-icons/faUserSlash' import {faUserPlus} from '@fortawesome/free-solid-svg-icons/faUserPlus' import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark' import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash' -import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket' -import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan' import {faX} from '@fortawesome/free-solid-svg-icons/faX' import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark' import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay' import {faPause} from '@fortawesome/free-solid-svg-icons/faPause' +import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack' export function setup() { library.add( @@ -91,6 +94,7 @@ export function setup() { faArrowLeft, faArrowRight, faArrowUp, + faArrowDown, faArrowRightFromBracket, faArrowUpFromBracket, faArrowUpRightFromSquare, @@ -145,6 +149,7 @@ export function setup() { faReply, faRetweet, faRss, + faSatelliteDish, faShare, faShareFromSquare, faShield, @@ -159,6 +164,7 @@ export function setup() { faUsersSlash, faTicket, faTrashCan, + faThumbtack, faX, faXmark, faPlay, diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx new file mode 100644 index 00000000..4149cd49 --- /dev/null +++ b/src/view/screens/CustomFeed.tsx @@ -0,0 +1,418 @@ +import React, {useMemo, useRef} from 'react' +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {usePalette} from 'lib/hooks/usePalette' +import {HeartIcon, HeartIconSolid} from 'lib/icons' +import {CommonNavigatorParams} from 'lib/routes/types' +import {makeRecordUri} from 'lib/strings/url-helpers' +import {colors, s} from 'lib/styles' +import {observer} from 'mobx-react-lite' +import {FlatList, StyleSheet, View} from 'react-native' +import {useStores} from 'state/index' +import {PostsFeedModel} from 'state/models/feeds/posts' +import {useCustomFeed} from 'lib/hooks/useCustomFeed' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {Feed} from 'view/com/posts/Feed' +import {pluralize} from 'lib/strings/helpers' +import {TextLink} from 'view/com/util/Link' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {ViewHeader} from 'view/com/util/ViewHeader' +import {Button} from 'view/com/util/forms/Button' +import {Text} from 'view/com/util/text/Text' +import * as Toast from 'view/com/util/Toast' +import {isDesktopWeb} from 'platform/detection' +import {useSetTitle} from 'lib/hooks/useSetTitle' +import {shareUrl} from 'lib/sharing' +import {toShareUrl} from 'lib/strings/url-helpers' +import {Haptics} from 'lib/haptics' +import {ComposeIcon2} from 'lib/icons' +import {FAB} from '../com/util/fab/FAB' +import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' +import {DropdownButton, DropdownItem} from 'view/com/util/forms/DropdownButton' +import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' +import {EmptyState} from 'view/com/util/EmptyState' + +type Props = NativeStackScreenProps +export const CustomFeedScreen = withAuthRequired( + observer(({route}: Props) => { + const store = useStores() + const pal = usePalette('default') + const {rkey, name} = route.params + const uri = useMemo( + () => makeRecordUri(name, 'app.bsky.feed.generator', rkey), + [rkey, name], + ) + const scrollElRef = useRef(null) + const currentFeed = useCustomFeed(uri) + const algoFeed: PostsFeedModel = useMemo(() => { + const feed = new PostsFeedModel(store, 'custom', { + feed: uri, + }) + feed.setup() + return feed + }, [store, uri]) + const isPinned = store.me.savedFeeds.isPinned(uri) + const [onMainScroll, isScrolledDown, resetMainScroll] = + useOnMainScroll(store) + useSetTitle(currentFeed?.displayName) + + const onToggleSaved = React.useCallback(async () => { + try { + Haptics.default() + if (currentFeed?.isSaved) { + await currentFeed?.unsave() + } else { + await currentFeed?.save() + } + } catch (err) { + Toast.show( + 'There was an an issue updating your feeds, please check your internet connection and try again.', + ) + store.log.error('Failed up update feeds', {err}) + } + }, [store, currentFeed]) + + const onToggleLiked = React.useCallback(async () => { + Haptics.default() + try { + if (currentFeed?.isLiked) { + await currentFeed?.unlike() + } else { + await currentFeed?.like() + } + } catch (err) { + Toast.show( + 'There was an an issue contacting the server, please check your internet connection and try again.', + ) + store.log.error('Failed up toggle like', {err}) + } + }, [store, currentFeed]) + + const onTogglePinned = React.useCallback(async () => { + Haptics.default() + store.me.savedFeeds.togglePinnedFeed(currentFeed!).catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to toggle pinned feed', {e}) + }) + }, [store, currentFeed]) + + const onPressShare = React.useCallback(() => { + const url = toShareUrl(`/profile/${name}/feed/${rkey}`) + shareUrl(url) + }, [name, rkey]) + + const onScrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: 0, animated: true}) + resetMainScroll() + }, [scrollElRef, resetMainScroll]) + + const onPressCompose = React.useCallback(() => { + store.shell.openComposer({}) + }, [store]) + + const dropdownItems: DropdownItem[] = React.useMemo(() => { + let items: DropdownItem[] = [ + { + testID: 'feedHeaderDropdownRemoveBtn', + label: 'Remove from my feeds', + onPress: onToggleSaved, + }, + { + testID: 'feedHeaderDropdownShareBtn', + label: 'Share link', + onPress: onPressShare, + }, + ] + return items + }, [onToggleSaved, onPressShare]) + + const renderHeaderBtns = React.useCallback(() => { + return ( + + + {currentFeed?.isSaved ? ( + + ) : undefined} + {currentFeed?.isSaved ? ( + + + + ) : ( + + )} + + ) + }, [ + pal, + currentFeed?.isSaved, + currentFeed?.isLiked, + isPinned, + onToggleSaved, + onTogglePinned, + onToggleLiked, + dropdownItems, + ]) + + const renderListHeaderComponent = React.useCallback(() => { + return ( + <> + + + + {currentFeed?.displayName} + + {currentFeed && ( + + by{' '} + {currentFeed.data.creator.did === store.me.did ? ( + 'you' + ) : ( + + )} + + )} + {isDesktopWeb && ( + + + + + + )} + + + + + + + {currentFeed?.data.description ? ( + + {currentFeed.data.description} + + ) : null} + + {currentFeed ? ( + + ) : null} + + + + + + Feed + + + + + ) + }, [ + pal, + currentFeed, + store.me.did, + onToggleSaved, + onToggleLiked, + onPressShare, + name, + rkey, + isPinned, + onTogglePinned, + ]) + + const renderEmptyState = React.useCallback(() => { + return + }, []) + + return ( + + + + {isScrolledDown ? ( + + ) : null} + } + accessibilityRole="button" + accessibilityLabel="Compose post" + accessibilityHint="" + /> + + ) + }), +) + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + gap: 12, + paddingHorizontal: 16, + paddingTop: 12, + paddingBottom: 16, + borderTopWidth: 1, + }, + headerBtns: { + flexDirection: 'row', + alignItems: 'center', + }, + headerBtnsDesktop: { + marginTop: 8, + gap: 4, + }, + headerAddBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingLeft: 4, + }, + headerDetails: { + paddingHorizontal: 16, + paddingBottom: 16, + }, + headerDetailsFooter: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + fakeSelector: { + flexDirection: 'row', + paddingHorizontal: isDesktopWeb ? 16 : 6, + }, + fakeSelectorItem: { + paddingHorizontal: 12, + paddingBottom: 8, + borderBottomWidth: 3, + }, + liked: { + color: colors.red3, + }, + top1: { + position: 'relative', + top: 1, + }, + top2: { + position: 'relative', + top: 2, + }, +}) diff --git a/src/view/screens/CustomFeedLikedBy.tsx b/src/view/screens/CustomFeedLikedBy.tsx new file mode 100644 index 00000000..49d0d048 --- /dev/null +++ b/src/view/screens/CustomFeedLikedBy.tsx @@ -0,0 +1,29 @@ +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 {useStores} from 'state/index' +import {makeRecordUri} from 'lib/strings/url-helpers' + +type Props = NativeStackScreenProps +export const CustomFeedLikedByScreen = withAuthRequired(({route}: Props) => { + const store = useStores() + const {name, rkey} = route.params + const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey) + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + }, [store]), + ) + + return ( + + + + + ) +}) diff --git a/src/view/screens/DiscoverFeeds.tsx b/src/view/screens/DiscoverFeeds.tsx new file mode 100644 index 00000000..cd32ec65 --- /dev/null +++ b/src/view/screens/DiscoverFeeds.tsx @@ -0,0 +1,112 @@ +import React from 'react' +import {RefreshControl, StyleSheet, View} from 'react-native' +import {observer} from 'mobx-react-lite' +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 {useStores} from 'state/index' +import {FeedsDiscoveryModel} from 'state/models/discovery/feeds' +import {CenteredView, FlatList} from 'view/com/util/Views' +import {CustomFeed} from 'view/com/feeds/CustomFeed' +import {Text} from 'view/com/util/text/Text' +import {isDesktopWeb} from 'platform/detection' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' + +type Props = NativeStackScreenProps +export const DiscoverFeedsScreen = withAuthRequired( + observer(({}: Props) => { + const store = useStores() + const pal = usePalette('default') + const feeds = React.useMemo(() => new FeedsDiscoveryModel(store), [store]) + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + feeds.refresh() + }, [store, feeds]), + ) + + const onRefresh = React.useCallback(() => { + store.me.savedFeeds.refresh() + }, [store]) + + const renderListEmptyComponent = React.useCallback(() => { + return ( + + + {feeds.isLoading + ? 'Loading...' + : `We can't find any feeds for some reason. This is probably an error - try refreshing!`} + + + ) + }, [pal, feeds.isLoading]) + + const renderItem = React.useCallback( + ({item}) => ( + + ), + [], + ) + + return ( + + + + + item.data.uri} + contentContainerStyle={styles.contentContainer} + refreshControl={ + + } + renderItem={renderItem} + initialNumToRender={10} + ListEmptyComponent={renderListEmptyComponent} + extraData={feeds.isLoading} + /> + + ) + }), +) + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + contentContainer: { + paddingBottom: 100, + }, + containerDesktop: { + borderLeftWidth: 1, + borderRightWidth: 1, + }, + empty: { + paddingHorizontal: 18, + paddingVertical: 16, + borderRadius: 8, + marginHorizontal: 18, + marginTop: 10, + }, +}) diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx new file mode 100644 index 00000000..169f8879 --- /dev/null +++ b/src/view/screens/Feeds.tsx @@ -0,0 +1,126 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {useFocusEffect} from '@react-navigation/native' +import isEqual from 'lodash.isequal' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {FlatList} from 'view/com/util/Views' +import {ViewHeader} from 'view/com/util/ViewHeader' +import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' +import {FAB} from 'view/com/util/fab/FAB' +import {Link} from 'view/com/util/Link' +import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types' +import {observer} from 'mobx-react-lite' +import {PostsMultiFeedModel} from 'state/models/feeds/multi-feed' +import {MultiFeed} from 'view/com/posts/MultiFeed' +import {isDesktopWeb} from 'platform/detection' +import {usePalette} from 'lib/hooks/usePalette' +import {useStores} from 'state/index' +import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' +import {ComposeIcon2, CogIcon} from 'lib/icons' +import {s} from 'lib/styles' + +const HEADER_OFFSET = isDesktopWeb ? 0 : 40 + +type Props = NativeStackScreenProps +export const FeedsScreen = withAuthRequired( + observer(({}: Props) => { + const pal = usePalette('default') + const store = useStores() + const flatListRef = React.useRef(null) + const multifeed = React.useMemo( + () => new PostsMultiFeedModel(store), + [store], + ) + const [onMainScroll, isScrolledDown, resetMainScroll] = + useOnMainScroll(store) + + const onSoftReset = React.useCallback(() => { + flatListRef.current?.scrollToOffset({offset: 0}) + resetMainScroll() + }, [flatListRef, resetMainScroll]) + + useFocusEffect( + React.useCallback(() => { + const softResetSub = store.onScreenSoftReset(onSoftReset) + const multifeedCleanup = multifeed.registerListeners() + const cleanup = () => { + softResetSub.remove() + multifeedCleanup() + } + + store.shell.setMinimalShellMode(false) + return cleanup + }, [store, multifeed, onSoftReset]), + ) + + React.useEffect(() => { + if ( + isEqual( + multifeed.feedInfos.map(f => f.uri), + store.me.savedFeeds.all.map(f => f.uri), + ) + ) { + // no changes + return + } + multifeed.refresh() + }, [multifeed, store.me.savedFeeds.all]) + + const onPressCompose = React.useCallback(() => { + store.shell.openComposer({}) + }, [store]) + + const renderHeaderBtn = React.useCallback(() => { + return ( + + + + ) + }, [pal]) + + return ( + + + + {isScrolledDown ? ( + + ) : null} + } + accessibilityRole="button" + accessibilityLabel="Compose post" + accessibilityHint="" + /> + + ) + }), +) + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 0ead6b65..eae4d979 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,18 +1,20 @@ import React from 'react' import {FlatList, View} from 'react-native' import {useFocusEffect, useIsFocused} from '@react-navigation/native' +import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api' import {observer} from 'mobx-react-lite' import useAppState from 'react-native-appstate-hook' +import isEqual from 'lodash.isequal' import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' import {PostsFeedModel} from 'state/models/feeds/posts' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect' import {Feed} from '../com/posts/Feed' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' -import {WhatsHotEmptyState} from 'view/com/posts/WhatsHotEmptyState' +import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' import {FeedsTabBar} from '../com/pager/FeedsTabBar' -import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager' +import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {FAB} from '../com/util/fab/FAB' import {useStores} from 'state/index' import {s} from 'lib/styles' @@ -21,30 +23,37 @@ import {useAnalytics} from 'lib/analytics' import {ComposeIcon2} from 'lib/icons' import {isDesktopWeb} from 'platform/detection' -const HEADER_OFFSET = isDesktopWeb ? 50 : 40 +const HEADER_OFFSET = isDesktopWeb ? 50 : 78 const POLL_FREQ = 30e3 // 30sec type Props = NativeStackScreenProps export const HomeScreen = withAuthRequired( observer((_opts: Props) => { const store = useStores() + const pagerRef = React.useRef(null) const [selectedPage, setSelectedPage] = React.useState(0) - const [initialLanguages] = React.useState( - store.preferences.contentLanguages, - ) - - const algoFeed: PostsFeedModel = React.useMemo(() => { - const feed = new PostsFeedModel(store, 'goodstuff', {}) - feed.setup() - return feed - }, [store]) + const [customFeeds, setCustomFeeds] = React.useState([]) React.useEffect(() => { - // refresh whats hot when lang preferences change - if (initialLanguages !== store.preferences.contentLanguages) { - algoFeed.refresh() + const {pinned} = store.me.savedFeeds + if ( + isEqual( + pinned.map(p => p.uri), + customFeeds.map(f => (f.params as GetCustomFeed.QueryParams).feed), + ) + ) { + // no changes + return } - }, [initialLanguages, store.preferences.contentLanguages, algoFeed]) + + const feeds = [] + for (const feed of pinned) { + const model = new PostsFeedModel(store, 'custom', {feed: feed.uri}) + model.setup() + feeds.push(model) + } + setCustomFeeds(feeds) + }, [store, store.me.savedFeeds.pinned, customFeeds, setCustomFeeds]) useFocusEffect( React.useCallback(() => { @@ -86,18 +95,17 @@ export const HomeScreen = withAuthRequired( return }, []) - const renderWhatsHotEmptyState = React.useCallback(() => { - return + const renderCustomFeedEmptyState = React.useCallback(() => { + return }, []) - const initialPage = store.me.followsCount === 0 ? 1 : 0 return ( + tabBarPosition="top"> - + {customFeeds.map((f, index) => { + return ( + + ) + })} ) }), @@ -130,7 +142,8 @@ const FeedPage = observer( renderEmptyState?: () => JSX.Element }) => { const store = useStores() - const onMainScroll = useOnMainScroll(store) + const [onMainScroll, isScrolledDown, resetMainScroll] = + useOnMainScroll(store) const {screen, track} = useAnalytics() const scrollElRef = React.useRef(null) const {appState} = useAppState({ @@ -158,12 +171,13 @@ const FeedPage = observer( const scrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -HEADER_OFFSET}) - }, [scrollElRef]) + resetMainScroll() + }, [scrollElRef, resetMainScroll]) const onSoftReset = React.useCallback(() => { if (isPageFocused) { - feed.refresh() scrollToTop() + feed.refresh() } }, [isPageFocused, scrollToTop, feed]) @@ -224,6 +238,7 @@ const FeedPage = observer( feed.refresh() }, [feed, scrollToTop]) + const hasNew = feed.hasNewLatest && !feed.isRefreshing return ( - {feed.hasNewLatest && !feed.isRefreshing && ( - + {(isScrolledDown || hasNew) && ( + )} item.did} + keyExtractor={item => item.did} refreshControl={ { const store = useStores() - const onMainScroll = useOnMainScroll(store) + const [onMainScroll, isScrolledDown, resetMainScroll] = + useOnMainScroll(store) const scrollElRef = React.useRef(null) const {screen} = useAnalytics() @@ -37,7 +38,8 @@ export const NotificationsScreen = withAuthRequired( const scrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: 0}) - }, [scrollElRef]) + resetMainScroll() + }, [scrollElRef, resetMainScroll]) const onPressLoadLatest = React.useCallback(() => { scrollToTop() @@ -86,6 +88,9 @@ export const NotificationsScreen = withAuthRequired( ), ) + const hasNew = + store.me.notifications.hasNewLatest && + !store.me.notifications.isRefreshing return ( @@ -96,10 +101,14 @@ export const NotificationsScreen = withAuthRequired( onScroll={onMainScroll} scrollElRef={scrollElRef} /> - {store.me.notifications.hasNewLatest && - !store.me.notifications.isRefreshing && ( - - )} + {(isScrolledDown || hasNew) && ( + + )} ) }), diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index b6d92e46..77e3743e 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -9,7 +9,7 @@ import {CenteredView} from '../com/util/Views' import {ScreenHider} from 'view/com/util/moderation/ScreenHider' import {ProfileUiModel, Sections} from 'state/models/ui/profile' import {useStores} from 'state/index' -import {PostsFeedSliceModel} from 'state/models/feeds/posts' +import {PostsFeedSliceModel} from 'state/models/feeds/post' import {ProfileHeader} from '../com/profile/ProfileHeader' import {FeedSlice} from '../com/posts/FeedSlice' import {ListCard} from 'view/com/lists/ListCard' @@ -25,6 +25,8 @@ import {FAB} from '../com/util/fab/FAB' import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics' import {ComposeIcon2} from 'lib/icons' +import {CustomFeed} from 'view/com/feeds/CustomFeed' +import {CustomFeedModel} from 'state/models/feeds/custom-feed' import {useSetTitle} from 'lib/hooks/useSetTitle' import {combinedDisplayName} from 'lib/strings/display-names' @@ -118,6 +120,7 @@ export const ProfileScreen = withAuthRequired( }, [uiState.showLoadingMoreFooter]) const renderItem = React.useCallback( (item: any) => { + // if section is lists if (uiState.selectedView === Sections.Lists) { if (item === ProfileUiModel.LOADING_ITEM) { return @@ -142,6 +145,32 @@ export const ProfileScreen = withAuthRequired( } else { return } + // if section is custom algorithms + } else if (uiState.selectedView === Sections.CustomAlgorithms) { + if (item === ProfileUiModel.LOADING_ITEM) { + return + } else if (item._reactKey === '__error__') { + return ( + + + + ) + } else if (item === ProfileUiModel.EMPTY_ITEM) { + return ( + + ) + } else if (item instanceof CustomFeedModel) { + return + } + // if section is posts or posts & replies } else { if (item === ProfileUiModel.END_ITEM) { return - end of feed - diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 01f27bae..7c3ed831 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -87,7 +87,7 @@ export const ProfileListScreen = withAuthRequired( return }, []) - const renderHeaderBtn = React.useCallback(() => { + const renderHeaderBtns = React.useCallback(() => { return ( {list?.isOwner && ( @@ -148,7 +148,7 @@ export const ProfileListScreen = withAuthRequired( pal.border, ]} testID="moderationMutelistsScreen"> - + + +export const SavedFeeds = withAuthRequired( + observer(({}: Props) => { + const pal = usePalette('default') + const store = useStores() + const {screen} = useAnalytics() + + const savedFeeds = useMemo(() => store.me.savedFeeds, [store]) + useFocusEffect( + useCallback(() => { + screen('SavedFeeds') + store.shell.setMinimalShellMode(false) + savedFeeds.refresh() + }, [screen, store, savedFeeds]), + ) + + const renderListEmptyComponent = useCallback(() => { + return ( + + + You don't have any saved feeds. + + + ) + }, [pal]) + + const renderListFooterComponent = useCallback(() => { + return ( + <> + + + + + Discover new feeds + + + + + + Feeds are custom algorithms that users build with a little coding + expertise.{' '} + {' '} + for more information. + + + {savedFeeds.isLoading && } + + ) + }, [pal, savedFeeds.isLoading]) + + const onRefresh = useCallback(() => savedFeeds.refresh(), [savedFeeds]) + + const onDragEnd = useCallback( + async ({data}) => { + try { + await savedFeeds.reorderPinnedFeeds(data) + } catch (e) { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to save pinned feed order', {e}) + } + }, + [savedFeeds, store], + ) + + return ( + + + item.data.uri} + refreshing={savedFeeds.isRefreshing} + refreshControl={ + + } + renderItem={({item, drag}) => } + getItemLayout={(data, index) => ({ + length: 77, + offset: 77 * index, + index, + })} + initialNumToRender={10} + ListFooterComponent={renderListFooterComponent} + ListEmptyComponent={renderListEmptyComponent} + extraData={savedFeeds.isLoading} + onDragEnd={onDragEnd} + /> + + ) + }), +) + +const ListItem = observer( + ({item, drag}: {item: CustomFeedModel; drag: () => void}) => { + const pal = usePalette('default') + const store = useStores() + const savedFeeds = useMemo(() => store.me.savedFeeds, [store]) + const isPinned = savedFeeds.isPinned(item) + + const onTogglePinned = useCallback(() => { + Haptics.default() + savedFeeds.togglePinnedFeed(item).catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to toggle pinned feed', {e}) + }) + }, [savedFeeds, item, store]) + const onPressUp = useCallback( + () => + savedFeeds.movePinnedFeed(item, 'up').catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to set pinned feed order', {e}) + }), + [store, savedFeeds, item], + ) + const onPressDown = useCallback( + () => + savedFeeds.movePinnedFeed(item, 'down').catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to set pinned feed order', {e}) + }), + [store, savedFeeds, item], + ) + + return ( + + + + {isPinned && isWeb ? ( + + + + + + + + + ) : isPinned ? ( + + ) : null} + + + + + + + + ) + }, +) + +const styles = StyleSheet.create({ + desktopContainer: { + borderLeftWidth: 1, + borderRightWidth: 1, + minHeight: '100vh', + }, + empty: { + paddingHorizontal: 20, + paddingVertical: 20, + borderRadius: 16, + marginHorizontal: 24, + marginTop: 10, + }, + itemContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + borderBottomWidth: 1, + paddingRight: 16, + }, + webArrowButtonsContainer: { + paddingLeft: 16, + flexDirection: 'column', + justifyContent: 'space-around', + }, + webArrowUpButton: { + marginBottom: 10, + }, + noBorder: { + borderTopWidth: 0, + }, + footerText: { + paddingHorizontal: 26, + paddingTop: 22, + paddingBottom: 100, + }, + footerLinks: { + borderBottomWidth: 1, + borderTopWidth: 0, + }, + footerLink: { + flexDirection: 'row', + paddingHorizontal: 26, + paddingVertical: 18, + gap: 18, + }, +}) diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx index f9b4864b..c9d09373 100644 --- a/src/view/screens/SearchMobile.tsx +++ b/src/view/screens/SearchMobile.tsx @@ -35,7 +35,7 @@ export const SearchScreen = withAuthRequired( const store = useStores() const scrollViewRef = React.useRef(null) const flatListRef = React.useRef(null) - const onMainScroll = useOnMainScroll(store) + const [onMainScroll] = useOnMainScroll(store) const [isInputFocused, setIsInputFocused] = React.useState(false) const [query, setQuery] = React.useState('') const autocompleteView = React.useMemo( diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 1571a614..ac4e5a9e 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -141,6 +141,11 @@ export const SettingsScreen = withAuthRequired( store.shell.openModal({name: 'delete-account'}) }, [store]) + const onPressResetPreferences = React.useCallback(async () => { + await store.preferences.reset() + Toast.show('Preferences reset') + }, [store]) + return ( @@ -301,6 +306,22 @@ export const SettingsScreen = withAuthRequired( App passwords + + + + + + Saved Feeds + + + {__DEV__ ? ( + + + Reset preferences state + + + ) : null} Build version {AppInfo.appVersion} ({AppInfo.buildVersion}) diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 2ecdbaab..57f4ee69 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -2,6 +2,7 @@ import React, {ComponentProps} from 'react' import { Linking, SafeAreaView, + ScrollView, StyleProp, StyleSheet, TouchableOpacity, @@ -28,6 +29,8 @@ import { MagnifyingGlassIcon2Solid, MoonIcon, UserIconSolid, + SatelliteDishIcon, + SatelliteDishIconSolid, HandIcon, } from 'lib/icons' import {UserAvatar} from 'view/com/util/UserAvatar' @@ -40,7 +43,7 @@ import {getTabState, TabState} from 'lib/routes/helpers' import {NavigationProp} from 'lib/routes/types' import {useNavigationTabState} from 'lib/hooks/useNavigationTabState' import {isWeb} from 'platform/detection' -import {formatCount} from 'view/com/util/numeric/format' +import {formatCount, formatCountShortOnly} from 'view/com/util/numeric/format' export const DrawerContent = observer(() => { const theme = useTheme() @@ -48,7 +51,7 @@ export const DrawerContent = observer(() => { const store = useStores() const navigation = useNavigation() const {track} = useAnalytics() - const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile} = + const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = useNavigationTabState() const {notifications} = store.me @@ -95,6 +98,11 @@ export const DrawerContent = observer(() => { onPressTab('MyProfile') }, [onPressTab]) + const onPressMyFeeds = React.useCallback( + () => onPressTab('Feeds'), + [onPressTab], + ) + const onPressModeration = React.useCallback(() => { track('Menu:ItemClicked', {url: 'Moderation'}) navigation.navigate('Moderation') @@ -147,19 +155,18 @@ export const DrawerContent = observer(() => { type="xl" style={[pal.textLight, styles.profileCardFollowers]}> - {formatCount(store.me.followersCount ?? 0)} + {formatCountShortOnly(store.me.followersCount ?? 0)} {' '} {pluralize(store.me.followersCount || 0, 'follower')} ·{' '} - {formatCount(store.me.followsCount ?? 0)} + {formatCountShortOnly(store.me.followsCount ?? 0)} {' '} following - - + { /> + isAtFeeds ? ( + + ) : ( + + ) } + label="My Feeds" + accessibilityLabel="My Feeds" + accessibilityHint="" + onPress={onPressMyFeeds} + /> + } label="Moderation" accessibilityLabel="Moderation" accessibilityHint="" @@ -278,8 +300,8 @@ export const DrawerContent = observer(() => { accessibilityHint="" onPress={onPressSettings} /> - - + + {!isWeb && ( { const store = useStores() const pal = usePalette('default') const safeAreaInsets = useSafeAreaInsets() const {track} = useAnalytics() - const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile} = + const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = useNavigationTabState() const {footerMinimalShellTransform} = useMinimalShellMode() @@ -60,6 +61,10 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { () => onPressTab('Search'), [onPressTab], ) + const onPressFeeds = React.useCallback( + () => onPressTab('Feeds'), + [onPressTab], + ) const onPressNotifications = React.useCallback( () => onPressTab('Notifications'), [onPressTab], @@ -121,6 +126,28 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { accessibilityLabel="Search" accessibilityHint="" /> + + ) : ( + + ) + } + onPress={onPressFeeds} + accessibilityRole="tab" + accessibilityLabel="Feeds" + accessibilityHint="" + /> { icon={ {isAtMyProfile ? ( - + + + ) : ( - + + + )} } diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx index 3d5adbc9..2414b991 100644 --- a/src/view/shell/bottom-bar/BottomBarStyles.tsx +++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx @@ -58,4 +58,9 @@ export const styles = StyleSheet.create({ profileIcon: { top: -4, }, + onProfile: { + borderColor: colors.black, + borderWidth: 1, + borderRadius: 100, + }, }) diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index b7daac5a..cbaafd1f 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -15,6 +15,8 @@ import { HomeIconSolid, MagnifyingGlassIcon2, MagnifyingGlassIcon2Solid, + SatelliteDishIcon, + SatelliteDishIconSolid, UserIcon, } from 'lib/icons' import {Link} from 'view/com/util/Link' @@ -61,6 +63,18 @@ export const BottomBarWeb = observer(() => { ) }} + + {({isActive}) => { + const Icon = isActive ? SatelliteDishIconSolid : SatelliteDishIcon + return ( + + ) + }} + {({isActive}) => { const Icon = isActive ? BellIconSolid : BellIcon diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index ba48dd2a..ce232a73 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -30,6 +30,8 @@ import { CogIconSolid, ComposeIcon2, HandIcon, + SatelliteDishIcon, + SatelliteDishIconSolid, } from 'lib/icons' import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers' import {NavigationProp} from 'lib/routes/types' @@ -195,6 +197,24 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() { } label="Search" /> + + } + iconFilled={ + + } + label="My Feeds" + /> =2.3.1" wonka "^4.0.14" -"@webassemblyjs/ast@1.11.5", "@webassemblyjs/ast@^1.11.5": - version "1.11.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.5.tgz#6e818036b94548c1fb53b754b5cae3c9b208281c" - integrity sha512-LHY/GSAZZRpsNQH+/oHqhRQ5FT7eoULcBqgfyTB5nQHogFnK3/7QoN7dLnwSE/JkUAF0SrRuclT7ODqMFtWxxQ== +"@webassemblyjs/ast@1.11.6", "@webassemblyjs/ast@^1.11.5": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.6.tgz#db046555d3c413f8966ca50a95176a0e2c642e24" + integrity sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q== dependencies: - "@webassemblyjs/helper-numbers" "1.11.5" - "@webassemblyjs/helper-wasm-bytecode" "1.11.5" + "@webassemblyjs/helper-numbers" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" -"@webassemblyjs/floating-point-hex-parser@1.11.5": - version "1.11.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.5.tgz#e85dfdb01cad16b812ff166b96806c050555f1b4" - integrity sha512-1j1zTIC5EZOtCplMBG/IEwLtUojtwFVwdyVMbL/hwWqbzlQoJsWCOavrdnLkemwNoC/EOwtUFch3fuo+cbcXYQ== +"@webassemblyjs/floating-point-hex-parser@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz#dacbcb95aff135c8260f77fa3b4c5fea600a6431" + integrity sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw== -"@webassemblyjs/helper-api-error@1.11.5": - version "1.11.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.5.tgz#1e82fa7958c681ddcf4eabef756ce09d49d442d1" - integrity sha512-L65bDPmfpY0+yFrsgz8b6LhXmbbs38OnwDCf6NpnMUYqa+ENfE5Dq9E42ny0qz/PdR0LJyq/T5YijPnU8AXEpA== +"@webassemblyjs/helper-api-error@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz#6132f68c4acd59dcd141c44b18cbebbd9f2fa768" + integrity sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q== -"@webassemblyjs/helper-buffer@1.11.5": - version "1.11.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.5.tgz#91381652ea95bb38bbfd270702351c0c89d69fba" - integrity sha512-fDKo1gstwFFSfacIeH5KfwzjykIE6ldh1iH9Y/8YkAZrhmu4TctqYjSh7t0K2VyDSXOZJ1MLhht/k9IvYGcIxg== +"@webassemblyjs/helper-buffer@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz#b66d73c43e296fd5e88006f18524feb0f2c7c093" + integrity sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA== -"@webassemblyjs/helper-numbers@1.11.5": - version "1.11.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.5.tgz#23380c910d56764957292839006fecbe05e135a9" - integrity sha512-DhykHXM0ZABqfIGYNv93A5KKDw/+ywBFnuWybZZWcuzWHfbp21wUfRkbtz7dMGwGgT4iXjWuhRMA2Mzod6W4WA== +"@webassemblyjs/helper-numbers@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz#cbce5e7e0c1bd32cf4905ae444ef64cea919f1b5" + integrity sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g== dependencies: - "@webassemblyjs/floating-point-hex-parser" "1.11.5" - "@webassemblyjs/helper-api-error" "1.11.5" + "@webassemblyjs/floating-point-hex-parser" "1.11.6" + "@webassemblyjs/helper-api-error" "1.11.6" "@xtuc/long" "4.2.2" -"@webassemblyjs/helper-wasm-bytecode@1.11.5": - version "1.11.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.5.tgz#e258a25251bc69a52ef817da3001863cc1c24b9f" - integrity sha512-oC4Qa0bNcqnjAowFn7MPCETQgDYytpsfvz4ujZz63Zu/a/v71HeCAAmZsgZ3YVKec3zSPYytG3/PrRCqbtcAvA== +"@webassemblyjs/helper-wasm-bytecode@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz#bb2ebdb3b83aa26d9baad4c46d4315283acd51e9" + integrity sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA== -"@webassemblyjs/helper-wasm-section@1.11.5": - version "1.11.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.5.tgz#966e855a6fae04d5570ad4ec87fbcf29b42ba78e" - integrity sha512-uEoThA1LN2NA+K3B9wDo3yKlBfVtC6rh0i4/6hvbz071E8gTNZD/pT0MsBf7MeD6KbApMSkaAK0XeKyOZC7CIA== +"@webassemblyjs/helper-wasm-section@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz#ff97f3863c55ee7f580fd5c41a381e9def4aa577" + integrity sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g== dependencies: - "@webassemblyjs/ast" "1.11.5" - "@webassemblyjs/helper-buffer" "1.11.5" - "@webassemblyjs/helper-wasm-bytecode" "1.11.5" - "@webassemblyjs/wasm-gen" "1.11.5" + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-buffer" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/wasm-gen" "1.11.6" -"@webassemblyjs/ieee754@1.11.5": - version "1.11.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.5.tgz#b2db1b33ce9c91e34236194c2b5cba9b25ca9d60" - integrity sha512-37aGq6qVL8A8oPbPrSGMBcp38YZFXcHfiROflJn9jxSdSMMM5dS5P/9e2/TpaJuhE+wFrbukN2WI6Hw9MH5acg== +"@webassemblyjs/ieee754@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz#bb665c91d0b14fffceb0e38298c329af043c6e3a" + integrity sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg== dependencies: "@xtuc/ieee754" "^1.2.0" -"@webassemblyjs/leb128@1.11.5": - version "1.11.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.5.tgz#482e44d26b6b949edf042a8525a66c649e38935a" - integrity sha512-ajqrRSXaTJoPW+xmkfYN6l8VIeNnR4vBOTQO9HzR7IygoCcKWkICbKFbVTNMjMgMREqXEr0+2M6zukzM47ZUfQ== +"@webassemblyjs/leb128@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.6.tgz#70e60e5e82f9ac81118bc25381a0b283893240d7" + integrity sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ== dependencies: "@xtuc/long" "4.2.2" -"@webassemblyjs/utf8@1.11.5": - version "1.11.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.5.tgz#83bef94856e399f3740e8df9f63bc47a987eae1a" - integrity sha512-WiOhulHKTZU5UPlRl53gHR8OxdGsSOxqfpqWeA2FmcwBMaoEdz6b2x2si3IwC9/fSPLfe8pBMRTHVMk5nlwnFQ== +"@webassemblyjs/utf8@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.6.tgz#90f8bc34c561595fe156603be7253cdbcd0fab5a" + integrity sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA== "@webassemblyjs/wasm-edit@^1.11.5": - version "1.11.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.5.tgz#93ee10a08037657e21c70de31c47fdad6b522b2d" - integrity sha512-C0p9D2fAu3Twwqvygvf42iGCQ4av8MFBLiTb+08SZ4cEdwzWx9QeAHDo1E2k+9s/0w1DM40oflJOpkZ8jW4HCQ== + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz#c72fa8220524c9b416249f3d94c2958dfe70ceab" + integrity sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw== dependencies: - "@webassemblyjs/ast" "1.11.5" - "@webassemblyjs/helper-buffer" "1.11.5" - "@webassemblyjs/helper-wasm-bytecode" "1.11.5" - "@webassemblyjs/helper-wasm-section" "1.11.5" - "@webassemblyjs/wasm-gen" "1.11.5" - "@webassemblyjs/wasm-opt" "1.11.5" - "@webassemblyjs/wasm-parser" "1.11.5" - "@webassemblyjs/wast-printer" "1.11.5" + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-buffer" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/helper-wasm-section" "1.11.6" + "@webassemblyjs/wasm-gen" "1.11.6" + "@webassemblyjs/wasm-opt" "1.11.6" + "@webassemblyjs/wasm-parser" "1.11.6" + "@webassemblyjs/wast-printer" "1.11.6" -"@webassemblyjs/wasm-gen@1.11.5": - version "1.11.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.5.tgz#ceb1c82b40bf0cf67a492c53381916756ef7f0b1" - integrity sha512-14vteRlRjxLK9eSyYFvw1K8Vv+iPdZU0Aebk3j6oB8TQiQYuO6hj9s4d7qf6f2HJr2khzvNldAFG13CgdkAIfA== +"@webassemblyjs/wasm-gen@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz#fb5283e0e8b4551cc4e9c3c0d7184a65faf7c268" + integrity sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA== dependencies: - "@webassemblyjs/ast" "1.11.5" - "@webassemblyjs/helper-wasm-bytecode" "1.11.5" - "@webassemblyjs/ieee754" "1.11.5" - "@webassemblyjs/leb128" "1.11.5" - "@webassemblyjs/utf8" "1.11.5" + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/ieee754" "1.11.6" + "@webassemblyjs/leb128" "1.11.6" + "@webassemblyjs/utf8" "1.11.6" -"@webassemblyjs/wasm-opt@1.11.5": - version "1.11.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.5.tgz#b52bac29681fa62487e16d3bb7f0633d5e62ca0a" - integrity sha512-tcKwlIXstBQgbKy1MlbDMlXaxpucn42eb17H29rawYLxm5+MsEmgPzeCP8B1Cl69hCice8LeKgZpRUAPtqYPgw== +"@webassemblyjs/wasm-opt@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz#d9a22d651248422ca498b09aa3232a81041487c2" + integrity sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g== dependencies: - "@webassemblyjs/ast" "1.11.5" - "@webassemblyjs/helper-buffer" "1.11.5" - "@webassemblyjs/wasm-gen" "1.11.5" - "@webassemblyjs/wasm-parser" "1.11.5" + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-buffer" "1.11.6" + "@webassemblyjs/wasm-gen" "1.11.6" + "@webassemblyjs/wasm-parser" "1.11.6" -"@webassemblyjs/wasm-parser@1.11.5", "@webassemblyjs/wasm-parser@^1.11.5": - version "1.11.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.5.tgz#7ba0697ca74c860ea13e3ba226b29617046982e2" - integrity sha512-SVXUIwsLQlc8srSD7jejsfTU83g7pIGr2YYNb9oHdtldSxaOhvA5xwvIiWIfcX8PlSakgqMXsLpLfbbJ4cBYew== +"@webassemblyjs/wasm-parser@1.11.6", "@webassemblyjs/wasm-parser@^1.11.5": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz#bb85378c527df824004812bbdb784eea539174a1" + integrity sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ== dependencies: - "@webassemblyjs/ast" "1.11.5" - "@webassemblyjs/helper-api-error" "1.11.5" - "@webassemblyjs/helper-wasm-bytecode" "1.11.5" - "@webassemblyjs/ieee754" "1.11.5" - "@webassemblyjs/leb128" "1.11.5" - "@webassemblyjs/utf8" "1.11.5" + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-api-error" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/ieee754" "1.11.6" + "@webassemblyjs/leb128" "1.11.6" + "@webassemblyjs/utf8" "1.11.6" -"@webassemblyjs/wast-printer@1.11.5": - version "1.11.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.11.5.tgz#7a5e9689043f3eca82d544d7be7a8e6373a6fa98" - integrity sha512-f7Pq3wvg3GSPUPzR0F6bmI89Hdb+u9WXrSKc4v+N0aV0q6r42WoF92Jp2jEorBEBRoRNXgjp53nBniDXcqZYPA== +"@webassemblyjs/wast-printer@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz#a7bf8dd7e362aeb1668ff43f35cb849f188eff20" + integrity sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A== dependencies: - "@webassemblyjs/ast" "1.11.5" + "@webassemblyjs/ast" "1.11.6" "@xtuc/long" "4.2.2" -"@webpack-cli/configtest@^2.0.1": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-2.0.1.tgz#a69720f6c9bad6aef54a8fa6ba9c3533e7ef4c7f" - integrity sha512-njsdJXJSiS2iNbQVS0eT8A/KPnmyH4pv1APj2K0d1wrZcBLw+yppxOy4CGqa0OxDJkzfL/XELDhD8rocnIwB5A== +"@webpack-cli/configtest@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-2.1.0.tgz#b59b33377b1b896a9a7357cfc643b39c1524b1e6" + integrity sha512-K/vuv72vpfSEZoo5KIU0a2FsEoYdW0DUMtMpB5X3LlUwshetMZRZRxB7sCsVji/lFaSxtQQ3aM9O4eMolXkU9w== "@webpack-cli/info@^2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-2.0.1.tgz#eed745799c910d20081e06e5177c2b2569f166c0" integrity sha512-fE1UEWTwsAxRhrJNikE7v4EotYflkEhBL7EbajfkPlf6E37/2QshOy/D48Mw8G5XMFlQtS6YV42vtbG9zBpIQA== -"@webpack-cli/serve@^2.0.2": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-2.0.2.tgz#10aa290e44a182c02e173a89452781b1acbc86d9" - integrity sha512-S9h3GmOmzUseyeFW3tYNnWS7gNUuwxZ3mmMq0JyW78Vx1SGKPSkt5bT4pB0rUnVfHjP0EL9gW2bOzmtiTfQt0A== +"@webpack-cli/serve@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-2.0.4.tgz#3982ee6f8b42845437fc4d391e93ac5d9da52f0f" + integrity sha512-0xRgjgDLdz6G7+vvDLlaRpFatJaJ69uTalZLRSMX5B3VUrDmXcrVA3+6fXXQgmYz7bY9AAgs348XQdmtLsK41A== "@xmldom/xmldom@~0.7.0", "@xmldom/xmldom@~0.7.7": version "0.7.10" @@ -5348,9 +5370,9 @@ acorn-globals@^7.0.0: acorn-walk "^8.0.2" acorn-import-assertions@^1.7.6: - version "1.8.0" - resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz#ba2b5939ce62c238db6d93d81c9b111b29b855e9" - integrity sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw== + version "1.9.0" + resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz#507276249d684797c84e0734ef84860334cfb1ac" + integrity sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA== acorn-jsx@^5.3.2: version "5.3.2" @@ -5789,16 +5811,17 @@ await-lock@^2.2.2: integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw== axe-core@^4.6.2: - version "4.7.0" - resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.7.0.tgz#34ba5a48a8b564f67e103f0aa5768d76e15bbbbf" - integrity sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ== + version "4.7.1" + resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.7.1.tgz#04392c9ccb3d7d7c5d2f8684f148d56d3442f33d" + integrity sha512-sCXXUhA+cljomZ3ZAwb8i1p3oOlkABzPy08ZDAoGcYuvtBPlQ1Ytde129ArXyHWDhfeewq7rlx9F+cUx2SSlkg== -axios@^0.24.0: - version "0.24.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.24.0.tgz#804e6fa1e4b9c5288501dd9dff56a7a0940d20d6" - integrity sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA== +axios@^0.27.2: + version "0.27.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" + integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== dependencies: - follow-redirects "^1.14.4" + follow-redirects "^1.14.9" + form-data "^4.0.0" axios@^1.3.4: version "1.4.0" @@ -6519,14 +6542,14 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001464: - version "1.0.30001482" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001482.tgz#8b3fad73dc35b2674a5c96df2d4f9f1c561435de" - integrity sha512-F1ZInsg53cegyjroxLNW9DmrEQ1SuGRTO1QlpA0o2/6OpQ0gFeDRoq1yFmnr8Sakn9qwwt9DmbxHB6w167OSuQ== + version "1.0.30001488" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001488.tgz#d19d7b6e913afae3e98f023db97c19e9ddc5e91f" + integrity sha512-NORIQuuL4xGpIy6iCCQGN4iFjlBXtfKWIenlUuyZJumLRIindLb7wXM+GO8erEhb7vXfcnf4BAg2PrSDN5TNLQ== case-anything@^2.1.10: - version "2.1.10" - resolved "https://registry.yarnpkg.com/case-anything/-/case-anything-2.1.10.tgz#d18a6ca968d54ec3421df71e3e190f3bced23410" - integrity sha512-JczJwVrCP0jPKh05McyVsuOg6AYosrB9XWZKbQzXeDAm2ClE/PJE/BcrrQrVyGYH7Jg8V/LDupmyL4kFlVsVFQ== + version "2.1.11" + resolved "https://registry.yarnpkg.com/case-anything/-/case-anything-2.1.11.tgz#39a00ff733f26e48729f5c6c7f23763ac8fa0467" + integrity sha512-uzKDXzdM/x914cepWPzElU3y50NRKYhjkO4ittOHLq+rF6M0AgRLF/+yPR1tvwLNAh8WHEPTfhuciZGPfX+oyg== case-sensitive-paths-webpack-plugin@^2.4.0: version "2.4.0" @@ -6553,16 +6576,16 @@ cbor-extract@^2.1.1: "@cbor-extract/cbor-extract-win32-x64" "2.1.1" cbor-x@^1.5.1: - version "1.5.2" - resolved "https://registry.yarnpkg.com/cbor-x/-/cbor-x-1.5.2.tgz#ceabc48bda06185de1f3a078bb4a793e6e222de5" - integrity sha512-JArE6xcgj3eo13fpnShO42QFBUuXP2uG12RLeF2Nb+dJcETFYxkUa27gXQrRYp67Ahtaxyfbg+ihc62XTyQqsQ== + version "1.5.3" + resolved "https://registry.yarnpkg.com/cbor-x/-/cbor-x-1.5.3.tgz#f8252fec7cab86b66c500e0c991788618e6638de" + integrity sha512-adrN0S67C7jY2hgqeGcw+Uj6iEGLQa5D/p6/9YNl5AaVIYJaJz/bARfWsP8UikBZWbhS27LN0DJK4531vo9ODw== optionalDependencies: cbor-extract "^2.1.1" cborg@^1.6.0: - version "1.10.1" - resolved "https://registry.yarnpkg.com/cborg/-/cborg-1.10.1.tgz#24cfe52c69ec0f66f95e23dc57f2086954c8d718" - integrity sha512-et6Qm8MOUY2kCWa5GKk2MlBVoPjHv0hQBmlzI/Z7+5V3VJCeIkGehIB3vWknNsm2kOkAIs6wEKJFJo8luWQQ/w== + version "1.10.2" + resolved "https://registry.yarnpkg.com/cborg/-/cborg-1.10.2.tgz#83cd581b55b3574c816f82696307c7512db759a1" + integrity sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug== chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" @@ -6712,9 +6735,9 @@ cli-cursor@^3.1.0: restore-cursor "^3.1.0" cli-spinners@^2.0.0, cli-spinners@^2.5.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.8.0.tgz#e97a3e2bd00e6d85aa0c13d7f9e3ce236f7787fc" - integrity sha512-/eG5sJcvEIwxcdYM86k5tPwn0MUzkX5YY3eImTGpJOZgVe4SdTMY14vQpcxgBzJ0wXwAYrS8E+c3uHeK4JNyzQ== + version "2.9.0" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.0.tgz#5881d0ad96381e117bbe07ad91f2008fe6ffd8db" + integrity sha512-4/aL9X3Wh0yiMQlE+eeRhWP6vclO3QRtw1JHKIT0FFUs5FjpFmESqtMvYZ0+lbzBw900b95mS0hohy+qn2VK/g== cli-width@^2.0.0: version "2.2.1" @@ -7041,21 +7064,21 @@ copy-webpack-plugin@^10.2.0: serialize-javascript "^6.0.0" core-js-compat@^3.25.1: - version "3.30.1" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.30.1.tgz#961541e22db9c27fc48bfc13a3cafa8734171dfe" - integrity sha512-d690npR7MC6P0gq4npTl5n2VQeNAmUrJ90n+MHiKS7W2+xno4o3F5GDEuylSdi6EJ3VssibSGXOa1r3YXD3Mhw== + version "3.30.2" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.30.2.tgz#83f136e375babdb8c80ad3c22d67c69098c1dd8b" + integrity sha512-nriW1nuJjUgvkEjIot1Spwakz52V9YkYHZAQG6A1eCgC8AA1p0zngrQEP9R0+V6hji5XilWKG1Bd0YRppmGimA== dependencies: browserslist "^4.21.5" core-js-pure@^3.23.3: - version "3.30.1" - resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.30.1.tgz#7d93dc89e7d47b8ef05d7e79f507b0e99ea77eec" - integrity sha512-nXBEVpmUnNRhz83cHd9JRQC52cTMcuXAmR56+9dSMpRdpeA4I1PX6yjmhd71Eyc/wXNsdBdUDIj1QTIeZpU5Tg== + version "3.30.2" + resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.30.2.tgz#005a82551f4af3250dcfb46ed360fad32ced114e" + integrity sha512-p/npFUJXXBkCCTIlEGBdghofn00jWG6ZOtdoIXSJmAu2QBvN0IqpZXWweOytcwE6cfx8ZvVUy1vw8zxhe4Y2vg== core-js@^3.19.2: - version "3.30.1" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.30.1.tgz#fc9c5adcc541d8e9fa3e381179433cbf795628ba" - integrity sha512-ZNS5nbiSwDTq4hFosEDqm65izl2CWmLz0hARJMyNQBgkUZMIF51cQiMvIQKA6hvuaeWxQDP3hEedM1JZIgTldQ== + version "3.30.2" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.30.2.tgz#6528abfda65e5ad728143ea23f7a14f0dcf503fc" + integrity sha512-uBJiDmwqsbJCWHAwjrx3cvjbMXP7xD72Dmsn5LOJpiRmE3WbBbN5rCqQ2Qh6Ek6/eOrjlWngEynBWo4VxerQhg== core-util-is@~1.0.0: version "1.0.3" @@ -7116,16 +7139,16 @@ create-require@^1.1.0: integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== crelt@^1.0.0: - version "1.0.5" - resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.5.tgz#57c0d52af8c859e354bace1883eb2e1eb182bb94" - integrity sha512-+BO9wPPi+DWTDcNYhr/W90myha8ptzftZT+LwcmUbbok0rcP/fequmFYCw8NMoH7pkAZQzU78b3kYrlua5a9eA== + version "1.0.6" + resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72" + integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g== cross-fetch@^3.1.5: - version "3.1.5" - resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" - integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== + version "3.1.6" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.6.tgz#bae05aa31a4da760969756318feeee6e70f15d6c" + integrity sha512-riRvo06crlE8HiqOwIpQhxwdOk4fOeR7FVM/wXoxchFEqMNUjvbs3bfo4OTgMEMHzppd4DxFBDbyySj8Cv781g== dependencies: - node-fetch "2.6.7" + node-fetch "^2.6.11" cross-spawn@^4.0.2: version "4.0.2" @@ -7296,9 +7319,9 @@ css-what@^6.0.1, css-what@^6.1.0: integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== cssdb@^7.1.0: - version "7.5.4" - resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-7.5.4.tgz#e34dafee5184d67634604e345e389ca79ac179ea" - integrity sha512-fGD+J6Jlq+aurfE1VDXlLS4Pt0VtNlu2+YgfGOdMxRyl/HQ9bDiHTwSck1Yz8A97Dt/82izSK6Bp/4nVqacOsg== + version "7.6.0" + resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-7.6.0.tgz#beac8f7a5f676db62d3c33da517ef4c9eb008f8b" + integrity sha512-Nna7rph8V0jC6+JBY4Vk4ndErUmfJfV6NJCaZdurL0omggabiy+QB2HCQtu5c/ACLZ0I7REv7A4QyPIoYzZx0w== cssesc@^3.0.0: version "3.0.0" @@ -7685,9 +7708,9 @@ detect-port-alt@^1.1.6: debug "^2.6.0" detox@^20.1.2: - version "20.7.1" - resolved "https://registry.yarnpkg.com/detox/-/detox-20.7.1.tgz#3e3981a8eaa223135ca85d44aa9dc3b742b8ed46" - integrity sha512-a8y+M40g4goqWnyHZnestmVL/EII8Hq4utCK4kuSpvRHWkBA5KuQFlXErfNrrOcqXuXhPqdBnxqO9VsMAlLHFA== + version "20.9.0" + resolved "https://registry.yarnpkg.com/detox/-/detox-20.9.0.tgz#936050d7d72e141aebd92483e8893598686aa76a" + integrity sha512-juf0UQLcdk+CsggD25y8sDu0bYzJL9VWmwPKKtW2jsjOigI16jO1xwKCCYsuPYjprQmH+cJWXQ5k03zF1+JvXQ== dependencies: ajv "^8.6.3" bunyan "^1.8.12" @@ -7724,11 +7747,6 @@ detox@^20.1.2: yargs-parser "^21.0.0" yargs-unparser "^2.0.0" -did-resolver@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/did-resolver/-/did-resolver-4.1.0.tgz#740852083c4fd5bf9729d528eca5d105aff45eb6" - integrity sha512-S6fWHvCXkZg2IhS4RcVHxwuyVejPR7c+a4Go0xbQ9ps5kILa8viiYQgrM4gfTyeTjJ0ekgJH9gk/BawTpmkbZA== - didyoumean@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" @@ -7956,9 +7974,9 @@ ejs@^3.1.6: jake "^10.8.5" electron-to-chromium@^1.4.284: - version "1.4.378" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.378.tgz#73431ffd5fffebc18b4e897fac2e7d4ae6d559d9" - integrity sha512-RfCD26kGStl6+XalfX3DGgt3z2DNwJS5DKRHCpkPq5T/PqpZMPB1moSRXuK9xhkt/sF57LlpzJgNoYl7mO7Z6w== + version "1.4.397" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.397.tgz#82a7e26c657538d59bb713b97ac22f97ea3a90ea" + integrity sha512-jwnPxhh350Q/aMatQia31KAIQdhEsYS0fFZ0BQQlN9tfvOEwShu6ZNwI4kL/xBabjcB/nTy6lSt17kNIluJZ8Q== email-validator@^2.0.4: version "2.0.4" @@ -8007,10 +8025,10 @@ end-of-stream@^1.1.0, end-of-stream@^1.4.1: dependencies: once "^1.4.0" -enhanced-resolve@^5.13.0: - version "5.13.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.13.0.tgz#26d1ecc448c02de997133217b5c1053f34a0a275" - integrity sha512-eyV8f0y1+bzyfh8xAwW/WTSZpLbjhqc4ne9eGSH4Zo2ejdyiNG9pU6mf9DG8a7+Auk6MFTlNOT4Y2y/9k8GKVg== +enhanced-resolve@^5.14.0: + version "5.14.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.14.0.tgz#0b6c676c8a3266c99fa281e4433a706f5c0c61c4" + integrity sha512-+DCows0XNwLDcUhbFJPdlQEVnT2zXlCv7hPxemTz86/O+B/hCQ+mb7ydkPKiflpVraqLPCAfu7lDy+hBXueojw== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -8379,9 +8397,9 @@ eslint-plugin-react@^7.27.1, eslint-plugin-react@^7.30.1: string.prototype.matchall "^4.0.8" eslint-plugin-testing-library@^5.0.1: - version "5.10.3" - resolved "https://registry.yarnpkg.com/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.10.3.tgz#e613fbaf9a145e9eef115d080b32cb488fae622e" - integrity sha512-0yhsKFsjHLud5PM+f2dWr9K3rqYzMy4cSHs3lcmFYMa1CdSzRvHGgXvsFarBjZ41gU8jhTdMIkg8jHLxGJqLqw== + version "5.11.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.11.0.tgz#0bad7668e216e20dd12f8c3652ca353009163121" + integrity sha512-ELY7Gefo+61OfXKlQeXNIDVVLPcvKTeiQOoMZG9TeuWa7Ln4dUNRv8JdRWBQI9Mbb427XGlVB1aa1QPZxBJM8Q== dependencies: "@typescript-eslint/utils" "^5.58.0" @@ -8406,10 +8424,10 @@ eslint-visitor-keys@^2.1.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== -eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz#c7f0f956124ce677047ddbc192a68f999454dedc" - integrity sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ== +eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz#c22c48f48942d08ca824cc526211ae400478a994" + integrity sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA== eslint-webpack-plugin@^3.1.1: version "3.2.0" @@ -8423,14 +8441,14 @@ eslint-webpack-plugin@^3.1.1: schema-utils "^4.0.0" eslint@^8.19.0, eslint@^8.3.0: - version "8.39.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.39.0.tgz#7fd20a295ef92d43809e914b70c39fd5a23cf3f1" - integrity sha512-mwiok6cy7KTW7rBpo05k6+p4YVZByLNjAZ/ACB9DRCu4YDRwjXI01tWHp6KAUWelsBetTxKK/2sHB0vdS8Z2Og== + version "8.40.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.40.0.tgz#a564cd0099f38542c4e9a2f630fa45bf33bc42a4" + integrity sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ== dependencies: "@eslint-community/eslint-utils" "^4.2.0" "@eslint-community/regexpp" "^4.4.0" - "@eslint/eslintrc" "^2.0.2" - "@eslint/js" "8.39.0" + "@eslint/eslintrc" "^2.0.3" + "@eslint/js" "8.40.0" "@humanwhocodes/config-array" "^0.11.8" "@humanwhocodes/module-importer" "^1.0.1" "@nodelib/fs.walk" "^1.2.8" @@ -8441,8 +8459,8 @@ eslint@^8.19.0, eslint@^8.3.0: doctrine "^3.0.0" escape-string-regexp "^4.0.0" eslint-scope "^7.2.0" - eslint-visitor-keys "^3.4.0" - espree "^9.5.1" + eslint-visitor-keys "^3.4.1" + espree "^9.5.2" esquery "^1.4.2" esutils "^2.0.2" fast-deep-equal "^3.1.3" @@ -8468,14 +8486,14 @@ eslint@^8.19.0, eslint@^8.3.0: strip-json-comments "^3.1.0" text-table "^0.2.0" -espree@^9.5.1: - version "9.5.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.5.1.tgz#4f26a4d5f18905bf4f2e0bd99002aab807e96dd4" - integrity sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg== +espree@^9.5.2: + version "9.5.2" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.5.2.tgz#e994e7dc33a082a7a82dceaf12883a829353215b" + integrity sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw== dependencies: acorn "^8.8.0" acorn-jsx "^5.3.2" - eslint-visitor-keys "^3.4.0" + eslint-visitor-keys "^3.4.1" esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: version "4.0.1" @@ -8783,10 +8801,10 @@ expo-modules-autolinking@1.2.0: find-up "^5.0.0" fs-extra "^9.1.0" -expo-modules-core@1.2.6: - version "1.2.6" - resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-1.2.6.tgz#921abc8031fe0e5474ee48905071902b9627d051" - integrity sha512-vyleKepkP8F6L+D55B/E4FbZ8x9pdy3yw/mdbGBkDkrmo2gmeMjOM1mKLSszOkLIqet05O7Wy8m0FZHZTo0VBg== +expo-modules-core@1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-1.2.7.tgz#c80627b13a8f1c94ae9da8eea41e1ef1df5788c8" + integrity sha512-sulqn2M8+tIdxi6QFkKppDEzbePAscgE2LEHocYoQOgHxJpeT7axE0Hkzc+81EeviQilZzGeFZMtNMGh3c9yJg== dependencies: compare-versions "^3.4.0" invariant "^2.2.4" @@ -8855,14 +8873,14 @@ expo-updates@~0.16.4: resolve-from "^5.0.0" expo@~48.0.15: - version "48.0.15" - resolved "https://registry.yarnpkg.com/expo/-/expo-48.0.15.tgz#28194c03ac85f7f5a87b7493b8cef0eb405eccbe" - integrity sha512-me2Xxr7Faxf60BiKq8WBSwkYV9BVbS+VqeHRFdXduVA0Uj2zp1a0zYB5eblmWqpRco75VBUgOa9M+/eR1YVZmw== + version "48.0.17" + resolved "https://registry.yarnpkg.com/expo/-/expo-48.0.17.tgz#000773a2675e5bf95688a84fbc625c105f8b8af9" + integrity sha512-5T1CsMUlfI+xFB89GOU+/xtSSbSBBFVTqwgheAU0cQolfbs+YyJCMTKU5vN45N5OK+ym7p/LKPa6DQAxYPF8YQ== dependencies: "@babel/runtime" "^7.20.0" "@expo/cli" "0.7.1" "@expo/config" "8.0.2" - "@expo/config-plugins" "6.0.1" + "@expo/config-plugins" "6.0.2" "@expo/vector-icons" "^13.0.0" babel-preset-expo "~9.3.2" cross-spawn "^6.0.5" @@ -8873,7 +8891,7 @@ expo@~48.0.15: expo-font "~11.1.1" expo-keep-awake "~12.0.1" expo-modules-autolinking "1.2.0" - expo-modules-core "1.2.6" + expo-modules-core "1.2.7" fbemitter "^3.0.0" getenv "^1.0.0" invariant "^2.2.4" @@ -9021,9 +9039,9 @@ fast-printf@^1.6.9: boolean "^3.1.4" fast-redact@^3.1.1: - version "3.1.2" - resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.1.2.tgz#d58e69e9084ce9fa4c1a6fa98a3e1ecf5d7839aa" - integrity sha512-+0em+Iya9fKGfEQGcd62Yv6onjBmmhV1uh86XVfOU8VwAe6kaFdQCWI9s0/Nnugx5Vd9tdbZ7e6gE2tR9dzXdw== + version "3.2.0" + resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.2.0.tgz#b1e2d39bc731376d28bde844454fa23e26919987" + integrity sha512-zaTadChr+NekyzallAMXATXLOR8MNx3zqpZ0MUF2aGf4EathnG0f32VLODNlY8IuGY3HoRO2L6/6fSzNsLaHIw== fast-text-encoding@^1.0.6: version "1.0.6" @@ -9129,7 +9147,7 @@ file-uri-to-path@1.0.0: resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== -filelist@^1.0.1: +filelist@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" integrity sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q== @@ -9272,16 +9290,16 @@ flatted@^3.1.0: integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== flow-parser@0.*: - version "0.205.0" - resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.205.0.tgz#8756173b6488dedc31ab838e80c8f008d7a44e05" - integrity sha512-ZJ6VuLe/BoqeI4GsF+ZuzlpfGi3FCnBrb4xDYhgEJxRt7SAj3ibRuRSsuJSRcY+lQhPZRPNbNWiQqFMxramUzw== + version "0.206.0" + resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.206.0.tgz#f4f794f8026535278393308e01ea72f31000bfef" + integrity sha512-HVzoK3r6Vsg+lKvlIZzaWNBVai+FXTX1wdYhz/wVlH13tb/gOdLXmlTqy6odmTBhT5UoWUbq0k8263Qhr9d88w== flow-parser@^0.185.0: version "0.185.2" resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.185.2.tgz#cb7ee57f77377d6c5d69a469e980f6332a15e492" integrity sha512-2hJ5ACYeJCzNtiVULov6pljKOLygy0zddoqSI1fFetM+XRPpRshFdGEijtqlamA1XwyZ+7rhryI6FQFzvtLWUQ== -follow-redirects@^1.0.0, follow-redirects@^1.14.4, follow-redirects@^1.15.0: +follow-redirects@^1.0.0, follow-redirects@^1.14.9, follow-redirects@^1.15.0: version "1.15.2" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== @@ -9491,12 +9509,13 @@ get-caller-file@^2.0.1, get-caller-file@^2.0.5: integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.0.tgz#7ad1dc0535f3a2904bba075772763e5051f6d05f" - integrity sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q== + version "1.2.1" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.1.tgz#d295644fed4505fc9cde952c37ee12b477a83d82" + integrity sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw== dependencies: function-bind "^1.1.1" has "^1.0.3" + has-proto "^1.0.1" has-symbols "^1.0.3" get-own-enumerable-property-symbols@^3.0.0: @@ -10346,9 +10365,9 @@ is-ci@^2.0.0: ci-info "^2.0.0" is-core-module@^2.11.0, is-core-module@^2.9.0: - version "2.12.0" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.12.0.tgz#36ad62f6f73c8253fd6472517a12483cf03e7ec4" - integrity sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ== + version "2.12.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.12.1.tgz#0c0b6885b6f80011c71541ce15c8d66cf5a4f9fd" + integrity sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg== dependencies: has "^1.0.3" @@ -10750,14 +10769,14 @@ istanbul-reports@^3.1.3: istanbul-lib-report "^3.0.0" jake@^10.8.5: - version "10.8.5" - resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.5.tgz#f2183d2c59382cb274226034543b9c03b8164c46" - integrity sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw== + version "10.8.6" + resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.6.tgz#227a96786a1e035214e0ba84b482d6223d41ef04" + integrity sha512-G43Ub9IYEFfu72sua6rzooi8V8Gz2lkfk48rW20vEWCGizeaEPlKB1Kh8JIA84yQbiAEfqlPmSpGgCKKxH3rDA== dependencies: async "^3.2.3" chalk "^4.0.2" - filelist "^1.0.1" - minimatch "^3.0.4" + filelist "^1.0.4" + minimatch "^3.1.2" jest-changed-files@^27.5.1: version "27.5.1" @@ -12903,10 +12922,10 @@ minipass@^3.0.0, minipass@^3.1.1: dependencies: yallist "^4.0.0" -minipass@^4.0.0: - version "4.2.8" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-4.2.8.tgz#f0010f64393ecfc1d1ccb5f582bcaf45f48e1a3a" - integrity sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ== +minipass@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" + integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== minizlib@^2.1.1: version "2.1.2" @@ -13131,17 +13150,10 @@ node-dir@^0.1.17: dependencies: minimatch "^3.0.2" -node-fetch@2.6.7: - version "2.6.7" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" - integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== - dependencies: - whatwg-url "^5.0.0" - -node-fetch@^2.0.0-alpha.8, node-fetch@^2.2.0, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.7: - version "2.6.9" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" - integrity sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg== +node-fetch@^2.0.0-alpha.8, node-fetch@^2.2.0, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.11, node-fetch@^2.6.7: + version "2.6.11" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.11.tgz#cde7fc71deef3131ef80a738919f999e6edfff25" + integrity sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w== dependencies: whatwg-url "^5.0.0" @@ -13200,9 +13212,9 @@ nodemailer-html-to-text@^3.2.0: html-to-text "7.1.1" nodemailer@^6.8.0: - version "6.9.1" - resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.9.1.tgz#8249d928a43ed85fec17b13d2870c8f758a126ed" - integrity sha512-qHw7dOiU5UKNnQpXktdgQ1d3OFgRAekuvbJLcdG5dnEo/GtcTHRYM7+UfJARdOFU9WUQO8OiIamgWPmiSFHYAA== + version "6.9.2" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.9.2.tgz#b79051811edd52c2436ad1c6aed2dc45b9c9cf1f" + integrity sha512-4+TYaa/e1nIxQfyw/WzNPYTEZ5OvHIDEnmjs4LPmIfccPQN+2CYKmGHjWixn/chzD3bmUTu5FMfpltizMxqzdg== normalize-css-color@^1.0.2: version "1.0.2" @@ -13559,9 +13571,9 @@ ora@^5.4.1: wcwidth "^1.0.1" orderedmap@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.1.0.tgz#819457082fa3a06abd316d83a281a1ca467437cd" - integrity sha512-/pIFexOm6S70EPdznemIz3BQZoJ4VTFrhqzu0ACBqBgeLsLxq8e6Jim63ImIfwW/zAD1AlXpRMlOv3aghmo4dA== + version "2.1.1" + resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.1.1.tgz#61481269c44031c449915497bf5a4ad273c512d2" + integrity sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g== os-homedir@^1.0.0: version "1.0.2" @@ -13831,10 +13843,15 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== -pg-connection-string@^2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34" - integrity sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ== +pg-cloudflare@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.0.tgz#833d70870d610d14bf9df7afb40e1cba310c17a0" + integrity sha512-tGM8/s6frwuAIyRcJ6nWcIvd3+3NmUKIs6OjviIm1HPPFEt5MzQDOTBQyhPWg/m0kCl95M6gA1JaIXtS8KovOA== + +pg-connection-string@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.0.tgz#12a36cc4627df19c25cc1b9b736cc39ee1f73ae8" + integrity sha512-x14ibktcwlHKoHxx9X3uTVW9zIGR41ZB6QNhHb21OPNdCCO3NaRnpJuwKIQSR4u+Yqjx4HCvy7Hh7VSy1U4dGg== pg-int8@1.0.1: version "1.0.1" @@ -13863,17 +13880,19 @@ pg-types@^2.1.0: postgres-interval "^1.1.0" pg@^8.10.0, pg@^8.9.0: - version "8.10.0" - resolved "https://registry.yarnpkg.com/pg/-/pg-8.10.0.tgz#5b8379c9b4a36451d110fc8cd98fc325fe62ad24" - integrity sha512-ke7o7qSTMb47iwzOSaZMfeR7xToFdkE71ifIipOAAaLIM0DYzfOAXlgFFmYUIE2BcJtvnVlGCID84ZzCegE8CQ== + version "8.11.0" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.11.0.tgz#a37e534e94b57a7ed811e926f23a7c56385f55d9" + integrity sha512-meLUVPn2TWgJyLmy7el3fQQVwft4gU5NGyvV0XbD41iU9Jbg8lCH4zexhIkihDzVHJStlt6r088G6/fWeNjhXA== dependencies: buffer-writer "2.0.0" packet-reader "1.0.0" - pg-connection-string "^2.5.0" + pg-connection-string "^2.6.0" pg-pool "^3.6.0" pg-protocol "^1.6.0" pg-types "^2.1.0" pgpass "1.x" + optionalDependencies: + pg-cloudflare "^1.1.0" pgpass@1.x: version "1.0.5" @@ -13938,14 +13957,14 @@ pino-http@^8.2.1, pino-http@^8.3.3: process-warning "^2.0.0" pino-std-serializers@^6.0.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-6.2.0.tgz#169048c0df3f61352fce56aeb7fb962f1b66ab43" - integrity sha512-IWgSzUL8X1w4BIWTwErRgtV8PyOGOOi60uqv0oKuS/fOA8Nco/OeI6lBuc4dyP8MMfdFwyHqTMcBIA7nDiqEqA== + version "6.2.1" + resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-6.2.1.tgz#369f4ae2a19eb6d769ddf2c88a2164b76879a284" + integrity sha512-wHuWB+CvSVb2XqXM0W/WOYUkVSPbiJb9S5fNB7TBhd8s892Xq910bRxwHtC4l71hgztObTjXL6ZheZXFjhDrDQ== pino@^8.0.0, pino@^8.11.0, pino@^8.6.1: - version "8.11.0" - resolved "https://registry.yarnpkg.com/pino/-/pino-8.11.0.tgz#2a91f454106b13e708a66c74ebc1c2ab7ab38498" - integrity sha512-Z2eKSvlrl2rH8p5eveNUnTdd4AjJk8tAsLkHYZQKGHP4WTh2Gi1cOSOs3eWPqaj+niS3gj4UkoreoaWgF3ZWYg== + version "8.14.1" + resolved "https://registry.yarnpkg.com/pino/-/pino-8.14.1.tgz#bb38dcda8b500dd90c1193b6c9171eb777a47ac8" + integrity sha512-8LYNv7BKWXSfS+k6oEc6occy5La+q2sPwU3q2ljTX5AZk7v+5kND2o5W794FyRaqha6DJajmkNRsWtPpFyMUdw== dependencies: atomic-sleep "^1.0.0" fast-redact "^3.1.1" @@ -14516,9 +14535,9 @@ postcss-selector-not@^6.0.1: postcss-selector-parser "^6.0.10" postcss-selector-parser@^6.0.10, postcss-selector-parser@^6.0.11, postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.9: - version "6.0.12" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.12.tgz#2efae5ffab3c8bfb2b7fbf0c426e3bca616c4abb" - integrity sha512-NdxGCAZdRrwVI1sy59+Wzrh+pMMHxapGnpfenDVlMEXoOcvt4pGE0JLK9YY2F5dLxcFYA/YbVQKhcGU+FtSYQg== + version "6.0.13" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz#d05d8d76b1e8e173257ef9d60b706a8e5e99bf1b" + integrity sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ== dependencies: cssesc "^3.0.0" util-deprecate "^1.0.2" @@ -14749,41 +14768,41 @@ proper-lockfile@^3.0.2: signal-exit "^3.0.2" prosemirror-changeset@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/prosemirror-changeset/-/prosemirror-changeset-2.2.0.tgz#22c05da271a118be40d3e339fa2cace789b1254b" - integrity sha512-QM7ohGtkpVpwVGmFb8wqVhaz9+6IUXcIQBGZ81YNAKYuHiFJ1ShvSzab4pKqTinJhwciZbrtBEk/2WsqSt2PYg== + version "2.2.1" + resolved "https://registry.yarnpkg.com/prosemirror-changeset/-/prosemirror-changeset-2.2.1.tgz#dae94b63aec618fac7bb9061648e6e2a79988383" + integrity sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ== dependencies: prosemirror-transform "^1.0.0" prosemirror-collab@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/prosemirror-collab/-/prosemirror-collab-1.3.0.tgz#601d33473bf72e6c43041a54b860c84c60b37769" - integrity sha512-+S/IJ69G2cUu2IM5b3PBekuxs94HO1CxJIWOFrLQXUaUDKL/JfBx+QcH31ldBlBXyDEUl+k3Vltfi1E1MKp2mA== + version "1.3.1" + resolved "https://registry.yarnpkg.com/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz#0e8c91e76e009b53457eb3b3051fb68dad029a33" + integrity sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ== dependencies: prosemirror-state "^1.0.0" prosemirror-commands@^1.0.0, prosemirror-commands@^1.3.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.5.1.tgz#89ddfa14e144dcc7fb0938aa0e2568c7fdde306f" - integrity sha512-ga1ga/RkbzxfAvb6iEXYmrEpekn5NCwTb8w1dr/gmhSoaGcQ0VPuCzOn5qDEpC45ql2oDkKoKQbRxLJwKLpMTQ== + version "1.5.2" + resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.5.2.tgz#e94aeea52286f658cd984270de9b4c3fff580852" + integrity sha512-hgLcPaakxH8tu6YvVAaILV2tXYsW3rAdDR8WNkeKGcgeMVQg3/TMhPdVoh7iAmfgVjZGtcOSjKiQaoeKjzd2mQ== dependencies: prosemirror-model "^1.0.0" prosemirror-state "^1.0.0" prosemirror-transform "^1.0.0" prosemirror-dropcursor@^1.5.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.0.tgz#7bfa11925e0da41d1db869954fe51e1aa55158e4" - integrity sha512-TZMitR8nlp9Xh42pDYGcWopCoFPmJduoyGJ7FjYM2/7gZKnfD41TIaZN5Q1cQjm6Fm/P5vk/DpVYFhS8kDdigw== + version "1.8.1" + resolved "https://registry.yarnpkg.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.1.tgz#49b9fb2f583e0d0f4021ff87db825faa2be2832d" + integrity sha512-M30WJdJZLyXHi3N8vxN6Zh5O8ZBbQCz0gURTfPmTIBNQ5pxrdU7A58QkNqfa98YEjSAL1HUyyU34f6Pm5xBSGw== dependencies: prosemirror-state "^1.0.0" prosemirror-transform "^1.1.0" prosemirror-view "^1.1.0" prosemirror-gapcursor@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.1.tgz#8cfd874592e4504d63720e14ed680c7866e64554" - integrity sha512-GKTeE7ZoMsx5uVfc51/ouwMFPq0o8YrZ7Hx4jTF4EeGbXxBveUV8CGv46mSHuBBeXGmvu50guoV2kSnOeZZnUA== + version "1.3.2" + resolved "https://registry.yarnpkg.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz#5fa336b83789c6199a7341c9493587e249215cb4" + integrity sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ== dependencies: prosemirror-keymap "^1.0.0" prosemirror-model "^1.0.0" @@ -14791,9 +14810,9 @@ prosemirror-gapcursor@^1.3.1: prosemirror-view "^1.0.0" prosemirror-history@^1.0.0, prosemirror-history@^1.3.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-1.3.1.tgz#d0dba9ed1cc2bce55a45ce9c7c8224e641f276b8" - integrity sha512-YMV/IWBZ+LZSfaNcBbPcaQUiAiJRYFyJW2aapuNzL8nhIRsI7fIO0ykJFSe802+mWeoTsVJ1jxvRWPYqaUqljQ== + version "1.3.2" + resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-1.3.2.tgz#ce6ad7ab9db83e761aee716f3040d74738311b15" + integrity sha512-/zm0XoU/N/+u7i5zepjmZAEnpvjDtzoPWW6VmKptcAnPadN/SStsBjMImdCEbb3seiNTpveziPTIrXQbHLtU1g== dependencies: prosemirror-state "^1.2.2" prosemirror-transform "^1.0.0" @@ -14801,33 +14820,33 @@ prosemirror-history@^1.0.0, prosemirror-history@^1.3.0: rope-sequence "^1.3.0" prosemirror-inputrules@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/prosemirror-inputrules/-/prosemirror-inputrules-1.2.0.tgz#476dde2dc244050b3aca00cf58a82adfad6749e7" - integrity sha512-eAW/M/NTSSzpCOxfR8Abw6OagdG0MiDAiWHQMQveIsZtoKVYzm0AflSPq/ymqJd56/Su1YPbwy9lM13wgHOFmQ== + version "1.2.1" + resolved "https://registry.yarnpkg.com/prosemirror-inputrules/-/prosemirror-inputrules-1.2.1.tgz#8faf3d78c16150aedac71d326a3e3947417ce557" + integrity sha512-3LrWJX1+ULRh5SZvbIQlwZafOXqp1XuV21MGBu/i5xsztd+9VD15x6OtN6mdqSFI7/8Y77gYUbQ6vwwJ4mr6QQ== dependencies: prosemirror-state "^1.0.0" prosemirror-transform "^1.0.0" prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.1.2, prosemirror-keymap@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.2.1.tgz#3839e7db66cecddae7451f4246e73bdd8489be1d" - integrity sha512-kVK6WGC+83LZwuSJnuCb9PsADQnFZllt94qPP3Rx/vLcOUV65+IbBeH2nS5cFggPyEVJhGkGrgYFRrG250WhHQ== + version "1.2.2" + resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.2.2.tgz#14a54763a29c7b2704f561088ccf3384d14eb77e" + integrity sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ== dependencies: prosemirror-state "^1.0.0" w3c-keyname "^2.2.0" prosemirror-markdown@^1.10.1: - version "1.10.1" - resolved "https://registry.yarnpkg.com/prosemirror-markdown/-/prosemirror-markdown-1.10.1.tgz#e20468201cda1916a6182686159398b242bb78ab" - integrity sha512-s7iaTLiX+qO5z8kF2NcMmy2T7mIlxzkS4Sp3vTKSYChPtbMpg6YxFkU0Y06rUg2WtKlvBu7v1bXzlGBkfjUWAA== + version "1.11.0" + resolved "https://registry.yarnpkg.com/prosemirror-markdown/-/prosemirror-markdown-1.11.0.tgz#75f2d6f14655762b4b8a247436b87ed81e22c7ee" + integrity sha512-yP9mZqPRstjZhhf3yykCQNE3AijxARrHe4e7esV9A+gp4cnGOH4QvrKYPpXLHspNWyvJJ+0URH+iIvV5qP1I2Q== dependencies: markdown-it "^13.0.1" prosemirror-model "^1.0.0" prosemirror-menu@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/prosemirror-menu/-/prosemirror-menu-1.2.1.tgz#94d99a8547b7ba5680c20e9c497ce19846ce3b2c" - integrity sha512-sBirXxVfHalZO4f1ZS63WzewINK4182+7dOmoMeBkqYO8wqMBvBS7wQuwVOHnkMWPEh0+N0LJ856KYUN+vFkmQ== + version "1.2.2" + resolved "https://registry.yarnpkg.com/prosemirror-menu/-/prosemirror-menu-1.2.2.tgz#c545a2de0b8cb79babc07682b1d93de0f273aa33" + integrity sha512-437HIWTq4F9cTX+kPfqZWWm+luJm95Aut/mLUy+9OMrOml0bmWDS26ceC6SNfb2/S94et1sZ186vLO7pDHzxSw== dependencies: crelt "^1.0.0" prosemirror-commands "^1.0.0" @@ -14835,32 +14854,32 @@ prosemirror-menu@^1.2.1: prosemirror-state "^1.0.0" prosemirror-model@^1.0.0, prosemirror-model@^1.16.0, prosemirror-model@^1.18.1, prosemirror-model@^1.19.0, prosemirror-model@^1.8.1: - version "1.19.0" - resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.19.0.tgz#d7ad9a65ada0bb12196f64fe0dd4fc392c841c29" - integrity sha512-/CvFGJnwc41EJSfDkQLly1cAJJJmBpZwwUJtwZPTjY2RqZJfM8HVbCreOY/jti8wTRbVyjagcylyGoeJH/g/3w== + version "1.19.1" + resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.19.1.tgz#7e10cd9584a0a55c87ffbecc68aa9d105b9a0f53" + integrity sha512-RpV0fZfy74DEO9GPRbGcG6xN33KuqEvlLE2V0e5CXUGs3xkZsiJfx1dcYPU57+606NVYCaDN1riFXdXBQRaRcg== dependencies: orderedmap "^2.0.0" prosemirror-schema-basic@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.1.tgz#a5a137a6399d1a829873332117d2fe8131d291d0" - integrity sha512-vYBdIHsYKSDIqYmPBC7lnwk9DsKn8PnVqK97pMYP5MLEDFqWIX75JiaJTzndBii4bRuNqhC2UfDOfM3FKhlBHg== + version "1.2.2" + resolved "https://registry.yarnpkg.com/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.2.tgz#6695f5175e4628aab179bf62e5568628b9cfe6c7" + integrity sha512-/dT4JFEGyO7QnNTe9UaKUhjDXbTNkiWTq/N4VpKaF79bBjSExVV2NXmJpcM7z/gD7mbqNjxbmWW5nf1iNSSGnw== dependencies: prosemirror-model "^1.19.0" prosemirror-schema-list@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.2.2.tgz#bafda37b72367d39accdcaf6ddf8fb654a16e8e5" - integrity sha512-rd0pqSDp86p0MUMKG903g3I9VmElFkQpkZ2iOd3EOVg1vo5Cst51rAsoE+5IPy0LPXq64eGcCYlW1+JPNxOj2w== + version "1.2.3" + resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.2.3.tgz#12e3d70cb17780980a3c28588ed7c888121d5e8d" + integrity sha512-HD8yjDOusz7JB3oBFCaMOpEN9Z9DZttLr6tcASjnvKMc0qTyX5xgAN8YiMFFEcwyhF7WZrZ2YQkAwzsn8ICVbQ== dependencies: prosemirror-model "^1.0.0" prosemirror-state "^1.0.0" prosemirror-transform "^1.0.0" prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.3.1, prosemirror-state@^1.4.1: - version "1.4.2" - resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.4.2.tgz#f93bd8a33a4454efab917ba9b738259d828db7e5" - integrity sha512-puuzLD2mz/oTdfgd8msFbe0A42j5eNudKAAPDB0+QJRw8cO1ygjLmhLrg9RvDpf87Dkd6D4t93qdef00KKNacQ== + version "1.4.3" + resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.4.3.tgz#94aecf3ffd54ec37e87aa7179d13508da181a080" + integrity sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q== dependencies: prosemirror-model "^1.0.0" prosemirror-transform "^1.0.0" @@ -14888,16 +14907,16 @@ prosemirror-trailing-node@^2.0.2: escape-string-regexp "^4.0.0" prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.2.1, prosemirror-transform@^1.7.0: - version "1.7.1" - resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.7.1.tgz#b516e818c3add0bdf960f4ca8ccb9d057a3ba21b" - integrity sha512-VteoifAfpt46z0yEt6Fc73A5OID9t/y2QIeR5MgxEwTuitadEunD/V0c9jQW8ziT8pbFM54uTzRLJ/nLuQjMxg== + version "1.7.2" + resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.7.2.tgz#f3e57d8424afa6ab7c2b2319cc0ac58e75f7160b" + integrity sha512-b94lVUdA9NyaYRb2WuGSgb5YANiITa05dtew9eSK+KkYu64BCnU27WhJPE95gAWAnhV57CM3FabWXM23gri8Kg== dependencies: prosemirror-model "^1.0.0" prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.13.3, prosemirror-view@^1.27.0, prosemirror-view@^1.28.2, prosemirror-view@^1.31.0: - version "1.31.1" - resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.31.1.tgz#706611f134018a4dd832110911bdd908e3af92c1" - integrity sha512-9NKJdXnGV4+1qFRi16XFZxpnx6zNok9MEj/HElkqUJ1HtOyKOICffKxqoXUUCAdHrrP+yMDvdXc6wT7GGWBL3A== + version "1.31.3" + resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.31.3.tgz#cfe171c4e50a577526d0235d9ec757cdddf6017d" + integrity sha512-UYDa8WxRFZm0xQLXiPJUVTl6H08Fn0IUVDootA7ZlQwzooqVWnBOXLovJyyTKgws1nprfsPhhlvWgt2jo4ZA6g== dependencies: prosemirror-model "^1.16.0" prosemirror-state "^1.0.0" @@ -15101,9 +15120,9 @@ react-dev-utils@^12.0.1: text-table "^0.2.0" react-devtools-core@^4.26.1: - version "4.27.6" - resolved "https://registry.yarnpkg.com/react-devtools-core/-/react-devtools-core-4.27.6.tgz#e5a613014f7506801ed6c1a97bd0e6316cc9c48a" - integrity sha512-jeFNhEzcSwpiqmw+zix5IFibNEPmUodICN7ClrlRKGktzO/3FMteMb52l1NRUiz/ABSYt9hOZ9IPgVDrg5pyUw== + version "4.27.7" + resolved "https://registry.yarnpkg.com/react-devtools-core/-/react-devtools-core-4.27.7.tgz#458a6541483078d60a036c75bf88f54c478086ec" + integrity sha512-12N0HrhCPbD76Z7SkyJdGdXdPGouUsgV6tlEsbSpAnLDO06tjXZP+irht4wPdYwJAJRQ85DxL48eQoz7UmrSuQ== dependencies: shell-quote "^1.6.1" ws "^7" @@ -15147,9 +15166,9 @@ react-native-appstate-hook@^1.0.6: integrity sha512-0hPVyf5yLxCSVrrNEuGqN1ZnSSj3Ye2gZex0NtcK/AHYwMc0rXWFNZjBKOoZSouspqu3hXBbQ6NOUSTzrME1AQ== react-native-background-fetch@^4.1.8: - version "4.1.9" - resolved "https://registry.yarnpkg.com/react-native-background-fetch/-/react-native-background-fetch-4.1.9.tgz#10ebff9ca45a8868f1a72b2aa6cea40d499ece6e" - integrity sha512-sk4MCXRhGghBXu9ReabuT8U0WRzjsMt2i/nqCwR9eHi0hux+4kUh5ubpLKLByw5G8WifUv1sp6qsA7uvQERrrQ== + version "4.1.10" + resolved "https://registry.yarnpkg.com/react-native-background-fetch/-/react-native-background-fetch-4.1.10.tgz#12c7e85140af67fb05edb7cd9960e4f09a457797" + integrity sha512-Ug54vTctZuD/c06ZLk/VyvFdhw/hCVVOHYR5heyMqc6FlT/m9fVhFWyl4uH3JmPCzmWDVR3fO28CzrGpKOrusw== react-native-codegen@^0.71.5: version "0.71.5" @@ -15168,6 +15187,13 @@ react-native-dotenv@^3.3.1: dependencies: dotenv "^16.0.3" +react-native-draggable-flatlist@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/react-native-draggable-flatlist/-/react-native-draggable-flatlist-4.0.1.tgz#2f027d387ba4b8f3eb0907340e32cb85e6460df2" + integrity sha512-ZO1QUTNx64KZfXGXeXcBfql67l38X7kBcJ3rxUVZzPHt5r035GnGzIC0F8rqSXp6zgnwgUYMfB6zQc5PKmPL9Q== + dependencies: + "@babel/preset-typescript" "^7.17.12" + react-native-drawer-layout@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/react-native-drawer-layout/-/react-native-drawer-layout-3.2.0.tgz#1ab05d0bed6bb684353c17c96e1d3e6c1a4e225d" @@ -15202,9 +15228,9 @@ react-native-get-random-values@^1.8.0: fast-base64-decode "^1.0.0" react-native-gradle-plugin@^0.71.17: - version "0.71.17" - resolved "https://registry.yarnpkg.com/react-native-gradle-plugin/-/react-native-gradle-plugin-0.71.17.tgz#cf780a27270f0a32dca8184eff91555d7627dd00" - integrity sha512-OXXYgpISEqERwjSlaCiaQY6cTY5CH6j73gdkWpK0hedxtiWMWgH+i5TOi4hIGYitm9kQBeyDu+wim9fA8ROFJA== + version "0.71.18" + resolved "https://registry.yarnpkg.com/react-native-gradle-plugin/-/react-native-gradle-plugin-0.71.18.tgz#20ef199bc85be32e45bb6cc069ec2e7dcb1a74a6" + integrity sha512-7F6bD7B8Xsn3JllxcwHhFcsl9aHIig47+3eN4IHFNqfLhZr++3ElDrcqfMzugM+niWbaMi7bJ0kAkAL8eCpdWg== react-native-haptic-feedback@^1.14.0: version "1.14.0" @@ -15259,9 +15285,9 @@ react-native-root-siblings@^4.1.1: integrity sha512-sdmLElNs5PDWqmZmj4/aNH4anyxreaPm61c4ZkRiR8SO/GzLg6KjAbb0e17RmMdnBdD0AIQbS38h/l55YKN4ZA== react-native-safe-area-context@^4.4.1: - version "4.5.2" - resolved "https://registry.yarnpkg.com/react-native-safe-area-context/-/react-native-safe-area-context-4.5.2.tgz#38438c7a52ce2a6a05fc4de6cd3ee47f78a9366e" - integrity sha512-oH4/Dm7/PWOOZtFRiA4HE08lsfA948BRq8Fn7TEndYjoDXFoNdbjQRahXzCV8JGP/tv3qrVNeaDE8rmdRRUOlA== + version "4.5.3" + resolved "https://registry.yarnpkg.com/react-native-safe-area-context/-/react-native-safe-area-context-4.5.3.tgz#e98eb1a73a6b3846d296545fe74760754dbaaa69" + integrity sha512-ihYeGDEBSkYH+1aWnadNhVtclhppVgd/c0tm4mj0+HV11FoiWJ8N6ocnnZnRLvM5Fxc+hUqxR9bm5AXU3rXiyA== react-native-screens@^3.13.1: version "3.20.0" @@ -15495,9 +15521,9 @@ readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable util-deprecate "^1.0.1" readable-stream@^4.0.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.3.0.tgz#0914d0c72db03b316c9733bb3461d64a3cc50cba" - integrity sha512-MuEnA0lbSi7JS8XM+WNJlWZkHAAdm7gETHdFK//Q/mChGyj2akEFtdLZh32jSdkWGbRwCW9pn6g3LWDdDeZnBQ== + version "4.4.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.4.0.tgz#55ce132d60a988c460d75c631e9ccf6a7229b468" + integrity sha512-kDMOq0qLtxV9f/SQv522h8cxZBqNZXuXNyjyezmfAAuribMyVXziljpQ/uQhfE1XLg2/TLTW2DsnoE4VAi/krg== dependencies: abort-controller "^3.0.0" buffer "^6.0.3" @@ -15895,9 +15921,9 @@ rollup@^2.43.1: fsevents "~2.3.2" rope-sequence@^1.3.0: - version "1.3.3" - resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.3.tgz#3f67fc106288b84b71532b4a5fd9d4881e4457f0" - integrity sha512-85aZYCxweiD5J8yTEbw+E6A27zSnLPNDL0WfPdw3YYodq7WjnTKo0q4dtyQ2gz23iPT8Q9CUyJtAaUNcTxRf5Q== + version "1.3.4" + resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.4.tgz#df85711aaecd32f1e756f76e43a415171235d425" + integrity sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ== rtl-detect@^1.0.2: version "1.0.4" @@ -16104,9 +16130,9 @@ semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== semver@^7.0.0, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8: - version "7.5.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.0.tgz#ed8c5dc8efb6c629c88b23d41dc9bf40c1d96cd0" - integrity sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA== + version "7.5.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.1.tgz#c90c4d631cf74720e46b21c1d37ea07edfab91ec" + integrity sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw== dependencies: lru-cache "^6.0.0" @@ -16358,9 +16384,9 @@ slash@^4.0.0: integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== slash@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/slash/-/slash-5.0.1.tgz#c354c3a49c0d3b4da1cb0bbeb15a85c2a6defa71" - integrity sha512-ywNzUOiXwetmLvTUiCBZpLi+vxqN3i+zDqjs2HHfUSV3wN4UJxVVKWrS1JZDeiJIeBFNgB5pmioC2g0IUTL+rQ== + version "5.1.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-5.1.0.tgz#be3adddcdf09ac38eebe8dcdc7b1a57a75b095ce" + integrity sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg== slice-ansi@^2.0.0: version "2.1.0" @@ -17034,13 +17060,13 @@ tar-stream@^2.1.4: readable-stream "^3.1.1" tar@^6.0.2, tar@^6.0.5: - version "6.1.13" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.13.tgz#46e22529000f612180601a6fe0680e7da508847b" - integrity sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw== + version "6.1.15" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.15.tgz#c9738b0b98845a3b344d334b8fa3041aaba53a69" + integrity sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A== dependencies: chownr "^2.0.0" fs-minipass "^2.0.0" - minipass "^4.0.0" + minipass "^5.0.0" minizlib "^2.1.1" mkdirp "^1.0.3" yallist "^4.0.0" @@ -17124,20 +17150,20 @@ terminal-link@^2.0.0, terminal-link@^2.1.1: supports-hyperlinks "^2.0.0" terser-webpack-plugin@^5.2.5, terser-webpack-plugin@^5.3.0, terser-webpack-plugin@^5.3.7: - version "5.3.7" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.7.tgz#ef760632d24991760f339fe9290deb936ad1ffc7" - integrity sha512-AfKwIktyP7Cu50xNjXF/6Qb5lBNzYaWpU6YfoX3uZicTx0zTy0stDDCsvjDapKsSDvOeWo5MEq4TmdBy2cNoHw== + version "5.3.8" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.8.tgz#415e03d2508f7de63d59eca85c5d102838f06610" + integrity sha512-WiHL3ElchZMsK27P8uIUh4604IgJyAW47LVXGbEoB21DbQcZ+OuMpGjVYnEUaqcWM6dO8uS2qUbA7LSCWqvsbg== dependencies: "@jridgewell/trace-mapping" "^0.3.17" jest-worker "^27.4.5" schema-utils "^3.1.1" serialize-javascript "^6.0.1" - terser "^5.16.5" + terser "^5.16.8" -terser@^5.0.0, terser@^5.10.0, terser@^5.15.0, terser@^5.16.5: - version "5.17.1" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.17.1.tgz#948f10830454761e2eeedc6debe45c532c83fd69" - integrity sha512-hVl35zClmpisy6oaoKALOpS0rDYLxRFLHhRuDlEGTKey9qHjS1w9GMORjuwIMt70Wan4lwsLYyWDVnWgF+KUEw== +terser@^5.0.0, terser@^5.10.0, terser@^5.15.0, terser@^5.16.8: + version "5.17.4" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.17.4.tgz#b0c2d94897dfeba43213ed5f90ed117270a2c696" + integrity sha512-jcEKZw6UPrgugz/0Tuk/PVyLAPfMBJf5clnGueo45wTweoV8yh7Q7PEkhkJ5uuUbC7zAxEcG3tqNr1bstkQ8nw== dependencies: "@jridgewell/source-map" "^0.3.2" acorn "^8.5.0" @@ -17333,12 +17359,11 @@ tr46@~0.0.3: integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== trace-event-lib@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/trace-event-lib/-/trace-event-lib-1.3.1.tgz#8113146caa30778f45d0ec479d899f9eda94d594" - integrity sha512-RO/TD5E9RNqU6MhOfi/njFWKYhrzOJCpRXlEQHgXwM+6boLSrQnOZ9xbHwOXzC+Luyixc7LNNSiTsqTVeF7I1g== + version "1.4.1" + resolved "https://registry.yarnpkg.com/trace-event-lib/-/trace-event-lib-1.4.1.tgz#a749b8141650f56dcdecea760df4735f28d1ac6b" + integrity sha512-TOgFolKG8JFY+9d5EohGWMvwvteRafcyfPWWNIqcuD1W/FUvxWcy2MSCZ/beYHM63oYPHYHCd3tkbgCctHVP7w== dependencies: browser-process-hrtime "^1.0.0" - lodash "^4.17.21" traverse@~0.6.6: version "0.6.7" @@ -17470,9 +17495,9 @@ type-fest@^2.19.0, type-fest@^2.3.3: integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== type-fest@^3.0.0: - version "3.9.0" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.9.0.tgz#36a9e46e6583649f9e6098b267bc577275e9e4f4" - integrity sha512-hR8JP2e8UiH7SME5JZjsobBlEiatFoxpzCP+R3ZeCo7kAaG1jXQE5X/buLzogM6GJu8le9Y4OcfNuIQX0rZskA== + version "3.10.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.10.0.tgz#d75f17a22be8816aea6315ab2739fe1c0c211863" + integrity sha512-hmAPf1datm+gt3c2mvu0sJyhFy6lTkIGf0GzyaZWxRLnabQfPUqg6tF95RPg6sLxKI7nFLGdFxBcf2/7+GXI+A== type-is@~1.6.18: version "1.6.18" @@ -17842,9 +17867,9 @@ w3c-hr-time@^1.0.2: browser-process-hrtime "^1.0.0" w3c-keyname@^2.2.0: - version "2.2.6" - resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.6.tgz#8412046116bc16c5d73d4e612053ea10a189c85f" - integrity sha512-f+fciywl1SJEniZHD6H+kUO8gOnwIr7f4ijKA6+ZvJFjeGi1r4PDLl53Ayud9O/rk64RqgoQine0feoeOU0kXg== + version "2.2.7" + resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.7.tgz#e29549e9ac97ac5cb2993c8222994e97922ef377" + integrity sha512-XB8aa62d4rrVfoZYQaYNy3fy+z4nrfy2ooea3/0BnBzXW0tSdZ+lRgjzBZhk0La0H6h8fVyYCxx/qkQcAIuvfg== w3c-xmlserializer@^2.0.0: version "2.0.0" @@ -17920,14 +17945,14 @@ webidl-conversions@^7.0.0: integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== webpack-cli@^5.0.1: - version "5.0.2" - resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-5.0.2.tgz#2954c10ecb61c5d4dad6f68ee2d77f051741946c" - integrity sha512-4y3W5Dawri5+8dXm3+diW6Mn1Ya+Dei6eEVAdIduAmYNLzv1koKVAqsfgrrc9P2mhrYHQphx5htnGkcNwtubyQ== + version "5.1.1" + resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-5.1.1.tgz#c211ac6d911e77c512978f7132f0d735d4a97ace" + integrity sha512-OLJwVMoXnXYH2ncNGU8gxVpUtm3ybvdioiTvHgUyBuyMLKiVvWy+QObzBsMtp5pH7qQoEuWgeEUQ/sU3ZJFzAw== dependencies: "@discoveryjs/json-ext" "^0.5.0" - "@webpack-cli/configtest" "^2.0.1" + "@webpack-cli/configtest" "^2.1.0" "@webpack-cli/info" "^2.0.1" - "@webpack-cli/serve" "^2.0.2" + "@webpack-cli/serve" "^2.0.4" colorette "^2.0.14" commander "^10.0.1" cross-spawn "^7.0.3" @@ -17950,9 +17975,9 @@ webpack-dev-middleware@^5.3.1: schema-utils "^4.0.0" webpack-dev-server@^4.11.1, webpack-dev-server@^4.6.0: - version "4.13.3" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-4.13.3.tgz#9feb740b8b56b886260bae1360286818a221bae8" - integrity sha512-KqqzrzMRSRy5ePz10VhjyL27K2dxqwXQLP5rAKwRJBPUahe7Z2bBWzHw37jeb8GCPKxZRO79ZdQUAPesMh/Nug== + version "4.15.0" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-4.15.0.tgz#87ba9006eca53c551607ea0d663f4ae88be7af21" + integrity sha512-HmNB5QeSl1KpulTBQ8UT4FPrByYyaLxpJoQ0+s7EvUrMc16m0ZS1sgb1XGqzmgCPk0c9y+aaXxn11tbLzuM7NQ== dependencies: "@types/bonjour" "^3.5.9" "@types/connect-history-api-fallback" "^1.3.5" @@ -18023,9 +18048,9 @@ webpack-sources@^3.2.3: integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== webpack@^5.64.4, webpack@^5.75.0: - version "5.81.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.81.0.tgz#27a2e8466c8b4820d800a8d90f06ef98294f9956" - integrity sha512-AAjaJ9S4hYCVODKLQTgG5p5e11hiMawBwV2v8MYLE0C/6UAGLuAF4n1qa9GOwdxnicaP+5k6M5HrLmD4+gIB8Q== + version "5.83.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.83.0.tgz#70b4b7c941ded3d2ad16d620c534a53dc27b0080" + integrity sha512-mdWk7amgh7hMCXzU+uTDGpIJEbkqat2RLgSDW53E1OOSE6U0gmBcWadJ6y0FdQQbGbW0lV2LT9t2iOEZc+FU7w== dependencies: "@types/eslint-scope" "^3.7.3" "@types/estree" "^1.0.0" @@ -18036,7 +18061,7 @@ webpack@^5.64.4, webpack@^5.75.0: acorn-import-assertions "^1.7.6" browserslist "^4.14.5" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.13.0" + enhanced-resolve "^5.14.0" es-module-lexer "^1.2.1" eslint-scope "5.1.1" events "^3.2.0"