Remove scenes (#36)

* Remove scenes from the main menu

* Remove scenes from the profile view

* Remove 'scenes explainer' from onboarding flow

* Remove scene-related modals

* Remove member/membership code

* Remove all scenes-related items from notifications

* Remove scene-related code from posts feed

* Remove scene-related API helpers

* Update tests
This commit is contained in:
Paul Frazee 2023-01-17 10:11:30 -06:00 committed by GitHub
parent 5abcc8e336
commit bf1092ad86
29 changed files with 18 additions and 1714 deletions

View file

@ -1,243 +0,0 @@
import React, {useState} from 'react'
import * as Toast from '../util/Toast'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {BottomSheetScrollView, BottomSheetTextInput} from '@gorhom/bottom-sheet'
import {AppBskyActorCreateScene} from '@atproto/api'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Text} from '../util/text/Text'
import {useStores} from '../../../state'
import {s, colors, gradients} from '../../lib/styles'
import {
makeValidHandle,
createFullHandle,
enforceLen,
MAX_DISPLAY_NAME,
MAX_DESCRIPTION,
} from '../../../lib/strings'
export const snapPoints = ['60%']
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
await store.api.app.bsky.actor
.updateProfile({
did: createSceneRes.data.did,
displayName,
description,
})
.catch(e =>
// an error here is not critical
store.log.error('Failed to update scene profile during creation', e),
)
// follow the scene
await store.api.app.bsky.graph.follow
.create(
{
did: store.me.did,
},
{
subject: {
did: createSceneRes.data.did,
declarationCid: createSceneRes.data.declaration.cid,
},
createdAt: new Date().toISOString(),
},
)
.catch(e =>
// an error here is not critical
store.log.error('Failed to follow scene after creation', e),
)
Toast.show('Scene created')
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 {
store.log.error('Failed to create scene', e)
setError(
'Failed to create the scene. Check your internet connection and try again.',
)
}
setIsProcessing(false)
}
}
const onPressCancel = () => {
store.shell.closeModal()
}
return (
<View style={styles.outer}>
<BottomSheetScrollView style={styles.inner}>
<Text style={[styles.title, s.black]}>Create a scene</Text>
<Text style={styles.description}>
Scenes are invite-only groups which aggregate what's popular with
members.
</Text>
<View style={{paddingBottom: 50}}>
<View style={styles.group}>
<Text style={[styles.label, s.black]}>Scene Handle</Text>
<BottomSheetTextInput
style={styles.textInput}
placeholder="e.g. alices-friends"
placeholderTextColor={colors.gray4}
autoCorrect={false}
value={handle}
onChangeText={str => setHandle(makeValidHandle(str))}
/>
</View>
<View style={styles.group}>
<Text style={[styles.label, s.black]}>Scene Display Name</Text>
<BottomSheetTextInput
style={styles.textInput}
placeholder="e.g. Alice's Friends"
placeholderTextColor={colors.gray4}
value={displayName}
onChangeText={v =>
setDisplayName(enforceLen(v, MAX_DISPLAY_NAME))
}
/>
</View>
<View style={styles.group}>
<Text style={[styles.label, s.black]}>Scene Description</Text>
<BottomSheetTextInput
style={[styles.textArea]}
placeholder="e.g. Artists, dog-lovers, and memelords."
placeholderTextColor={colors.gray4}
multiline
value={description}
onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
/>
</View>
{error !== '' && (
<View style={s.mb10}>
<ErrorMessage message={error} numberOfLines={3} />
</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>
)}
<TouchableOpacity style={s.mt10} onPress={onPressCancel}>
<View style={[styles.btn, {backgroundColor: colors.white}]}>
<Text style={[s.black, s.bold]}>Cancel</Text>
</View>
</TouchableOpacity>
</View>
</BottomSheetScrollView>
</View>
)
}
const styles = StyleSheet.create({
outer: {
flex: 1,
// paddingTop: 20,
},
title: {
textAlign: 'center',
fontWeight: 'bold',
fontSize: 24,
marginBottom: 12,
},
description: {
textAlign: 'center',
fontSize: 17,
paddingHorizontal: 22,
color: colors.gray5,
marginBottom: 10,
},
inner: {
padding: 14,
height: 350,
},
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,
color: colors.black,
},
textArea: {
borderWidth: 1,
borderColor: colors.gray3,
borderRadius: 6,
paddingHorizontal: 12,
paddingTop: 10,
fontSize: 16,
color: colors.black,
height: 70,
textAlignVertical: 'top',
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
borderRadius: 32,
padding: 14,
backgroundColor: colors.gray1,
},
})

View file

