Add scene creator

zio/stable
Paul Frazee 2022-11-09 15:57:49 -06:00
parent 93b64cf474
commit e7536289cb
18 changed files with 281 additions and 23 deletions

View File

@ -9,14 +9,14 @@ import {createContext, useContext} from 'react'
import {isObj, hasProp} from '../lib/type-guards' import {isObj, hasProp} from '../lib/type-guards'
import {SessionModel} from './session' import {SessionModel} from './session'
import {NavigationModel} from './navigation' import {NavigationModel} from './navigation'
import {ShellModel} from './shell' import {ShellUiModel} from './shell-ui'
import {MeModel} from './me' import {MeModel} from './me'
import {OnboardModel} from './onboard' import {OnboardModel} from './onboard'
export class RootStoreModel { export class RootStoreModel {
session = new SessionModel(this) session = new SessionModel(this)
nav = new NavigationModel() nav = new NavigationModel()
shell = new ShellModel() shell = new ShellUiModel()
me = new MeModel(this) me = new MeModel(this)
onboard = new OnboardModel() onboard = new OnboardModel()

View File

@ -35,14 +35,27 @@ export class EditProfileModel {
} }
} }
export class CreateSceneModel {
name = 'create-scene'
constructor() {
makeAutoObservable(this)
}
}
export interface ComposerOpts { export interface ComposerOpts {
replyTo?: Post.PostRef replyTo?: Post.PostRef
onPost?: () => void onPost?: () => void
} }
export class ShellModel { export class ShellUiModel {
isModalActive = false isModalActive = false
activeModal: LinkActionsModel | SharePostModel | EditProfileModel | undefined activeModal:
| LinkActionsModel
| SharePostModel
| EditProfileModel
| CreateSceneModel
| undefined
isComposerActive = false isComposerActive = false
composerOpts: ComposerOpts | undefined composerOpts: ComposerOpts | undefined
@ -50,7 +63,13 @@ export class ShellModel {
makeAutoObservable(this) makeAutoObservable(this)
} }
openModal(modal: LinkActionsModel | SharePostModel | EditProfileModel) { openModal(
modal:
| LinkActionsModel
| SharePostModel
| EditProfileModel
| CreateSceneModel,
) {
this.isModalActive = true this.isModalActive = true
this.activeModal = modal this.activeModal = modal
} }

View File

@ -8,7 +8,7 @@ import Toast from '../util/Toast'
import ProgressCircle from '../util/ProgressCircle' import ProgressCircle from '../util/ProgressCircle'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import * as apilib from '../../../state/lib/api' import * as apilib from '../../../state/lib/api'
import {ComposerOpts} from '../../../state/models/shell' import {ComposerOpts} from '../../../state/models/shell-ui'
import {s, colors, gradients} from '../../lib/styles' import {s, colors, gradients} from '../../lib/styles'
const MAX_TEXT_LENGTH = 256 const MAX_TEXT_LENGTH = 256

View File

@ -0,0 +1,210 @@
import React, {useState} from 'react'
import Toast from '../util/Toast'
import {
ActivityIndicator,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {ErrorMessage} from '../util/ErrorMessage'
import {useStores} from '../../../state'
import {s, colors, gradients} from '../../lib/styles'
import {makeValidHandle, createFullHandle} from '../../lib/strings'
import {AppBskyActorCreateScene} from '../../../third-party/api/index'
export const snapPoints = ['70%']
export function Component({}: {}) {
const store = useStores()
const [error, setError] = useState<string>('')
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [handle, setHandle] = useState<string>('')
const [displayName, setDisplayName] = useState<string>('')
const [description, setDescription] = useState<string>('')
const onPressSave = async () => {
setIsProcessing(true)
if (error) {
setError('')
}
try {
if (!store.me.did) {
return
}
const desc = await store.api.com.atproto.server.getAccountsConfig()
const fullHandle = createFullHandle(
handle,
desc.data.availableUserDomains[0],
)
// create scene actor
const createSceneRes = await store.api.app.bsky.actor.createScene({
handle: fullHandle,
})
// set the scene profile
// TODO
// follow the scene
await store.api.app.bsky.graph.follow
.create(
{
did: store.me.did,
},
{
subject: {
did: createSceneRes.data.did,
declarationCid: createSceneRes.data.declarationCid,
},
createdAt: new Date().toISOString(),
},
)
.catch(e => console.error(e)) // an error here is not critical
Toast.show('Scene created', {
position: Toast.positions.TOP,
})
store.shell.closeModal()
store.nav.navigate(`/profile/${fullHandle}`)
} catch (e: any) {
if (e instanceof AppBskyActorCreateScene.InvalidHandleError) {
setError(
'The handle can only contain letters, numbers, and dashes, and must start with a letter.',
)
} else if (e instanceof AppBskyActorCreateScene.HandleNotAvailableError) {
setError(`The handle "${handle}" is not available.`)
} else {
console.error(e)
setError(
'Failed to create the scene. Check your internet connection and try again.',
)
}
setIsProcessing(false)
}
}
return (
<View style={s.flex1}>
<Text style={styles.title}>Create a scene</Text>
<Text style={styles.description}>
Scenes are invite-only groups which aggregate what's popular with
members.
</Text>
<View style={styles.inner}>
<View style={styles.group}>
<Text style={styles.label}>Scene Handle</Text>
<TextInput
style={styles.textInput}
placeholder="e.g. alices-friends"
value={handle}
onChangeText={str => setHandle(makeValidHandle(str))}
/>
</View>
<View style={styles.group}>
<Text style={styles.label}>Scene Display Name</Text>
<TextInput
style={styles.textInput}
placeholder="e.g. Alice's Friends"
value={displayName}
onChangeText={setDisplayName}
/>
</View>
<View style={styles.group}>
<Text style={styles.label}>Scene Description</Text>
<TextInput
style={[styles.textArea]}
placeholder="e.g. Artists, dog-lovers, and memelords."
multiline
value={description}
onChangeText={setDescription}
/>
</View>
<View style={styles.errorContainer}>
{error !== '' && (
<View style={s.mb10}>
<ErrorMessage message={error} numberOfLines={3} />
</View>
)}
</View>
{handle.length >= 2 && !isProcessing ? (
<TouchableOpacity style={s.mt10} onPress={onPressSave}>
<LinearGradient
colors={[gradients.primary.start, gradients.primary.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text style={[s.white, s.bold, s.f18]}>Create Scene</Text>
</LinearGradient>
</TouchableOpacity>
) : (
<View style={s.mt10}>
<View style={[styles.btn]}>
{isProcessing ? (
<ActivityIndicator />
) : (
<Text style={[s.gray4, s.bold, s.f18]}>Create Scene</Text>
)}
</View>
</View>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
title: {
textAlign: 'center',
fontWeight: 'bold',
fontSize: 24,
marginBottom: 12,
},
description: {
textAlign: 'center',
fontSize: 17,
paddingHorizontal: 22,
color: colors.gray5,
marginBottom: 10,
},
inner: {
padding: 14,
},
group: {
marginBottom: 10,
},
label: {
fontSize: 16,
fontWeight: 'bold',
paddingHorizontal: 4,
paddingBottom: 4,
},
textInput: {
borderWidth: 1,
borderColor: colors.gray3,
borderRadius: 6,
paddingHorizontal: 14,
paddingVertical: 10,
fontSize: 16,
},
textArea: {
borderWidth: 1,
borderColor: colors.gray3,
borderRadius: 6,
paddingHorizontal: 12,
paddingTop: 10,
fontSize: 16,
height: 100,
textAlignVertical: 'top',
},
errorContainer: {
height: 80,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
borderRadius: 32,
padding: 14,
marginBottom: 10,
backgroundColor: colors.gray1,
},
})

View File

@ -68,7 +68,7 @@ export function Component({profileView}: {profileView: ProfileViewModel}) {
/> />
</View> </View>
<View style={styles.group}> <View style={styles.group}>
<Text style={styles.label}>Biography</Text> <Text style={styles.label}>Description</Text>
<TextInput <TextInput
style={[styles.textArea]} style={[styles.textArea]}
placeholder="e.g. Artist, dog-lover, and memelord." placeholder="e.g. Artist, dog-lover, and memelord."

View File

@ -5,11 +5,12 @@ import BottomSheet from '@gorhom/bottom-sheet'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
import * as models from '../../../state/models/shell' import * as models from '../../../state/models/shell-ui'
import * as LinkActionsModal from './LinkActions' import * as LinkActionsModal from './LinkActions'
import * as SharePostModal from './SharePost.native' import * as SharePostModal from './SharePost.native'
import * as EditProfile from './EditProfile' import * as EditProfile from './EditProfile'
import * as CreateScene from './CreateScene'
const CLOSED_SNAPPOINTS = ['10%'] const CLOSED_SNAPPOINTS = ['10%']
@ -57,6 +58,9 @@ export const Modal = observer(function Modal() {
{...(store.shell.activeModal as models.EditProfileModel)} {...(store.shell.activeModal as models.EditProfileModel)}
/> />
) )
} else if (store.shell.activeModal?.name === 'create-scene') {
snapPoints = CreateScene.snapPoints
element = <CreateScene.Component />
} else { } else {
element = <View /> element = <View />
} }

View File

@ -6,7 +6,7 @@ import {
PostThreadViewPostModel, PostThreadViewPostModel,
} from '../../../state/models/post-thread-view' } from '../../../state/models/post-thread-view'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {SharePostModel} from '../../../state/models/shell' import {SharePostModel} from '../../../state/models/shell-ui'
import {PostThreadItem} from './PostThreadItem' import {PostThreadItem} from './PostThreadItem'
export const PostThread = observer(function PostThread({uri}: {uri: string}) { export const PostThread = observer(function PostThread({uri}: {uri: string}) {

View File

@ -5,7 +5,7 @@ import {AtUri} from '../../../third-party/uri'
import * as PostType from '../../../third-party/api/src/client/types/app/bsky/feed/post' import * as PostType from '../../../third-party/api/src/client/types/app/bsky/feed/post'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FeedItemModel} from '../../../state/models/feed-view' import {FeedItemModel} from '../../../state/models/feed-view'
import {SharePostModel} from '../../../state/models/shell' import {SharePostModel} from '../../../state/models/shell-ui'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {PostDropdownBtn} from '../util/DropdownBtn' import {PostDropdownBtn} from '../util/DropdownBtn'
import {UserInfoText} from '../util/UserInfoText' import {UserInfoText} from '../util/UserInfoText'

View File

@ -11,7 +11,7 @@ import LinearGradient from 'react-native-linear-gradient'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {ProfileViewModel} from '../../../state/models/profile-view' import {ProfileViewModel} from '../../../state/models/profile-view'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {EditProfileModel} from '../../../state/models/shell' import {EditProfileModel} from '../../../state/models/shell-ui'
import {pluralize} from '../../lib/strings' import {pluralize} from '../../lib/strings'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
import {getGradient} from '../../lib/asset-gen' import {getGradient} from '../../lib/asset-gen'

View File

@ -13,7 +13,7 @@ import RootSiblings from 'react-native-root-siblings'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {colors} from '../../lib/styles' import {colors} from '../../lib/styles'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {SharePostModel} from '../../../state/models/shell' import {SharePostModel} from '../../../state/models/shell-ui'
export interface DropdownItem { export interface DropdownItem {
icon?: IconProp icon?: IconProp

View File

@ -5,9 +5,11 @@ import {colors} from '../../lib/styles'
export function ErrorMessage({ export function ErrorMessage({
message, message,
numberOfLines,
onPressTryAgain, onPressTryAgain,
}: { }: {
message: string message: string
numberOfLines?: number
onPressTryAgain?: () => void onPressTryAgain?: () => void
}) { }) {
return ( return (
@ -19,7 +21,9 @@ export function ErrorMessage({
size={16} size={16}
/> />
</View> </View>
<Text style={styles.message}>{message}</Text> <Text style={styles.message} numberOfLines={numberOfLines}>
{message}
</Text>
{onPressTryAgain && ( {onPressTryAgain && (
<TouchableOpacity style={styles.btn} onPress={onPressTryAgain}> <TouchableOpacity style={styles.btn} onPress={onPressTryAgain}>
<FontAwesomeIcon <FontAwesomeIcon

View File

@ -2,7 +2,7 @@ import React from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {StyleProp, Text, TouchableOpacity, ViewStyle} from 'react-native' import {StyleProp, Text, TouchableOpacity, ViewStyle} from 'react-native'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {LinkActionsModel} from '../../../state/models/shell' import {LinkActionsModel} from '../../../state/models/shell-ui'
export const Link = observer(function Link({ export const Link = observer(function Link({
style, style,

View File

@ -71,3 +71,17 @@ export function extractEntities(text: string): Entity[] | undefined {
} }
return ents.length > 0 ? ents : undefined return ents.length > 0 ? ents : undefined
} }
export function makeValidHandle(str: string): string {
if (str.length > 20) {
str = str.slice(0, 20)
}
str = str.toLowerCase()
return str.replace(/^[^a-z]+/g, '').replace(/[^a-z0-9-]/g, '')
}
export function createFullHandle(name: string, domain: string): string {
name = name.replace(/[\.]+$/, '')
domain = domain.replace(/^[\.]+/, '')
return `${name}.${domain}`
}

View File

@ -19,7 +19,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {HomeIcon, UserGroupIcon, BellIcon} from '../../lib/icons' import {HomeIcon, UserGroupIcon, BellIcon} from '../../lib/icons'
import {ComposePost} from '../../com/composer/ComposePost' import {ComposePost} from '../../com/composer/ComposePost'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {ComposerOpts} from '../../../state/models/shell' import {ComposerOpts} from '../../../state/models/shell-ui'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
export const Composer = observer( export const Composer = observer(

View File

@ -20,6 +20,7 @@ import _chunk from 'lodash.chunk'
import {HomeIcon, UserGroupIcon, BellIcon} from '../../lib/icons' import {HomeIcon, UserGroupIcon, BellIcon} from '../../lib/icons'
import {UserAvatar} from '../../com/util/UserAvatar' import {UserAvatar} from '../../com/util/UserAvatar'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {CreateSceneModel} from '../../../state/models/shell-ui'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
export const MainMenu = observer( export const MainMenu = observer(
@ -54,6 +55,10 @@ export const MainMenu = observer(
store.nav.navigate(url) store.nav.navigate(url)
onClose() onClose()
} }
const onPressCreateScene = () => {
store.shell.openModal(new CreateSceneModel())
onClose()
}
// rendering // rendering
// = // =
@ -65,17 +70,19 @@ export const MainMenu = observer(
const MenuItem = ({ const MenuItem = ({
icon, icon,
label, label,
url,
count, count,
url,
onPress,
}: { }: {
icon: IconProp icon: IconProp
label: string label: string
url: string
count?: number count?: number
url?: string
onPress?: () => void
}) => ( }) => (
<TouchableOpacity <TouchableOpacity
style={[styles.menuItem, styles.menuItemMargin]} style={[styles.menuItem, styles.menuItemMargin]}
onPress={() => onNavigate(url)}> onPress={onPress ? onPress : () => onNavigate(url || '/')}>
<View style={[styles.menuItemIconWrapper]}> <View style={[styles.menuItemIconWrapper]}>
{icon === 'home' ? ( {icon === 'home' ? (
<HomeIcon style={styles.menuItemIcon} size="32" /> <HomeIcon style={styles.menuItemIcon} size="32" />
@ -209,7 +216,7 @@ export const MainMenu = observer(
<MenuItem <MenuItem
icon={'user-group'} icon={'user-group'}
label="Create Scene" label="Create Scene"
url="/contacts" onPress={onPressCreateScene}
/> />
{store.me.memberships ? ( {store.me.memberships ? (
store.me.memberships.memberships.map((membership, i) => ( store.me.memberships.memberships.map((membership, i) => (

View File

@ -19,7 +19,7 @@ import Swipeable from 'react-native-gesture-handler/Swipeable'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
import {match} from '../../routes' import {match} from '../../routes'
import {LinkActionsModel} from '../../../state/models/shell' import {LinkActionsModel} from '../../../state/models/shell-ui'
const TAB_HEIGHT = 42 const TAB_HEIGHT = 42

View File

@ -230,11 +230,11 @@ export const MobileShell: React.FC = observer(() => {
/> />
<Btn icon={['far', 'clone']} onPress={onPressTabs} /> <Btn icon={['far', 'clone']} onPress={onPressTabs} />
</View> </View>
<Modal />
<MainMenu <MainMenu
active={isMainMenuActive} active={isMainMenuActive}
onClose={() => setMainMenuActive(false)} onClose={() => setMainMenuActive(false)}
/> />
<Modal />
<TabsSelector <TabsSelector
active={isTabsSelectorActive} active={isTabsSelectorActive}
onClose={() => setTabsSelectorActive(false)} onClose={() => setTabsSelectorActive(false)}

View File

@ -9,8 +9,8 @@ Paul's todo list
- * - *
- Avatars - Avatars
- SVG generate - SVG generate
- Create scene view - Create scene
- * - Set profile during creation
- Discover scenes view - Discover scenes view
- * - *
- User profile - User profile