Implement scene invitation and membership controls
This commit is contained in:
parent
ecf56729b0
commit
d3707f30e3
49 changed files with 2603 additions and 462 deletions
95
src/view/com/modals/Confirm.tsx
Normal file
95
src/view/com/modals/Confirm.tsx
Normal file
|
@ -0,0 +1,95 @@
|
|||
import React, {useState} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {useStores} from '../../../state'
|
||||
import {s, colors, gradients} from '../../lib/styles'
|
||||
import {ErrorMessage} from '../util/ErrorMessage'
|
||||
|
||||
export const snapPoints = ['50%']
|
||||
|
||||
export function Component({
|
||||
title,
|
||||
message,
|
||||
onPressConfirm,
|
||||
}: {
|
||||
title: string
|
||||
message: string | (() => JSX.Element)
|
||||
onPressConfirm: () => void | Promise<void>
|
||||
}) {
|
||||
const store = useStores()
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
const onPress = async () => {
|
||||
setError('')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await onPressConfirm()
|
||||
store.shell.closeModal()
|
||||
return
|
||||
} catch (e: any) {
|
||||
setError(e.toString())
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<View style={[s.flex1, s.pl10, s.pr10]}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{typeof message === 'string' ? (
|
||||
<Text style={styles.description}>{message}</Text>
|
||||
) : (
|
||||
message()
|
||||
)}
|
||||
{error ? (
|
||||
<View style={s.mt10}>
|
||||
<ErrorMessage message={error} />
|
||||
</View>
|
||||
) : undefined}
|
||||
{isProcessing ? (
|
||||
<View style={[styles.btn, s.mt10]}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity style={s.mt10} onPress={onPress}>
|
||||
<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]}>Confirm</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</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,
|
||||
},
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
borderRadius: 32,
|
||||
padding: 14,
|
||||
backgroundColor: colors.gray1,
|
||||
},
|
||||
})
|
|
@ -53,7 +53,7 @@ export function Component({}: {}) {
|
|||
{
|
||||
subject: {
|
||||
did: createSceneRes.data.did,
|
||||
declarationCid: createSceneRes.data.declarationCid,
|
||||
declarationCid: createSceneRes.data.declaration.cid,
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
|
|
238
src/view/com/modals/InviteToScene.tsx
Normal file
238
src/view/com/modals/InviteToScene.tsx
Normal file
|
@ -0,0 +1,238 @@
|
|||
import React, {useState, useEffect, useMemo} from 'react'
|
||||
import Toast from '../util/Toast'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
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/ErrorMessage'
|
||||
import {useStores} from '../../../state'
|
||||
import * as apilib from '../../../state/lib/api'
|
||||
import {ProfileViewModel} from '../../../state/models/profile-view'
|
||||
import {SceneInviteSuggestions} from '../../../state/models/scene-invite-suggestions'
|
||||
import {FollowItem} from '../../../state/models/user-follows-view'
|
||||
import {s, colors} from '../../lib/styles'
|
||||
|
||||
export const snapPoints = ['70%']
|
||||
|
||||
export 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 SceneInviteSuggestions(store, {sceneDid: profileView.did}),
|
||||
[profileView.did],
|
||||
)
|
||||
const [createdInvites, setCreatedInvites] = useState<Record<string, string>>(
|
||||
{},
|
||||
)
|
||||
|
||||
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', {
|
||||
duration: Toast.durations.LONG,
|
||||
position: Toast.positions.TOP,
|
||||
})
|
||||
} catch (e) {
|
||||
setError('There was an issue with the invite. Please try again.')
|
||||
console.error(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) {
|
||||
setError('There was an issue with the invite. Please try again.')
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const renderSuggestionItem = ({item}: {item: FollowItem}) => {
|
||||
const createdInvite = createdInvites[item.did]
|
||||
return (
|
||||
<ProfileCard
|
||||
did={item.did}
|
||||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
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 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>
|
||||
<View style={styles.todoContainer}>
|
||||
<Text style={styles.todoLabel}>
|
||||
Pending invites are still being implemented. Check back soon!
|
||||
</Text>
|
||||
</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,
|
||||
},
|
||||
})
|
|
@ -8,9 +8,11 @@ import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
|
|||
import * as models from '../../../state/models/shell-ui'
|
||||
|
||||
import * as LinkActionsModal from './LinkActions'
|
||||
import * as ConfirmModal from './Confirm'
|
||||
import * as SharePostModal from './SharePost.native'
|
||||
import * as EditProfile from './EditProfile'
|
||||
import * as CreateScene from './CreateScene'
|
||||
import * as EditProfileModal from './EditProfile'
|
||||
import * as CreateSceneModal from './CreateScene'
|
||||
import * as InviteToSceneModal from './InviteToScene'
|
||||
|
||||
const CLOSED_SNAPPOINTS = ['10%']
|
||||
|
||||
|
@ -44,6 +46,13 @@ export const Modal = observer(function Modal() {
|
|||
{...(store.shell.activeModal as models.LinkActionsModel)}
|
||||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'confirm') {
|
||||
snapPoints = ConfirmModal.snapPoints
|
||||
element = (
|
||||
<ConfirmModal.Component
|
||||
{...(store.shell.activeModal as models.ConfirmModel)}
|
||||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'share-post') {
|
||||
snapPoints = SharePostModal.snapPoints
|
||||
element = (
|
||||
|
@ -52,15 +61,22 @@ export const Modal = observer(function Modal() {
|
|||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'edit-profile') {
|
||||
snapPoints = EditProfile.snapPoints
|
||||
snapPoints = EditProfileModal.snapPoints
|
||||
element = (
|
||||
<EditProfile.Component
|
||||
<EditProfileModal.Component
|
||||
{...(store.shell.activeModal as models.EditProfileModel)}
|
||||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'create-scene') {
|
||||
snapPoints = CreateScene.snapPoints
|
||||
element = <CreateScene.Component />
|
||||
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.InviteToSceneModel)}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
element = <View />
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, {useMemo} from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {Image, StyleSheet, Text, View} from 'react-native'
|
||||
import {StyleSheet, Text, View} from 'react-native'
|
||||
import {AtUri} from '../../../third-party/uri'
|
||||
import {FontAwesomeIcon, Props} from '@fortawesome/react-native-fontawesome'
|
||||
import {NotificationsViewItemModel} from '../../../state/models/notifications-view'
|
||||
|
@ -11,6 +11,7 @@ import {UserAvatar} from '../util/UserAvatar'
|
|||
import {PostText} from '../post/PostText'
|
||||
import {Post} from '../post/Post'
|
||||
import {Link} from '../util/Link'
|
||||
import {InviteAccepter} from './InviteAccepter'
|
||||
|
||||
const MAX_AUTHORS = 8
|
||||
|
||||
|
@ -20,10 +21,10 @@ export const FeedItem = observer(function FeedItem({
|
|||
item: NotificationsViewItemModel
|
||||
}) {
|
||||
const itemHref = useMemo(() => {
|
||||
if (item.isUpvote || item.isRepost) {
|
||||
if (item.isUpvote || item.isRepost || item.isTrend) {
|
||||
const urip = new AtUri(item.subjectUri)
|
||||
return `/profile/${urip.host}/post/${urip.rkey}`
|
||||
} else if (item.isFollow) {
|
||||
} else if (item.isFollow || item.isAssertion) {
|
||||
return `/profile/${item.author.handle}`
|
||||
} else if (item.isReply) {
|
||||
const urip = new AtUri(item.uri)
|
||||
|
@ -34,7 +35,7 @@ export const FeedItem = observer(function FeedItem({
|
|||
const itemTitle = useMemo(() => {
|
||||
if (item.isUpvote || item.isRepost) {
|
||||
return 'Post'
|
||||
} else if (item.isFollow) {
|
||||
} else if (item.isFollow || item.isAssertion) {
|
||||
return item.author.handle
|
||||
} else if (item.isReply) {
|
||||
return 'Post'
|
||||
|
@ -66,6 +67,10 @@ 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.blue3]
|
||||
} else if (item.isReply) {
|
||||
action = 'replied to your post'
|
||||
icon = ['far', 'comment']
|
||||
|
@ -73,6 +78,10 @@ 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 <></>
|
||||
}
|
||||
|
@ -133,6 +142,9 @@ export const FeedItem = observer(function FeedItem({
|
|||
) : undefined}
|
||||
</View>
|
||||
<View style={styles.meta}>
|
||||
{item.isTrend && (
|
||||
<Text style={[styles.metaItem, s.f15]}>{action}</Text>
|
||||
)}
|
||||
<Link
|
||||
key={authors[0].href}
|
||||
style={styles.metaItem}
|
||||
|
@ -150,7 +162,9 @@ export const FeedItem = observer(function FeedItem({
|
|||
</Text>
|
||||
</>
|
||||
) : undefined}
|
||||
<Text style={[styles.metaItem, s.f15]}>{action}</Text>
|
||||
{!item.isTrend && (
|
||||
<Text style={[styles.metaItem, s.f15]}>{action}</Text>
|
||||
)}
|
||||
<Text style={[styles.metaItem, s.f15, s.gray5]}>
|
||||
{ago(item.indexedAt)}
|
||||
</Text>
|
||||
|
@ -162,6 +176,11 @@ export const FeedItem = observer(function FeedItem({
|
|||
)}
|
||||
</View>
|
||||
</View>
|
||||
{item.isInvite && (
|
||||
<View style={styles.addedContainer}>
|
||||
<InviteAccepter item={item} />
|
||||
</View>
|
||||
)}
|
||||
{item.isReply ? (
|
||||
<View style={s.pt5}>
|
||||
<Post uri={item.uri} />
|
||||
|
@ -216,6 +235,7 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
meta: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
paddingTop: 6,
|
||||
paddingBottom: 2,
|
||||
},
|
||||
|
@ -225,4 +245,9 @@ const styles = StyleSheet.create({
|
|||
postText: {
|
||||
paddingBottom: 5,
|
||||
},
|
||||
|
||||
addedContainer: {
|
||||
paddingTop: 4,
|
||||
paddingLeft: 36,
|
||||
},
|
||||
})
|
||||
|
|
96
src/view/com/notifications/InviteAccepter.tsx
Normal file
96
src/view/com/notifications/InviteAccepter.tsx
Normal file
|
@ -0,0 +1,96 @@
|
|||
import React, {useState} from 'react'
|
||||
import {StyleSheet, Text, 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 {ConfirmModel} from '../../../state/models/shell-ui'
|
||||
import {useStores} from '../../../state'
|
||||
import {ProfileCard} from '../profile/ProfileCard'
|
||||
import Toast from '../util/Toast'
|
||||
import {s, colors, gradients} from '../../lib/styles'
|
||||
|
||||
export function InviteAccepter({item}: {item: NotificationsViewItemModel}) {
|
||||
const store = useStores()
|
||||
const [confirmationUri, setConfirmationUri] = useState<string>('')
|
||||
const isMember =
|
||||
confirmationUri !== '' || store.me.memberships?.isMemberOf(item.author.did)
|
||||
const onPressAccept = async () => {
|
||||
store.shell.openModal(
|
||||
new ConfirmModel(
|
||||
'Join this scene?',
|
||||
() => (
|
||||
<View>
|
||||
<View style={styles.profileCardContainer}>
|
||||
<ProfileCard
|
||||
did={item.author.did}
|
||||
handle={item.author.handle}
|
||||
displayName={item.author.displayName}
|
||||
/>
|
||||
</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', {
|
||||
duration: Toast.durations.LONG,
|
||||
position: Toast.positions.TOP,
|
||||
})
|
||||
setConfirmationUri(uri)
|
||||
}
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{!isMember ? (
|
||||
<TouchableOpacity 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 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',
|
||||
},
|
||||
})
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, Text, View} from 'react-native'
|
||||
import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'
|
||||
import {Link} from '../util/Link'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {s, colors} from '../../lib/styles'
|
||||
|
@ -9,11 +9,15 @@ export function ProfileCard({
|
|||
handle,
|
||||
displayName,
|
||||
description,
|
||||
renderButton,
|
||||
onPressButton,
|
||||
}: {
|
||||
did: string
|
||||
handle: string
|
||||
displayName?: string
|
||||
description?: string
|
||||
renderButton?: () => JSX.Element
|
||||
onPressButton?: () => void
|
||||
}) {
|
||||
return (
|
||||
<Link style={styles.outer} href={`/profile/${handle}`} title={handle}>
|
||||
|
@ -22,9 +26,20 @@ export function ProfileCard({
|
|||
<UserAvatar size={40} displayName={displayName} handle={handle} />
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<Text style={[s.f16, s.bold]}>{displayName || handle}</Text>
|
||||
<Text style={[s.f15, s.gray5]}>@{handle}</Text>
|
||||
<Text style={[s.f16, s.bold]} numberOfLines={1}>
|
||||
{displayName || handle}
|
||||
</Text>
|
||||
<Text style={[s.f15, s.gray5]} numberOfLines={1}>
|
||||
@{handle}
|
||||
</Text>
|
||||
</View>
|
||||
{renderButton ? (
|
||||
<View style={styles.layoutButton}>
|
||||
<TouchableOpacity onPress={onPressButton} style={styles.btn}>
|
||||
{renderButton()}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : undefined}
|
||||
</View>
|
||||
</Link>
|
||||
)
|
||||
|
@ -34,9 +49,11 @@ const styles = StyleSheet.create({
|
|||
outer: {
|
||||
marginTop: 1,
|
||||
backgroundColor: colors.white,
|
||||
borderRadius: 6,
|
||||
},
|
||||
layout: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
layoutAvi: {
|
||||
width: 60,
|
||||
|
@ -56,4 +73,17 @@ const styles = StyleSheet.create({
|
|||
paddingTop: 12,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
layoutButton: {
|
||||
paddingRight: 10,
|
||||
},
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 7,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 50,
|
||||
backgroundColor: colors.gray1,
|
||||
marginLeft: 6,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react'
|
||||
import React, {useMemo} from 'react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
|
@ -9,12 +9,18 @@ import {
|
|||
} 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 {EditProfileModel} from '../../../state/models/shell-ui'
|
||||
import {
|
||||
ConfirmModel,
|
||||
EditProfileModel,
|
||||
InviteToSceneModel,
|
||||
} from '../../../state/models/shell-ui'
|
||||
import {pluralize} from '../../lib/strings'
|
||||
import {s, colors} from '../../lib/styles'
|
||||
import {getGradient} from '../../lib/asset-gen'
|
||||
import {DropdownBtn, DropdownItem} from '../util/DropdownBtn'
|
||||
import Toast from '../util/Toast'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {UserBanner} from '../util/UserBanner'
|
||||
|
@ -22,10 +28,16 @@ import {UserInfoText} from '../util/UserInfoText'
|
|||
|
||||
export const ProfileHeader = observer(function ProfileHeader({
|
||||
view,
|
||||
onRefreshAll,
|
||||
}: {
|
||||
view: ProfileViewModel
|
||||
onRefreshAll: () => void
|
||||
}) {
|
||||
const store = useStores()
|
||||
const isMember = useMemo(
|
||||
() => view.isScene && view.myState.member,
|
||||
[view.myState.member],
|
||||
)
|
||||
|
||||
const onPressBack = () => {
|
||||
store.nav.tab.goBack()
|
||||
|
@ -49,9 +61,6 @@ export const ProfileHeader = observer(function ProfileHeader({
|
|||
const onPressEditProfile = () => {
|
||||
store.shell.openModal(new EditProfileModel(view))
|
||||
}
|
||||
const onPressMenu = () => {
|
||||
// TODO
|
||||
}
|
||||
const onPressFollowers = () => {
|
||||
store.nav.navigate(`/profile/${view.handle}/followers`)
|
||||
}
|
||||
|
@ -61,6 +70,31 @@ export const ProfileHeader = observer(function ProfileHeader({
|
|||
const onPressMembers = () => {
|
||||
store.nav.navigate(`/profile/${view.handle}/members`)
|
||||
}
|
||||
const onPressInviteMembers = () => {
|
||||
store.shell.openModal(new InviteToSceneModel(view))
|
||||
}
|
||||
const onPressLeaveScene = () => {
|
||||
store.shell.openModal(
|
||||
new ConfirmModel(
|
||||
'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`, {
|
||||
duration: Toast.durations.LONG,
|
||||
position: Toast.positions.TOP,
|
||||
})
|
||||
}
|
||||
onRefreshAll()
|
||||
}
|
||||
|
||||
// loading
|
||||
// =
|
||||
|
@ -86,6 +120,23 @@ 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 (isCreator || isMember) {
|
||||
dropdownItems = []
|
||||
if (isCreator) {
|
||||
dropdownItems.push({
|
||||
label: 'Edit Profile',
|
||||
onPress: () => {}, // TODO
|
||||
})
|
||||
}
|
||||
if (isMember) {
|
||||
dropdownItems.push({
|
||||
label: 'Leave Scene...',
|
||||
onPress: onPressLeaveScene,
|
||||
})
|
||||
}
|
||||
}
|
||||
return (
|
||||
<View style={styles.outer}>
|
||||
<UserBanner handle={view.handle} />
|
||||
|
@ -136,11 +187,14 @@ export const ProfileHeader = observer(function ProfileHeader({
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
onPress={onPressMenu}
|
||||
style={[styles.btn, styles.secondaryBtn]}>
|
||||
<FontAwesomeIcon icon="ellipsis" style={[s.gray5]} />
|
||||
</TouchableOpacity>
|
||||
{view.isScene &&
|
||||
(view.myState.member || view.creator === store.me.did) ? (
|
||||
<DropdownBtn
|
||||
items={dropdownItems}
|
||||
style={[styles.btn, styles.secondaryBtn]}>
|
||||
<FontAwesomeIcon icon="ellipsis" style={[s.gray5]} />
|
||||
</DropdownBtn>
|
||||
) : undefined}
|
||||
</View>
|
||||
<View style={styles.displayNameLine}>
|
||||
<Text style={styles.displayName}>
|
||||
|
@ -224,6 +278,24 @@ export const ProfileHeader = observer(function ProfileHeader({
|
|||
</View>
|
||||
) : undefined}
|
||||
</View>
|
||||
{view.isScene && view.creator === store.me.did ? (
|
||||
<View style={styles.sceneAdminContainer}>
|
||||
<TouchableOpacity 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 style={[s.bold, s.f15, s.white]}>Invite Members</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : undefined}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
@ -340,4 +412,15 @@ const styles = StyleSheet.create({
|
|||
alignItems: 'center',
|
||||
marginBottom: 5,
|
||||
},
|
||||
|
||||
sceneAdminContainer: {
|
||||
borderColor: colors.gray1,
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
sceneAdminBtn: {
|
||||
paddingVertical: 8,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -16,7 +16,10 @@ export const Link = observer(function Link({
|
|||
children?: React.ReactNode
|
||||
}) {
|
||||
const store = useStores()
|
||||
const onPress = () => store.nav.navigate(href)
|
||||
const onPress = () => {
|
||||
store.shell.closeModal() // close any active modals
|
||||
store.nav.navigate(href)
|
||||
}
|
||||
const onLongPress = () => {
|
||||
store.shell.openModal(new LinkActionsModel(href, title || href))
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ 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'
|
||||
|
@ -47,6 +48,7 @@ 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'
|
||||
import {faUserPlus} from '@fortawesome/free-solid-svg-icons/faUserPlus'
|
||||
import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark'
|
||||
import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
|
||||
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
|
||||
|
||||
|
@ -61,6 +63,7 @@ export function setup() {
|
|||
faArrowUpFromBracket,
|
||||
faArrowUpRightFromSquare,
|
||||
faArrowsRotate,
|
||||
faArrowTrendUp,
|
||||
faAt,
|
||||
faBars,
|
||||
faBell,
|
||||
|
@ -99,6 +102,7 @@ export function setup() {
|
|||
faUsers,
|
||||
faUserCheck,
|
||||
faUserPlus,
|
||||
faUserXmark,
|
||||
faTicket,
|
||||
faX,
|
||||
)
|
||||
|
|
|
@ -18,6 +18,7 @@ export const Notifications = ({visible}: ScreenParams) => {
|
|||
if (!visible) {
|
||||
return
|
||||
}
|
||||
store.me.refreshMemberships() // needed for the invite notifications
|
||||
if (hasSetup) {
|
||||
console.log('Updating notifications feed')
|
||||
notesView?.update()
|
||||
|
|
|
@ -73,7 +73,7 @@ export const Profile = observer(({visible, params}: ScreenParams) => {
|
|||
if (!uiState) {
|
||||
return <View />
|
||||
}
|
||||
return <ProfileHeader view={uiState.profile} />
|
||||
return <ProfileHeader view={uiState.profile} onRefreshAll={onRefresh} />
|
||||
}
|
||||
let renderItem
|
||||
let items: any[] = []
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue