diff --git a/src/lib/api/feed/demo.ts b/src/lib/api/feed/demo.ts new file mode 100644 index 0000000000..049e0f116e --- /dev/null +++ b/src/lib/api/feed/demo.ts @@ -0,0 +1,20 @@ +import {type AppBskyFeedDefs, type BskyAgent} from '@atproto/api' + +import {DEMO_FEED} from '#/lib/demo' +import {type FeedAPI, type FeedAPIResponse} from './types' + +export class DemoFeedAPI implements FeedAPI { + agent: BskyAgent + + constructor({agent}: {agent: BskyAgent}) { + this.agent = agent + } + + async peekLatest(): Promise { + return DEMO_FEED.feed[0] + } + + async fetch(): Promise { + return DEMO_FEED + } +} diff --git a/src/lib/demo.ts b/src/lib/demo.ts new file mode 100644 index 0000000000..5ead62c9d3 --- /dev/null +++ b/src/lib/demo.ts @@ -0,0 +1,202 @@ +import {type AppBskyFeedGetFeed} from '@atproto/api' +import {subDays, subMinutes} from 'date-fns' + +const DID = `did:plc:z72i7hdynmk6r22z27h6tvur` +const NOW = new Date() +const POST_1_DATE = subMinutes(NOW, 2).toISOString() +const POST_2_DATE = subMinutes(NOW, 4).toISOString() +const POST_3_DATE = subMinutes(NOW, 5).toISOString() + +export const DEMO_FEED = { + feed: [ + { + post: { + uri: 'at://did:plc:pvooorihapc2lf2pijehgrdf/app.bsky.feed.post/3lniysofyll2d', + cid: 'bafyreihwh3wxxme732ylbylhhdyz7ex6t4jtu6s3gjxxvnnh4feddhg3ku', + author: { + did: 'did:plc:pvooorihapc2lf2pijehgrdf', + handle: 'forkedriverband.bsky.social', + displayName: 'Forked River Band', + avatar: 'https://bsky.social/about/adi/post_1_avi.jpg', + viewer: { + muted: false, + blockedBy: false, + following: `at://${DID}/app.bsky.graph.follow/post1`, + }, + labels: [], + createdAt: POST_1_DATE, + verification: { + verifications: [ + { + issuer: DID, + uri: `at://${DID}/app.bsky.graph.verification/post1`, + isValid: true, + createdAt: subDays(NOW, 11).toISOString(), + }, + ], + verifiedStatus: 'valid', + trustedVerifierStatus: 'none', + }, + }, + record: { + $type: 'app.bsky.feed.post', + createdAt: POST_1_DATE, + // embed: { + // $type: 'app.bsky.embed.images', + // images: [ + // { + // alt: 'Fake flier for Sebastapol Bluegrass Fest', + // aspectRatio: { + // height: 1350, + // width: 900, + // }, + // image: { + // $type: 'blob', + // ref: { + // $link: + // 'bafkreig7gnirmz5guhhjutf3mqbjjzxzi3w4wvs5qy2gnxma5g3brbaidi', + // }, + // mimeType: 'image/jpeg', + // size: 562871, + // }, + // }, + // ], + // }, + langs: ['en'], + text: 'Sonoma County folks: Come tip your hats our way and see us play new and old bluegrass tunes at Sebastopol Solstice Fest on June 20th.', + }, + embed: { + $type: 'app.bsky.embed.images#view', + images: [ + { + thumb: 'https://bsky.social/about/adi/post_1_image.jpg', + fullsize: 'https://bsky.social/about/adi/post_1_image.jpg', + alt: 'Fake flier for Sebastapol Bluegrass Fest', + aspectRatio: { + height: 1350, + width: 900, + }, + }, + ], + }, + replyCount: 1, + repostCount: 4, + likeCount: 18, + quoteCount: 0, + indexedAt: POST_1_DATE, + viewer: { + threadMuted: false, + embeddingDisabled: false, + }, + labels: [], + }, + }, + { + post: { + uri: 'at://did:plc:fhhqii56ppgyh5qcm2b3mokf/app.bsky.feed.post/3lnizc7fug52c', + cid: 'bafyreienuabsr55rycirdf4ewue5tjcseg5lzqompcsh2brqzag6hvxllm', + author: { + did: 'did:plc:fhhqii56ppgyh5qcm2b3mokf', + handle: 'dinh-designs.bsky.social', + displayName: 'Rich Dinh Designs', + avatar: 'https://bsky.social/about/adi/post_2_avi.jpg', + viewer: { + muted: false, + blockedBy: false, + following: `at://${DID}/app.bsky.graph.follow/post2`, + }, + labels: [], + createdAt: POST_2_DATE, + }, + record: { + $type: 'app.bsky.feed.post', + createdAt: POST_2_DATE, + // embed: { + // $type: 'app.bsky.embed.images', + // images: [ + // { + // alt: 'Placeholder image of interior design', + // aspectRatio: { + // height: 872, + // width: 598, + // }, + // image: { + // $type: 'blob', + // ref: { + // $link: + // 'bafkreidcjc6bjb4jjjejruin5cldhj5zovsuu4tydulenyprneziq5rfeu', + // }, + // mimeType: 'image/jpeg', + // size: 296003, + // }, + // }, + // ], + // }, + langs: ['en'], + text: 'Details from our install at the Lucas residence in Joshua Tree. We populated the space with rich, earthy tones and locally-sourced materials to suit the landscape.', + }, + embed: { + $type: 'app.bsky.embed.images#view', + images: [ + { + thumb: 'https://bsky.social/about/adi/post_2_image.jpg', + fullsize: 'https://bsky.social/about/adi/post_2_image.jpg', + alt: 'Placeholder image of interior design', + aspectRatio: { + height: 872, + width: 598, + }, + }, + ], + }, + replyCount: 3, + repostCount: 1, + likeCount: 4, + quoteCount: 0, + indexedAt: POST_2_DATE, + viewer: { + threadMuted: false, + embeddingDisabled: false, + }, + labels: [], + }, + }, + { + post: { + uri: 'at://did:plc:h7fwnfejmmifveeea5eyxgkc/app.bsky.feed.post/3lnizna3g4f2t', + cid: 'bafyreiepn7obmlshliori4j34texpaukrqkyyu7cq6nmpzk4lkis7nqeae', + author: { + did: 'did:plc:h7fwnfejmmifveeea5eyxgkc', + handle: 'rodyalbuerne.bsky.social', + displayName: 'Rody Albuerne', + avatar: 'https://bsky.social/about/adi/post_3_avi.jpg', + viewer: { + muted: false, + blockedBy: false, + following: `at://${DID}/app.bsky.graph.follow/post3`, + }, + labels: [], + createdAt: POST_3_DATE, + }, + record: { + $type: 'app.bsky.feed.post', + createdAt: POST_3_DATE, + langs: ['en'], + text: 'Tinkering with the basics of traditional wooden joinery in my shop lately. Starting small with this ox, made using simple mortise and tenon joints.', + }, + replyCount: 11, + repostCount: 97, + likeCount: 399, + quoteCount: 0, + indexedAt: POST_3_DATE, + viewer: { + threadMuted: false, + embeddingDisabled: false, + }, + labels: [], + }, + }, + ], +} satisfies AppBskyFeedGetFeed.OutputSchema + +export const BOTTOM_BAR_AVI = 'https://bsky.social/about/adi/user_avi.jpg' diff --git a/src/screens/Settings/AboutSettings.tsx b/src/screens/Settings/AboutSettings.tsx index 199d12e63d..0ce127ff3f 100644 --- a/src/screens/Settings/AboutSettings.tsx +++ b/src/screens/Settings/AboutSettings.tsx @@ -12,9 +12,10 @@ import {Statsig} from 'statsig-react-native-expo' import {appVersion, BUNDLE_DATE, bundleInfo} from '#/lib/app-info' import {STATUS_PAGE_URL} from '#/lib/constants' import {type CommonNavigatorParams} from '#/lib/routes/types' -import {isAndroid, isNative} from '#/platform/detection' +import {isAndroid, isIOS, isNative} from '#/platform/detection' import * as Toast from '#/view/com/util/Toast' import * as SettingsList from '#/screens/Settings/components/SettingsList' +import {Atom_Stroke2_Corner0_Rounded as AtomIcon} from '#/components/icons/Atom' import {BroomSparkle_Stroke2_Corner2_Rounded as BroomSparkleIcon} from '#/components/icons/BroomSparkle' import {CodeLines_Stroke2_Corner2_Rounded as CodeLinesIcon} from '#/components/icons/CodeLines' import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' @@ -22,6 +23,7 @@ import {Newspaper_Stroke2_Corner2_Rounded as NewspaperIcon} from '#/components/i import {Wrench_Stroke2_Corner2_Rounded as WrenchIcon} from '#/components/icons/Wrench' import * as Layout from '#/components/Layout' import {Loader} from '#/components/Loader' +import {useDemoMode} from '#/storage/hooks/demo-mode' import {useDevMode} from '#/storage/hooks/dev-mode' import {OTAInfo} from './components/OTAInfo' @@ -29,6 +31,7 @@ type Props = NativeStackScreenProps export function AboutSettingsScreen({}: Props) { const {_, i18n} = useLingui() const [devModeEnabled, setDevModeEnabled] = useDevMode() + const [demoModeEnabled, setDemoModeEnabled] = useDemoMode() const stableID = useMemo(() => Statsig.getStableID(), []) const {mutate: onClearImageCache, isPending: isClearingImageCache} = @@ -153,7 +156,31 @@ export function AboutSettingsScreen({}: Props) { {bundleInfo} - {devModeEnabled && } + {devModeEnabled && ( + <> + + {isIOS && ( + { + const newDemoModeEnabled = !demoModeEnabled + setDemoModeEnabled(newDemoModeEnabled) + Toast.show( + 'Demo mode ' + + (newDemoModeEnabled ? 'enabled' : 'disabled'), + ) + }} + label={ + demoModeEnabled ? 'Disable demo mode' : 'Enable demo mode' + } + disabled={isClearingImageCache}> + + + {demoModeEnabled ? 'Disable demo mode' : 'Enable demo mode'} + + + )} + + )} diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 82a118ec2c..f3fa13cfba 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -1,31 +1,32 @@ import React, {useCallback, useEffect, useRef} from 'react' import {AppState} from 'react-native' import { - AppBskyActorDefs, + type AppBskyActorDefs, AppBskyFeedDefs, - AppBskyFeedPost, + type AppBskyFeedPost, AtUri, - BskyAgent, + type BskyAgent, moderatePost, - ModerationDecision, + type ModerationDecision, } from '@atproto/api' import { - InfiniteData, - QueryClient, - QueryKey, + type InfiniteData, + type QueryClient, + type QueryKey, useInfiniteQuery, } from '@tanstack/react-query' import {AuthorFeedAPI} from '#/lib/api/feed/author' import {CustomFeedAPI} from '#/lib/api/feed/custom' +import {DemoFeedAPI} from '#/lib/api/feed/demo' import {FollowingFeedAPI} from '#/lib/api/feed/following' import {HomeFeedAPI} from '#/lib/api/feed/home' import {LikesFeedAPI} from '#/lib/api/feed/likes' import {ListFeedAPI} from '#/lib/api/feed/list' import {MergeFeedAPI} from '#/lib/api/feed/merge' -import {FeedAPI, ReasonFeedSource} from '#/lib/api/feed/types' +import {type FeedAPI, type ReasonFeedSource} from '#/lib/api/feed/types' import {aggregateUserInterests} from '#/lib/api/feed/utils' -import {FeedTuner, FeedTunerFn} from '#/lib/api/feed-manip' +import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip' import {DISCOVER_FEED_URI} from '#/lib/constants' import {BSKY_FEED_OWNER_DIDS} from '#/lib/constants' import {logger} from '#/logger' @@ -59,6 +60,7 @@ export type FeedDescriptor = | `feedgen|${FeedUri}` | `likes|${ActorDid}` | `list|${ListUri}` + | 'demo' export interface FeedParams { mergeFeedEnabled?: boolean mergeFeedSources?: string[] @@ -483,6 +485,8 @@ function createApi({ } else if (feedDesc.startsWith('list')) { const [_, list] = feedDesc.split('|') return new ListFeedAPI({agent, feedParams: {list}}) + } else if (feedDesc === 'demo') { + return new DemoFeedAPI({agent}) } else { // shouldnt happen return new FollowingFeedAPI({agent}) diff --git a/src/storage/hooks/demo-mode.ts b/src/storage/hooks/demo-mode.ts new file mode 100644 index 0000000000..b65dd147e2 --- /dev/null +++ b/src/storage/hooks/demo-mode.ts @@ -0,0 +1,7 @@ +import {device, useStorage} from '#/storage' + +export function useDemoMode() { + const [demoMode = false, setDemoMode] = useStorage(device, ['demoMode']) + + return [demoMode, setDemoMode] as const +} diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 0e9b1985cb..7430532a96 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -10,6 +10,7 @@ export type Device = { } trendingBetaEnabled: boolean devMode: boolean + demoMode: boolean } export type Account = { diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 6f5f9d3ab9..431baa2b22 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -1,12 +1,16 @@ import React from 'react' import { InteractionManager, - StyleProp, + type StyleProp, StyleSheet, View, - ViewStyle, + type ViewStyle, } from 'react-native' -import {MeasuredDimensions, runOnJS, runOnUI} from 'react-native-reanimated' +import { + type MeasuredDimensions, + runOnJS, + runOnUI, +} from 'react-native-reanimated' import {Image} from 'expo-image' import { AppBskyEmbedExternal, @@ -18,10 +22,10 @@ import { AppBskyGraphDefs, moderateFeedGenerator, moderateUserList, - ModerationDecision, + type ModerationDecision, } from '@atproto/api' -import {HandleRef, measureHandle} from '#/lib/hooks/useHandleRef' +import {type HandleRef, measureHandle} from '#/lib/hooks/useHandleRef' import {usePalette} from '#/lib/hooks/usePalette' import {useLightboxControls} from '#/state/lightbox' import {useModerationOpts} from '#/state/preferences/moderation-opts' @@ -30,7 +34,7 @@ import {atoms as a, useTheme} from '#/alf' import * as ListCard from '#/components/ListCard' import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard' import {ContentHider} from '../../../../components/moderation/ContentHider' -import {Dimensions} from '../../lightbox/ImageViewing/@types' +import {type Dimensions} from '../../lightbox/ImageViewing/@types' import {AutoSizedImage} from '../images/AutoSizedImage' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' import {ExternalLinkEmbed} from './ExternalLinkEmbed' diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index a6e2595eec..e058e28831 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -8,28 +8,36 @@ import {useOTAUpdates} from '#/lib/hooks/useOTAUpdates' import {useSetTitle} from '#/lib/hooks/useSetTitle' import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import { - HomeTabNavigatorParams, - NativeStackScreenProps, + type HomeTabNavigatorParams, + type NativeStackScreenProps, } from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' import {isWeb} from '#/platform/detection' import {emitSoftReset} from '#/state/events' -import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed' -import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' +import { + type SavedFeedSourceInfo, + usePinnedFeedsInfos, +} from '#/state/queries/feed' +import {type FeedDescriptor, type FeedParams} from '#/state/queries/post-feed' import {usePreferencesQuery} from '#/state/queries/preferences' -import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' +import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {useSession} from '#/state/session' import {useSetMinimalShellMode} from '#/state/shell' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' import {FeedPage} from '#/view/com/feeds/FeedPage' import {HomeHeader} from '#/view/com/home/HomeHeader' -import {Pager, PagerRef, RenderTabBarFnProps} from '#/view/com/pager/Pager' +import { + Pager, + type PagerRef, + type RenderTabBarFnProps, +} from '#/view/com/pager/Pager' import {CustomFeedEmptyState} from '#/view/com/posts/CustomFeedEmptyState' import {FollowingEmptyState} from '#/view/com/posts/FollowingEmptyState' import {FollowingEndOfFeed} from '#/view/com/posts/FollowingEndOfFeed' import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned' import * as Layout from '#/components/Layout' +import {useDemoMode} from '#/storage/hooks/demo-mode' type Props = NativeStackScreenProps export function HomeScreen(props: Props) { @@ -184,8 +192,22 @@ function HomeScreenReady({ [setMinimalShellMode], ) + const [demoMode] = useDemoMode() + const renderTabBar = React.useCallback( (props: RenderTabBarFnProps) => { + if (demoMode) { + return ( + + ) + } return ( ) }, - [onPressSelected, pinnedFeedInfos], + [onPressSelected, pinnedFeedInfos, demoMode], ) const renderFollowingEmptyState = React.useCallback(() => { @@ -218,6 +240,35 @@ function HomeScreenReady({ } }, [preferences]) + if (demoMode) { + return ( + + + + + ) + } + return hasSession ? ( @@ -259,7 +263,7 @@ export function BottomBar({navigation}: BottomTabBarProps) { {borderColor: pal.text.color}, ]}>