about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/modals/Confirm.tsx95
-rw-r--r--src/view/com/modals/CreateScene.tsx2
-rw-r--r--src/view/com/modals/InviteToScene.tsx238
-rw-r--r--src/view/com/modals/Modal.tsx28
-rw-r--r--src/view/com/notifications/FeedItem.tsx35
-rw-r--r--src/view/com/notifications/InviteAccepter.tsx96
-rw-r--r--src/view/com/profile/ProfileCard.tsx36
-rw-r--r--src/view/com/profile/ProfileHeader.tsx103
-rw-r--r--src/view/com/util/Link.tsx5
-rw-r--r--src/view/index.ts4
-rw-r--r--src/view/screens/Notifications.tsx1
-rw-r--r--src/view/screens/Profile.tsx2
12 files changed, 618 insertions, 27 deletions
diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx
new file mode 100644
index 000000000..5afe7c087
--- /dev/null
+++ b/src/view/com/modals/Confirm.tsx
@@ -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,
+  },
+})
diff --git a/src/view/com/modals/CreateScene.tsx b/src/view/com/modals/CreateScene.tsx
index 16a085d53..5c0a351b2 100644
--- a/src/view/com/modals/CreateScene.tsx
+++ b/src/view/com/modals/CreateScene.tsx
@@ -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(),
           },
diff --git a/src/view/com/modals/InviteToScene.tsx b/src/view/com/modals/InviteToScene.tsx
new file mode 100644
index 000000000..f1c9d3386
--- /dev/null
+++ b/src/view/com/modals/InviteToScene.tsx
@@ -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,
+  },
+})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index f79a571d4..f2c61a6ae 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -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 />
   }
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index c67e6b966..fdc893e78 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -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,
+  },
 })
diff --git a/src/view/com/notifications/InviteAccepter.tsx b/src/view/com/notifications/InviteAccepter.tsx
new file mode 100644
index 000000000..7d735a66b
--- /dev/null
+++ b/src/view/com/notifications/InviteAccepter.tsx
@@ -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',
+  },
+})
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index cb58aec3f..fc8104937 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -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,
+  },
 })
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index ee4df4fb9..3adffc44c 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -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,
+  },
 })
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 08536b0c3..84060ac01 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -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))
   }
diff --git a/src/view/index.ts b/src/view/index.ts
index fcae1491a..341051d4e 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -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,
   )
diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx
index 50832bd8d..85106f2de 100644
--- a/src/view/screens/Notifications.tsx
+++ b/src/view/screens/Notifications.tsx
@@ -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()
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index df14f3c52..7fb3a5fb7 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -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[] = []