@ -1,308 +0,0 @@
import React, {useState, useEffect, useMemo} from 'react'
import {observer} from 'mobx-react-lite'
import * as Toast from '../util/Toast'
import {
ActivityIndicator,
FlatList,
StyleSheet,
useWindowDimensions,
View,
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {
TabView,
SceneMap,
Route,
TabBar,
TabBarProps,
} from 'react-native-tab-view'
import _omit from 'lodash.omit'
import {AtUri} from '../../../third-party/uri'
import {ProfileCard} from '../profile/ProfileCard'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Text} from '../util/text/Text'
import {useStores} from '../../../state'
import * as apilib from '../../../state/lib/api'
import {ProfileViewModel} from '../../../state/models/profile-view'
import {SuggestedInvitesView} from '../../../state/models/suggested-invites-view'
import {Assertion} from '../../../state/models/get-assertions-view'
import {FollowItem} from '../../../state/models/user-follows-view'
import {s, colors} from '../../lib/styles'
export const snapPoints = ['70%']
export const Component = observer(function Component({
profileView,
}: {
profileView: ProfileViewModel
}) {
const store = useStores()
const layout = useWindowDimensions()
const [index, setIndex] = useState(0)
const tabRoutes = [
{key: 'suggestions', title: 'Suggestions'},
{key: 'pending', title: 'Pending Invites'},
]
const [hasSetup, setHasSetup] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const suggestions = useMemo(
() => new SuggestedInvitesView(store, {sceneDid: profileView.did}),
[profileView.did],
)
const [createdInvites, setCreatedInvites] = useState<Record<string, string>>(
{},
)
// TODO: it would be much better if we just used the suggestions view for the deleted pending invites
// but mobx isnt picking up on the state change in suggestions.unconfirmed and I dont have
// time to debug that right now -prf
const [deletedPendingInvites, setDeletedPendingInvites] = useState<
Record<string, boolean>
>({})
useEffect(() => {
let aborted = false
if (hasSetup) {
return
}
suggestions.setup().then(() => {
if (aborted) return
setHasSetup(true)
})
return () => {
aborted = true
}
}, [profileView.did])
const onPressInvite = async (follow: FollowItem) => {
setError('')
try {
const assertionUri = await apilib.inviteToScene(
store,
profileView.did,
follow.did,
follow.declaration.cid,
)
setCreatedInvites({[follow.did]: assertionUri, ...createdInvites})
Toast.show('Invite sent')
} catch (e: any) {
setError('There was an issue with the invite. Please try again.')
store.log.error('Failed to invite user to scene', e)
}
}
const onPressUndo = async (subjectDid: string, assertionUri: string) => {
setError('')
const urip = new AtUri(assertionUri)
try {
await store.api.app.bsky.graph.assertion.delete({
did: profileView.did,
rkey: urip.rkey,
})
setCreatedInvites(_omit(createdInvites, [subjectDid]))
} catch (e: any) {
setError('There was an issue with the invite. Please try again.')
store.log.error('Failed to delete a scene invite', e)
}
}
const onPressDeleteInvite = async (assertion: Assertion) => {
setError('')
const urip = new AtUri(assertion.uri)
try {
await store.api.app.bsky.graph.assertion.delete({
did: profileView.did,
rkey: urip.rkey,
})
setDeletedPendingInvites({
[assertion.uri]: true,
...deletedPendingInvites,
})
Toast.show('Invite removed')
} catch (e: any) {
setError('There was an issue with the invite. Please try again.')
store.log.error('Failed to delete an invite', e)
}
}
const renderSuggestionItem = ({item}: {item: FollowItem}) => {
const createdInvite = createdInvites[item.did]
return (
<ProfileCard
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
renderButton={() =>
!createdInvite ? (
<>
<FontAwesomeIcon icon="user-plus" style={[s.mr5]} size={14} />
<Text style={[s.fw400, s.f14]}>Invite</Text>
</>
) : (
<>
<FontAwesomeIcon icon="x" style={[s.mr5]} size={14} />
<Text style={[s.fw400, s.f14]}>Undo invite</Text>
</>
)
}
onPressButton={() =>
!createdInvite
? onPressInvite(item)
: onPressUndo(item.did, createdInvite)
}
/>
)
}
const renderPendingInviteItem = ({item}: {item: Assertion}) => {
const wasDeleted = deletedPendingInvites[item.uri]
if (wasDeleted) {
return <View />
}
return (
<ProfileCard
did={item.subject.did}
handle={item.subject.handle}
displayName={item.subject.displayName}
avatar={item.subject.avatar}
renderButton={() => (
<>
<FontAwesomeIcon icon="x" style={[s.mr5]} size={14} />
<Text style={[s.fw400, s.f14]}>Undo invite</Text>
</>
)}
onPressButton={() => onPressDeleteInvite(item)}
/>
)
}
const Suggestions = () => (
<View style={s.flex1}>
{hasSetup ? (
<View style={s.flex1}>
<View style={styles.todoContainer}>
<Text style={styles.todoLabel}>
User search is still being implemented. For now, you can pick from
your follows below.
</Text>
</View>
{!suggestions.hasContent ? (
<Text
style={{
textAlign: 'center',
paddingTop: 10,
paddingHorizontal: 40,
fontWeight: 'bold',
color: colors.gray5,
}}>
{suggestions.myFollowsView.follows.length
? 'Sorry! You dont follow anybody for us to suggest.'
: 'Sorry! All of the users you follow are members already.'}
</Text>
) : (
<FlatList
data={suggestions.suggestions}
keyExtractor={item => item._reactKey}
renderItem={renderSuggestionItem}
style={s.flex1}
/>
)}
</View>
) : !error ? (
<ActivityIndicator />
) : undefined}
</View>
)
const PendingInvites = () => (
<View style={s.flex1}>
{suggestions.sceneAssertionsView.isLoading ? (
<ActivityIndicator />
) : undefined}
<View style={s.flex1}>
{!suggestions.unconfirmed.length ? (
<Text
style={{
textAlign: 'center',
paddingTop: 10,
paddingHorizontal: 40,
fontWeight: 'bold',
color: colors.gray5,
}}>
No pending invites.
</Text>
) : (
<FlatList
data={suggestions.unconfirmed}
keyExtractor={item => item._reactKey}
renderItem={renderPendingInviteItem}
style={s.flex1}
/>
)}
</View>
</View>
)
const renderScene = SceneMap({
suggestions: Suggestions,
pending: PendingInvites,
})
const renderTabBar = (props: TabBarProps<Route>) => (
<TabBar
{...props}
style={{backgroundColor: 'white'}}
activeColor="black"
inactiveColor={colors.gray5}
labelStyle={{textTransform: 'none'}}
indicatorStyle={{backgroundColor: colors.purple3}}
/>
)
return (
<View style={s.flex1}>
<Text style={styles.title}>
Invite to {profileView.displayName || profileView.handle}
</Text>
{error !== '' ? (
<View style={s.p10}>
<ErrorMessage message={error} />
</View>
) : undefined}
<TabView
navigationState={{index, routes: tabRoutes}}
renderScene={renderScene}
renderTabBar={renderTabBar}
onIndexChange={setIndex}
initialLayout={{width: layout.width}}
/>
</View>
)
})
const styles = StyleSheet.create({
title: {
textAlign: 'center',
fontWeight: 'bold',
fontSize: 18,
marginBottom: 4,
},
todoContainer: {
backgroundColor: colors.pink1,
margin: 10,
padding: 10,
borderRadius: 6,
},
todoLabel: {
color: colors.pink5,
textAlign: 'center',
},
tabBar: {
flexDirection: 'row',
},
tabItem: {
alignItems: 'center',
padding: 16,
flex: 1,
},
})

View file

@ -9,8 +9,6 @@ import * as models from '../../../state/models/shell-ui'
import * as ConfirmModal from './Confirm'
import * as EditProfileModal from './EditProfile'
import * as CreateSceneModal from './CreateScene'
import * as InviteToSceneModal from './InviteToScene'
import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './ReportPost'
import * as ReportAccountModal from './ReportAccount'
@ -55,16 +53,6 @@ export const Modal = observer(function Modal() {
{...(store.shell.activeModal as models.EditProfileModal)}
/>
)
} else if (store.shell.activeModal?.name === 'create-scene') {
snapPoints = CreateSceneModal.snapPoints
element = <CreateSceneModal.Component />
} else if (store.shell.activeModal?.name === 'invite-to-scene') {
snapPoints = InviteToSceneModal.snapPoints
element = (
<InviteToSceneModal.Component
{...(store.shell.activeModal as models.InviteToSceneModal)}
/>
)
} else if (store.shell.activeModal?.name === 'server-input') {
snapPoints = ServerInputModal.snapPoints
element = (

View file

@ -14,7 +14,6 @@ import {UserAvatar} from '../util/UserAvatar'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Post} from '../post/Post'
import {Link} from '../util/Link'
import {InviteAccepter} from './InviteAccepter'
import {usePalette} from '../../lib/hooks/usePalette'
const MAX_AUTHORS = 8
@ -26,7 +25,7 @@ export const FeedItem = observer(function FeedItem({
}) {
const pal = usePalette('default')
const itemHref = useMemo(() => {
if (item.isUpvote || item.isRepost || item.isTrend) {
if (item.isUpvote || item.isRepost) {
const urip = new AtUri(item.subjectUri)
return `/profile/${urip.host}/post/${urip.rkey}`
} else if (item.isFollow || item.isAssertion) {
@ -82,10 +81,6 @@ export const FeedItem = observer(function FeedItem({
action = 'reposted your post'
icon = 'retweet'
iconStyle = [s.green3]
} else if (item.isTrend) {
action = 'Your post is trending with'
icon = 'arrow-trend-up'
iconStyle = [s.red3]
} else if (item.isReply) {
action = 'replied to your post'
icon = ['far', 'comment']
@ -93,10 +88,6 @@ export const FeedItem = observer(function FeedItem({
action = 'followed you'
icon = 'user-plus'
iconStyle = [s.blue3]
} else if (item.isInvite) {
icon = 'users'
iconStyle = [s.blue3]
action = 'invited you to join their scene'
} else {
return <></>
}
@ -173,9 +164,6 @@ export const FeedItem = observer(function FeedItem({
) : undefined}
</View>
<View style={styles.meta}>
{item.isTrend && (
<Text style={[styles.metaItem, pal.text]}>{action}</Text>
)}
<Link
key={authors[0].href}
style={styles.metaItem}
@ -193,25 +181,17 @@ export const FeedItem = observer(function FeedItem({
</Text>
</>
) : undefined}
{!item.isTrend && (
<Text style={[styles.metaItem, pal.text]}>{action}</Text>
)}
<Text style={[styles.metaItem, pal.textLight]}>
{ago(item.indexedAt)}
</Text>
</View>
{item.isUpvote || item.isRepost || item.isTrend ? (
{item.isUpvote || item.isRepost ? (
<AdditionalPostText additionalPost={item.additionalPost} />
) : (
<></>
)}
</View>
</View>
{item.isInvite && (
<View style={styles.addedContainer}>
<InviteAccepter item={item} />
</View>
)}
</Link>
)
})

View file

@ -1,96 +0,0 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import * as apilib from '../../../state/lib/api'
import {NotificationsViewItemModel} from '../../../state/models/notifications-view'
import {ConfirmModal} from '../../../state/models/shell-ui'
import {useStores} from '../../../state'
import {ProfileCard} from '../profile/ProfileCard'
import * as Toast from '../util/Toast'
import {Text} from '../util/text/Text'
import {s, colors, gradients} from '../../lib/styles'
export function InviteAccepter({item}: {item: NotificationsViewItemModel}) {
const store = useStores()
// Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment
const [confirmationUri, setConfirmationUri] = React.useState<string>('')
const isMember =
confirmationUri !== '' || store.me.memberships?.isMemberOf(item.author.did)
const onPressAccept = async () => {
store.shell.openModal(
new ConfirmModal(
'Join this scene?',
() => (
<View>
<View style={styles.profileCardContainer}>
<ProfileCard
did={item.author.did}
handle={item.author.handle}
displayName={item.author.displayName}
avatar={item.author.avatar}
/>
</View>
</View>
),
onPressConfirmAccept,
),
)
}
const onPressConfirmAccept = async () => {
const uri = await apilib.acceptSceneInvite(store, {
originator: {
did: item.author.did,
declarationCid: item.author.declaration.cid,
},
assertion: {
uri: item.uri,
cid: item.cid,
},
})
store.me.refreshMemberships()
Toast.show('Invite accepted')
setConfirmationUri(uri)
}
return (
<View style={styles.container}>
{!isMember ? (
<TouchableOpacity testID="acceptInviteButton" onPress={onPressAccept}>
<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.f16]}>Accept Invite</Text>
</LinearGradient>
</TouchableOpacity>
) : (
<View testID="inviteAccepted" style={styles.inviteAccepted}>
<FontAwesomeIcon icon="check" size={14} style={s.mr5} />
<Text style={[s.gray5, s.f15]}>Invite accepted</Text>
</View>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
},
btn: {
borderRadius: 32,
paddingHorizontal: 18,
paddingVertical: 8,
backgroundColor: colors.gray1,
},
profileCardContainer: {
borderWidth: 1,
borderColor: colors.gray3,
borderRadius: 6,
},
inviteAccepted: {
flexDirection: 'row',
alignItems: 'center',
},
})

View file

@ -11,10 +11,9 @@ import {
import {TabView, SceneMap, Route, TabBarProps} from 'react-native-tab-view'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text'
import {UserGroupIcon} from '../../lib/icons'
import {useStores} from '../../../state'
import {s} from '../../lib/styles'
import {SCENE_EXPLAINER, TABS_EXPLAINER} from '../../lib/assets'
import {TABS_EXPLAINER} from '../../lib/assets'
import {TABS_ENABLED} from '../../../build-flags'
const Intro = () => (
@ -28,25 +27,7 @@ const Intro = () => (
Welcome to <Text style={[s.bold, s.blue3, {fontSize: 56}]}>Bluesky</Text>
</Text>
<Text style={[styles.explainerDesc, {fontSize: 24}]}>
Let's do a quick tour through the new features.
</Text>
</View>
)
const Scenes = () => (
<View style={styles.explainer}>
<View style={styles.explainerIcon}>
<View style={s.flex1} />
<UserGroupIcon style={s.black} size="48" />
<View style={s.flex1} />
</View>
<Text style={styles.explainerHeading}>Scenes</Text>
<Text style={styles.explainerDesc}>
Scenes are invite-only groups of users. Follow them to see what's trending
with the scene's members.
</Text>
<Text style={styles.explainerDesc}>
<Image source={SCENE_EXPLAINER} style={styles.explainerImg} />
This is an early beta. Your feedback is appreciated!
</Text>
</View>
)
@ -74,7 +55,6 @@ const Tabs = () => (
const SCENE_MAP = {
intro: Intro,
scenes: Scenes,
tabs: Tabs,
}
const renderScene = SceneMap(SCENE_MAP)
@ -85,7 +65,6 @@ export const FeatureExplainer = () => {
const [index, setIndex] = useState(0)
const routes = [
{key: 'intro', title: 'Intro'},
{key: 'scenes', title: 'Scenes'},
TABS_ENABLED ? {key: 'tabs', title: 'Tabs'} : undefined,
].filter(Boolean)

View file

@ -155,23 +155,6 @@ export const FeedItem = observer(function ({
</Text>
</Link>
)}
{item.reasonTrend && (
<Link
style={styles.includeReason}
href={`/profile/${item.reasonTrend.by.handle}`}
title={
item.reasonTrend.by.displayName || item.reasonTrend.by.handle
}>
<FontAwesomeIcon
icon="arrow-trend-up"
style={styles.includeReasonIcon}
/>
<Text type="overline2" style={{color: pal.colors.actionLabel}}>
Trending with{' '}
{item.reasonTrend.by.displayName || item.reasonTrend.by.handle}
</Text>
</Link>
)}
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<Link href={authorHref} title={item.post.author.handle}>

View file

@ -1,15 +1,12 @@
import React, {useMemo} from 'react'
import React from 'react'
import {observer} from 'mobx-react-lite'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AtUri} from '../../../third-party/uri'
import {ProfileViewModel} from '../../../state/models/profile-view'
import {useStores} from '../../../state'
import {
ConfirmModal,
EditProfileModal,
InviteToSceneModal,
ReportAccountModal,
ProfileImageLightbox,
} from '../../../state/models/shell-ui'
@ -23,7 +20,6 @@ import {Text} from '../util/text/Text'
import {RichText} from '../util/text/RichText'
import {UserAvatar} from '../util/UserAvatar'
import {UserBanner} from '../util/UserBanner'
import {UserInfoText} from '../util/UserInfoText'
import {usePalette} from '../../lib/hooks/usePalette'
export const ProfileHeader = observer(function ProfileHeader({
@ -35,10 +31,6 @@ export const ProfileHeader = observer(function ProfileHeader({
}) {
const pal = usePalette('default')
const store = useStores()
const isMember = useMemo(
() => view.isScene && view.myState.member,
[view.myState.member],
)
const onPressAvi = () => {
store.shell.openLightbox(new ProfileImageLightbox(view))
@ -64,31 +56,6 @@ export const ProfileHeader = observer(function ProfileHeader({
const onPressFollows = () => {
store.nav.navigate(`/profile/${view.handle}/follows`)
}
const onPressMembers = () => {
store.nav.navigate(`/profile/${view.handle}/members`)
}
const onPressInviteMembers = () => {
store.shell.openModal(new InviteToSceneModal(view))
}
const onPressLeaveScene = () => {
store.shell.openModal(
new ConfirmModal(
'Leave this scene?',
`You'll be able to come back unless your invite is revoked.`,
onPressConfirmLeaveScene,
),
)
}
const onPressConfirmLeaveScene = async () => {
if (view.myState.member) {
await store.api.app.bsky.graph.confirmation.delete({
did: store.me.did || '',
rkey: new AtUri(view.myState.member).rkey,
})
Toast.show(`Scene left`)
}
onRefreshAll()
}
const onPressMuteAccount = async () => {
try {
await view.muteAccount()
@ -157,7 +124,6 @@ export const ProfileHeader = observer(function ProfileHeader({
// =
const gradient = getGradient(view.handle)
const isMe = store.me.did === view.did
const isCreator = view.isScene && view.creator === store.me.did
let dropdownItems: DropdownItem[] | undefined
if (!isMe) {
dropdownItems = dropdownItems || []
@ -170,21 +136,6 @@ export const ProfileHeader = observer(function ProfileHeader({
onPress: onPressReportAccount,
})
}
if (isCreator || isMember) {
dropdownItems = dropdownItems || []
if (isCreator) {
dropdownItems.push({
label: 'Edit Profile',
onPress: onPressEditProfile,
})
}
if (isMember) {
dropdownItems.push({
label: 'Leave Scene...',
onPress: onPressLeaveScene,
})
}
}
return (
<View style={pal.view}>
<UserBanner handle={view.handle} banner={view.banner} />
@ -247,15 +198,6 @@ export const ProfileHeader = observer(function ProfileHeader({
</Text>
</View>
<View style={styles.handleLine}>
{view.isScene ? (
<View
style={[
styles.typeLabelWrapper,
{backgroundColor: pal.colors.backgroundLight},
]}>
<Text style={[styles.typeLabel, pal.textLight]}>Scene</Text>
</View>
) : undefined}
<Text style={pal.textLight}>@{view.handle}</Text>
</View>
<View style={styles.metricsLine}>
@ -283,19 +225,6 @@ export const ProfileHeader = observer(function ProfileHeader({
</Text>
</TouchableOpacity>
) : undefined}
{view.isScene ? (
<TouchableOpacity
testID="profileHeaderMembersButton"
style={[s.flexRow, s.mr10]}
onPress={onPressMembers}>
<Text type="body2" style={[s.bold, s.mr2, pal.text]}>
{view.membersCount}
</Text>
<Text type="body2" style={[pal.textLight]}>
{pluralize(view.membersCount, 'member')}
</Text>
</TouchableOpacity>
) : undefined}
<View style={[s.flexRow, s.mr10]}>
<Text type="body2" style={[s.bold, s.mr2, pal.text]}>
{view.postsCount}
@ -313,35 +242,6 @@ export const ProfileHeader = observer(function ProfileHeader({
entities={view.descriptionEntities}
/>
) : undefined}
{view.isScene && view.creator ? (
<View style={styles.detailLine}>
<FontAwesomeIcon
icon={['far', 'user']}
style={[pal.textLight, s.mr5]}
/>
<Text type="body2" style={[s.mr2, pal.textLight]}>
Created by
</Text>
<UserInfoText
type="body2"
style={[pal.link]}
did={view.creator}
prefix="@"
asLink
/>
</View>
) : undefined}
{view.isScene && view.myState.member ? (
<View style={styles.detailLine}>
<FontAwesomeIcon
icon={['far', 'circle-check']}
style={[pal.textLight, s.mr5]}
/>
<Text type="body2" style={[s.mr2, pal.textLight]}>
You are a member
</Text>
</View>
) : undefined}
{view.myState.muted ? (
<View style={[styles.detailLine, pal.btn, s.p5]}>
<FontAwesomeIcon
@ -354,28 +254,6 @@ export const ProfileHeader = observer(function ProfileHeader({
</View>
) : undefined}
</View>
{view.isScene && view.creator === store.me.did ? (
<View style={[styles.sceneAdminContainer, pal.border]}>
<TouchableOpacity
testID="profileHeaderInviteMembersButton"
onPress={onPressInviteMembers}>
<LinearGradient
colors={[gradient[1], gradient[0]]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn, styles.gradientBtn, styles.sceneAdminBtn]}>
<FontAwesomeIcon
icon="user-plus"
style={[s.mr5, s.white]}
size={15}
/>
<Text type="button" style={[s.bold, s.white]}>
Invite Members
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
) : undefined}
<TouchableOpacity
testID="profileHeaderAviButton"
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}
@ -444,15 +322,6 @@ const styles = StyleSheet.create({
flexDirection: 'row',
marginBottom: 8,
},
typeLabelWrapper: {
paddingHorizontal: 4,
borderRadius: 4,
marginRight: 5,
},
typeLabel: {
fontSize: 15,
fontWeight: 'bold',
},
metricsLine: {
flexDirection: 'row',
@ -468,14 +337,4 @@ const styles = StyleSheet.create({
alignItems: 'center',
marginBottom: 5,
},
sceneAdminContainer: {
borderTopWidth: 1,
borderBottomWidth: 1,
paddingVertical: 12,
paddingHorizontal: 12,
},
sceneAdminBtn: {
paddingVertical: 8,
},
})

View file

@ -1,80 +0,0 @@
import React, {useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {ActivityIndicator, FlatList, View} from 'react-native'
import {MembersViewModel, MemberItem} from '../../../state/models/members-view'
import {ProfileCard} from './ProfileCard'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {useStores} from '../../../state'
export const ProfileMembers = observer(function ProfileMembers({
name,
}: {
name: string
}) {
const store = useStores()
// Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment
const [view, setView] = React.useState<MembersViewModel | undefined>()
useEffect(() => {
if (view?.params.actor === name) {
return // no change needed? or trigger refresh?
}
const newView = new MembersViewModel(store, {actor: name})
setView(newView)
newView
.setup()
.catch(err => store.log.error('Failed to fetch members', err))
}, [name, view?.params.actor, store])
const onRefresh = () => {
view?.refresh()
}
// loading
// =
if (
!view ||
(view.isLoading && !view.isRefreshing) ||
view.params.actor !== name
) {
return (
<View testID="profileMembersActivityIndicatorView">
<ActivityIndicator />
</View>
)
}
// error
// =
if (view.hasError) {
return (
<View>
<ErrorMessage
message={view.error}
style={{margin: 6}}
onPressTryAgain={onRefresh}
/>
</View>
)
}
// loaded
// =
const renderItem = ({item}: {item: MemberItem}) => (
<ProfileCard
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
/>
)
return (
<View testID="profileMembersFlatList">
<FlatList
data={view.members}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
/>
</View>
)
})

View file

@ -12,7 +12,6 @@ import {faArrowRightFromBracket} from '@fortawesome/free-solid-svg-icons'
import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket'
import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare'
import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate'
import {faArrowTrendUp} from '@fortawesome/free-solid-svg-icons/faArrowTrendUp'
import {faAt} from '@fortawesome/free-solid-svg-icons/faAt'
import {faBars} from '@fortawesome/free-solid-svg-icons/faBars'
import {faBell} from '@fortawesome/free-solid-svg-icons/faBell'
@ -81,7 +80,6 @@ export function setup() {
faArrowUpFromBracket,
faArrowUpRightFromSquare,
faArrowsRotate,
faArrowTrendUp,
faAt,
faBars,
faBell,

View file

@ -1,5 +1,4 @@
import {ImageSourcePropType} from 'react-native'
export const DEF_AVATAR: ImageSourcePropType = require('../../../public/img/default-avatar.jpg')
export const SCENE_EXPLAINER: ImageSourcePropType = require('../../../public/img/scene-explainer.jpg')
export const TABS_EXPLAINER: ImageSourcePropType = require('../../../public/img/tabs-explainer.jpg')

View file

@ -1,9 +1,6 @@
import {ImageSourcePropType} from 'react-native'
export const DEF_AVATAR: ImageSourcePropType = {uri: '/img/default-avatar.jpg'}
export const SCENE_EXPLAINER: ImageSourcePropType = {
uri: '/img/scene-explainer.jpg',
}
export const TABS_EXPLAINER: ImageSourcePropType = {
uri: '/img/tabs-explainer.jpg',
}

View file

@ -13,7 +13,6 @@ import {PostRepostedBy} from './screens/PostRepostedBy'
import {Profile} from './screens/Profile'
import {ProfileFollowers} from './screens/ProfileFollowers'
import {ProfileFollows} from './screens/ProfileFollows'
import {ProfileMembers} from './screens/ProfileMembers'
import {Settings} from './screens/Settings'
import {Debug} from './screens/Debug'
import {Log} from './screens/Log'
@ -48,7 +47,6 @@ export const routes: Route[] = [
r('/profile/(?<name>[^/]+)/followers'),
],
[ProfileFollows, 'Follows', 'users', r('/profile/(?<name>[^/]+)/follows')],
[ProfileMembers, 'Members', 'users', r('/profile/(?<name>[^/]+)/members')],
[
PostThread,
'Post',

View file

@ -15,7 +15,6 @@ export const Notifications = ({navIdx, visible}: ScreenParams) => {
return
}
store.log.debug('Updating notifications feed')
store.me.refreshMemberships() // needed for the invite notifications
store.me.notifications
.update()
.catch(e => {

View file

@ -1,16 +1,13 @@
import React, {useEffect, useState} from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {ViewSelector} from '../com/util/ViewSelector'
import {ScreenParams} from '../routes'
import {ProfileUiModel, Sections} from '../../state/models/profile-ui'
import {MembershipItem} from '../../state/models/memberships-view'
import {useStores} from '../../state'
import {ConfirmModal} from '../../state/models/shell-ui'
import {ProfileHeader} from '../com/profile/ProfileHeader'
import {FeedItem} from '../com/posts/FeedItem'
import {ProfileCard} from '../com/profile/ProfileCard'
import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder'
import {ErrorScreen} from '../com/util/error/ErrorScreen'
import {ErrorMessage} from '../com/util/error/ErrorMessage'
@ -77,18 +74,6 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
const onPressTryAgain = () => {
uiState.setup()
}
const onPressRemoveMember = (membership: MembershipItem) => {
store.shell.openModal(
new ConfirmModal(
`Remove ${membership.displayName || membership.handle}?`,
`You'll be able to invite them again if you change your mind.`,
async () => {
await uiState.members.removeMember(membership.did)
Toast.show(`User removed`)
},
),
)
}
const onPressCompose = () => {
store.shell.openComposer({})
@ -97,9 +82,6 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
// rendering
// =
const isSceneCreator =
uiState.isScene && store.me.did === uiState.profile.creator
const renderHeader = () => {
if (!uiState) {
return <View />
@ -131,8 +113,7 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
} else {
if (
uiState.selectedView === Sections.Posts ||
uiState.selectedView === Sections.PostsWithReplies ||
uiState.selectedView === Sections.Trending
uiState.selectedView === Sections.PostsWithReplies
) {
if (uiState.feed.hasContent) {
if (uiState.selectedView === Sections.Posts) {
@ -152,81 +133,12 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
return <FeedItem item={item} ignoreMuteFor={uiState.profile.did} />
}
} else if (uiState.feed.isEmpty) {
items = items.concat([EMPTY_ITEM])
if (uiState.profile.isScene) {
renderItem = () => (
<EmptyState
icon="user-group"
message="As members upvote posts, they will trend here. Follow the scene to see its trending posts in your timeline."
/>
)
} else {
renderItem = () => (
<EmptyState
icon={['far', 'message']}
message="No posts yet!"
style={{paddingVertical: 40}}
/>
)
}
}
} else if (uiState.selectedView === Sections.Scenes) {
if (uiState.memberships.hasContent) {
items = uiState.memberships.memberships.slice()
renderItem = (item: any) => {
return (
<ProfileCard
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
/>
)
}
} else if (uiState.memberships.isEmpty) {
items = items.concat([EMPTY_ITEM])
renderItem = () => (
<EmptyState
icon="user-group"
message="This user hasn't joined any scenes."
/>
)
}
} else if (uiState.selectedView === Sections.Members) {
if (uiState.members.hasContent) {
items = uiState.members.members.slice()
renderItem = (item: any) => {
const shouldAdmin = isSceneCreator && item.did !== store.me.did
const renderButton = shouldAdmin
? () => (
<>
<FontAwesomeIcon
testID="shouldAdminButton"
icon="user-xmark"
style={[s.mr5]}
size={14}
/>
<Text style={[s.fw400, s.f14]}>Remove</Text>
</>
)
: undefined
return (
<ProfileCard
did={item.did}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
renderButton={renderButton}
onPressButton={() => onPressRemoveMember(item)}
/>
)
}
} else if (uiState.members.isEmpty) {
items = items.concat([EMPTY_ITEM])
renderItem = () => (
<EmptyState
icon="user-group"
message="This scene doesn't have any members."
icon={['far', 'message']}
message="No posts yet!"
style={{paddingVertical: 40}}
/>
)
}

View file

@ -1,25 +0,0 @@
import React, {useEffect} from 'react'
import {View} from 'react-native'
import {ViewHeader} from '../com/util/ViewHeader'
import {ProfileMembers as ProfileMembersComponent} from '../com/profile/ProfileMembers'
import {ScreenParams} from '../routes'
import {useStores} from '../../state'
export const ProfileMembers = ({navIdx, visible, params}: ScreenParams) => {
const store = useStores()
const {name} = params
useEffect(() => {
if (visible) {
store.nav.setTitle(navIdx, `Members of ${name}`)
store.shell.setMinimalShellMode(false)
}
}, [store, visible, name])
return (
<View>
<ViewHeader title="Members" subtitle={`of ${name}`} />
<ProfileMembersComponent name={name} />
</View>
)
}

View file

@ -1,4 +1,4 @@
import React, {useEffect} from 'react'
import React from 'react'
import {
ScrollView,
StyleProp,
@ -11,17 +11,10 @@ import {observer} from 'mobx-react-lite'
import VersionNumber from 'react-native-version-number'
import {s, colors} from '../../lib/styles'
import {useStores} from '../../../state'
import {
HomeIcon,
UserGroupIcon,
BellIcon,
CogIcon,
MagnifyingGlassIcon,
} from '../../lib/icons'
import {HomeIcon, BellIcon, CogIcon, MagnifyingGlassIcon} from '../../lib/icons'
import {UserAvatar} from '../../com/util/UserAvatar'
import {Text} from '../../com/util/text/Text'
import {ToggleButton} from '../../com/util/forms/ToggleButton'
import {CreateSceneModal} from '../../../state/models/shell-ui'
import {usePalette} from '../../lib/hooks/usePalette'
export const Menu = observer(
@ -29,14 +22,6 @@ export const Menu = observer(
const pal = usePalette('default')
const store = useStores()
useEffect(() => {
if (visible) {
// trigger a refresh in case memberships have changed recently
// TODO this impacts performance, need to find the right time to do this
// store.me.refreshMemberships()
}
}, [store, visible])
// events
// =
@ -51,10 +36,6 @@ export const Menu = observer(
}
}
}
const onPressCreateScene = () => {
onClose()
store.shell.openModal(new CreateSceneModal())
}
// rendering
// =
@ -152,40 +133,6 @@ export const Menu = observer(
url="/notifications"
count={store.me.notificationCount}
/>
</View>
<View style={[styles.section, pal.border]}>
<Text type="h5" style={[pal.text, styles.heading]}>
Scenes
</Text>
{store.me.memberships
? store.me.memberships.memberships.map((membership, i) => (
<MenuItem
key={i}
icon={
<UserAvatar
size={34}
displayName={membership.displayName}
handle={membership.handle}
avatar={membership.avatar}
/>
}
label={membership.displayName || membership.handle}
url={`/profile/${membership.handle}`}
/>
))
: undefined}
</View>
<View style={[styles.section, pal.border]}>
<MenuItem
icon={
<UserGroupIcon
style={pal.text as StyleProp<ViewStyle>}
size="30"
/>
}
label="Create a scene"
onPress={onPressCreateScene}
/>
<MenuItem
icon={
<CogIcon