Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Paul Frazee and committed by
GitHub
bf1092ad 5abcc8e3

+18 -1714
-10
__tests__/view/shell/mobile/Menu.test.tsx
··· 57 57 expect(onCloseMock).toHaveBeenCalled() 58 58 expect(mockedNavigationStore.switchTo).toHaveBeenCalledWith(1, true) 59 59 }) 60 - 61 - it('presses new scene button', () => { 62 - const {getAllByTestId} = render(<Menu {...mockedProps} />) 63 - 64 - const menuItemButton = getAllByTestId('menuItemButton') 65 - fireEvent.press(menuItemButton[3]) 66 - 67 - expect(onCloseMock).toHaveBeenCalled() 68 - expect(mockedShellStore.openModal).toHaveBeenCalled() 69 - }) 70 60 })
public/img/scene-explainer.jpg

This is a binary file and will not be displayed.

-48
src/state/lib/api.ts
··· 216 216 }) 217 217 } 218 218 219 - export async function inviteToScene( 220 - store: RootStoreModel, 221 - sceneDid: string, 222 - subjectDid: string, 223 - subjectDeclarationCid: string, 224 - ): Promise<string> { 225 - const res = await store.api.app.bsky.graph.assertion.create( 226 - { 227 - did: sceneDid, 228 - }, 229 - { 230 - subject: { 231 - did: subjectDid, 232 - declarationCid: subjectDeclarationCid, 233 - }, 234 - assertion: APP_BSKY_GRAPH.AssertMember, 235 - createdAt: new Date().toISOString(), 236 - }, 237 - ) 238 - return res.uri 239 - } 240 - 241 - interface Confirmation { 242 - originator: { 243 - did: string 244 - declarationCid: string 245 - } 246 - assertion: { 247 - uri: string 248 - cid: string 249 - } 250 - } 251 - export async function acceptSceneInvite( 252 - store: RootStoreModel, 253 - details: Confirmation, 254 - ): Promise<string> { 255 - const res = await store.api.app.bsky.graph.confirmation.create( 256 - { 257 - did: store.me.did || '', 258 - }, 259 - { 260 - ...details, 261 - createdAt: new Date().toISOString(), 262 - }, 263 - ) 264 - return res.uri 265 - } 266 - 267 219 interface FetchHandlerResponse { 268 220 status: number 269 221 headers: Record<string, string>
+1 -9
src/state/models/feed-view.ts
··· 6 6 AppBskyFeedGetAuthorFeed as GetAuthorFeed, 7 7 } from '@atproto/api' 8 8 type FeedViewPost = AppBskyFeedFeedViewPost.Main 9 - type ReasonTrend = AppBskyFeedFeedViewPost.ReasonTrend 10 9 type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost 11 10 type PostView = AppBskyFeedPost.View 12 11 import {AtUri} from '../../third-party/uri' ··· 91 90 get reasonRepost(): ReasonRepost | undefined { 92 91 if (this.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost') { 93 92 return this.reason as ReasonRepost 94 - } 95 - } 96 - 97 - get reasonTrend(): ReasonTrend | undefined { 98 - if (this.reason?.$type === 'app.bsky.feed.feedViewPost#reasonTrend') { 99 - return this.reason as ReasonTrend 100 93 } 101 94 } 102 95 ··· 494 487 private _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) { 495 488 for (const item of res.data.feed) { 496 489 const existingItem = this.feed.find( 497 - // HACK: need to find the reposts and trends item, so we have to check for that -prf 490 + // HACK: need to find the reposts' item, so we have to check for that -prf 498 491 item2 => 499 492 item.post.uri === item2.post.uri && 500 - item.reason?.$trend === item2.reason?.$trend && 501 493 // @ts-ignore todo 502 494 item.reason?.by?.did === item2.reason?.by?.did, 503 495 )
-13
src/state/models/me.ts
··· 1 1 import {makeAutoObservable, runInAction} from 'mobx' 2 2 import {RootStoreModel} from './root-store' 3 3 import {FeedModel} from './feed-view' 4 - import {MembershipsViewModel} from './memberships-view' 5 4 import {NotificationsViewModel} from './notifications-view' 6 5 import {isObj, hasProp} from '../lib/type-guards' 7 6 ··· 12 11 description: string = '' 13 12 avatar: string = '' 14 13 notificationCount: number = 0 15 - memberships?: MembershipsViewModel 16 14 mainFeed: FeedModel 17 15 notifications: NotificationsViewModel 18 16 ··· 35 33 this.description = '' 36 34 this.avatar = '' 37 35 this.notificationCount = 0 38 - this.memberships = undefined 39 36 } 40 37 41 38 serialize(): unknown { ··· 99 96 algorithm: 'reverse-chronological', 100 97 }) 101 98 this.notifications = new NotificationsViewModel(this.rootStore, {}) 102 - this.memberships = new MembershipsViewModel(this.rootStore, { 103 - actor: this.did, 104 - }) 105 99 await Promise.all([ 106 - this.memberships?.setup().catch(e => { 107 - this.rootStore.log.error('Failed to setup memberships model', e) 108 - }), 109 100 this.mainFeed.setup().catch(e => { 110 101 this.rootStore.log.error('Failed to setup main feed model', e) 111 102 }), ··· 132 123 this.notifications.refresh() 133 124 } 134 125 }) 135 - } 136 - 137 - async refreshMemberships() { 138 - return this.memberships?.refresh() 139 126 } 140 127 }
-149
src/state/models/members-view.ts
··· 1 - import {makeAutoObservable, runInAction} from 'mobx' 2 - import { 3 - AppBskyGraphGetMembers as GetMembers, 4 - AppBskyActorRef as ActorRef, 5 - APP_BSKY_GRAPH, 6 - } from '@atproto/api' 7 - import {AtUri} from '../../third-party/uri' 8 - import {RootStoreModel} from './root-store' 9 - 10 - export type MemberItem = GetMembers.Member & { 11 - _reactKey: string 12 - } 13 - 14 - export class MembersViewModel { 15 - // state 16 - isLoading = false 17 - isRefreshing = false 18 - hasLoaded = false 19 - error = '' 20 - params: GetMembers.QueryParams 21 - 22 - // data 23 - subject: ActorRef.WithInfo = { 24 - did: '', 25 - handle: '', 26 - displayName: '', 27 - declaration: {cid: '', actorType: ''}, 28 - avatar: undefined, 29 - } 30 - members: MemberItem[] = [] 31 - 32 - constructor( 33 - public rootStore: RootStoreModel, 34 - params: GetMembers.QueryParams, 35 - ) { 36 - makeAutoObservable( 37 - this, 38 - { 39 - rootStore: false, 40 - params: false, 41 - }, 42 - {autoBind: true}, 43 - ) 44 - this.params = params 45 - } 46 - 47 - get hasContent() { 48 - return this.members.length !== 0 49 - } 50 - 51 - get hasError() { 52 - return this.error !== '' 53 - } 54 - 55 - get isEmpty() { 56 - return this.hasLoaded && !this.hasContent 57 - } 58 - 59 - isMember(did: string) { 60 - return this.members.find(member => member.did === did) 61 - } 62 - 63 - // public api 64 - // = 65 - 66 - async setup() { 67 - await this._fetch() 68 - } 69 - 70 - async refresh() { 71 - await this._fetch(true) 72 - } 73 - 74 - async loadMore() { 75 - // TODO 76 - } 77 - 78 - async removeMember(did: string) { 79 - const assertsRes = await this.rootStore.api.app.bsky.graph.getAssertions({ 80 - author: this.subject.did, 81 - subject: did, 82 - assertion: APP_BSKY_GRAPH.AssertMember, 83 - }) 84 - if (assertsRes.data.assertions.length < 1) { 85 - throw new Error('Could not find membership record') 86 - } 87 - for (const assert of assertsRes.data.assertions) { 88 - await this.rootStore.api.app.bsky.graph.assertion.delete({ 89 - did: this.subject.did, 90 - rkey: new AtUri(assert.uri).rkey, 91 - }) 92 - } 93 - runInAction(() => { 94 - this.members = this.members.filter(m => m.did !== did) 95 - }) 96 - } 97 - 98 - // state transitions 99 - // = 100 - 101 - private _xLoading(isRefreshing = false) { 102 - this.isLoading = true 103 - this.isRefreshing = isRefreshing 104 - this.error = '' 105 - } 106 - 107 - private _xIdle(err?: any) { 108 - this.isLoading = false 109 - this.isRefreshing = false 110 - this.hasLoaded = true 111 - this.error = err ? err.toString() : '' 112 - if (err) { 113 - this.rootStore.log.error('Failed to fetch members', err) 114 - } 115 - } 116 - 117 - // loader functions 118 - // = 119 - 120 - private async _fetch(isRefreshing = false) { 121 - this._xLoading(isRefreshing) 122 - try { 123 - const res = await this.rootStore.api.app.bsky.graph.getMembers( 124 - this.params, 125 - ) 126 - this._replaceAll(res) 127 - this._xIdle() 128 - } catch (e: any) { 129 - this._xIdle(e) 130 - } 131 - } 132 - 133 - private _replaceAll(res: GetMembers.Response) { 134 - this.subject.did = res.data.subject.did 135 - this.subject.handle = res.data.subject.handle 136 - this.subject.displayName = res.data.subject.displayName 137 - this.subject.declaration = res.data.subject.declaration 138 - this.subject.avatar = res.data.subject.avatar 139 - this.members.length = 0 140 - let counter = 0 141 - for (const item of res.data.members) { 142 - this._append({_reactKey: `item-${counter++}`, ...item}) 143 - } 144 - } 145 - 146 - private _append(item: MemberItem) { 147 - this.members.push(item) 148 - } 149 - }
-127
src/state/models/memberships-view.ts
··· 1 - import {makeAutoObservable} from 'mobx' 2 - import { 3 - AppBskyGraphGetMemberships as GetMemberships, 4 - AppBskyActorRef as ActorRef, 5 - } from '@atproto/api' 6 - import {RootStoreModel} from './root-store' 7 - 8 - export type MembershipItem = GetMemberships.Membership & { 9 - _reactKey: string 10 - } 11 - 12 - export class MembershipsViewModel { 13 - // state 14 - isLoading = false 15 - isRefreshing = false 16 - hasLoaded = false 17 - error = '' 18 - params: GetMemberships.QueryParams 19 - 20 - // data 21 - subject: ActorRef.WithInfo = { 22 - did: '', 23 - handle: '', 24 - displayName: '', 25 - declaration: {cid: '', actorType: ''}, 26 - avatar: undefined, 27 - } 28 - memberships: MembershipItem[] = [] 29 - 30 - constructor( 31 - public rootStore: RootStoreModel, 32 - params: GetMemberships.QueryParams, 33 - ) { 34 - makeAutoObservable( 35 - this, 36 - { 37 - rootStore: false, 38 - params: false, 39 - }, 40 - {autoBind: true}, 41 - ) 42 - this.params = params 43 - } 44 - 45 - get hasContent() { 46 - return this.memberships.length !== 0 47 - } 48 - 49 - get hasError() { 50 - return this.error !== '' 51 - } 52 - 53 - get isEmpty() { 54 - return this.hasLoaded && !this.hasContent 55 - } 56 - 57 - isMemberOf(did: string) { 58 - return !!this.memberships.find(m => m.did === did) 59 - } 60 - 61 - // public api 62 - // = 63 - 64 - async setup() { 65 - await this._fetch() 66 - } 67 - 68 - async refresh() { 69 - await this._fetch(true) 70 - } 71 - 72 - async loadMore() { 73 - // TODO 74 - } 75 - 76 - // state transitions 77 - // = 78 - 79 - private _xLoading(isRefreshing = false) { 80 - this.isLoading = true 81 - this.isRefreshing = isRefreshing 82 - this.error = '' 83 - } 84 - 85 - private _xIdle(err?: any) { 86 - this.isLoading = false 87 - this.isRefreshing = false 88 - this.hasLoaded = true 89 - this.error = err ? err.toString() : '' 90 - if (err) { 91 - this.rootStore.log.error('Failed to fetch memberships', err) 92 - } 93 - } 94 - 95 - // loader functions 96 - // = 97 - 98 - private async _fetch(isRefreshing = false) { 99 - this._xLoading(isRefreshing) 100 - try { 101 - const res = await this.rootStore.api.app.bsky.graph.getMemberships( 102 - this.params, 103 - ) 104 - this._replaceAll(res) 105 - this._xIdle() 106 - } catch (e: any) { 107 - this._xIdle(e) 108 - } 109 - } 110 - 111 - private _replaceAll(res: GetMemberships.Response) { 112 - this.subject.did = res.data.subject.did 113 - this.subject.handle = res.data.subject.handle 114 - this.subject.displayName = res.data.subject.displayName 115 - this.subject.declaration = res.data.subject.declaration 116 - this.subject.avatar = res.data.subject.avatar 117 - this.memberships.length = 0 118 - let counter = 0 119 - for (const item of res.data.memberships) { 120 - this._append({_reactKey: `item-${counter++}`, ...item}) 121 - } 122 - } 123 - 124 - private _append(item: MembershipItem) { 125 - this.memberships.push(item) 126 - } 127 - }
+3 -26
src/state/models/notifications-view.ts
··· 4 4 AppBskyActorRef as ActorRef, 5 5 AppBskyFeedPost, 6 6 AppBskyFeedRepost, 7 - AppBskyFeedTrend, 8 7 AppBskyFeedVote, 9 8 AppBskyGraphAssertion, 10 9 AppBskyGraphFollow, 11 - APP_BSKY_GRAPH, 12 10 } from '@atproto/api' 13 11 import {RootStoreModel} from './root-store' 14 12 import {PostThreadViewModel} from './post-thread-view' 15 13 import {cleanError} from '../../lib/strings' 16 14 17 - const UNGROUPABLE_REASONS = ['trend', 'assertion'] 15 + const UNGROUPABLE_REASONS = ['assertion'] 18 16 const PAGE_SIZE = 30 19 17 const MS_60MIN = 1e3 * 60 * 60 20 18 ··· 27 25 type SupportedRecord = 28 26 | AppBskyFeedPost.Record 29 27 | AppBskyFeedRepost.Record 30 - | AppBskyFeedTrend.Record 31 28 | AppBskyFeedVote.Record 32 29 | AppBskyGraphAssertion.Record 33 30 | AppBskyGraphFollow.Record ··· 94 91 return this.reason === 'repost' 95 92 } 96 93 97 - get isTrend() { 98 - return this.reason === 'trend' 99 - } 100 - 101 94 get isMention() { 102 95 return this.reason === 'mention' 103 96 } ··· 115 108 } 116 109 117 110 get needsAdditionalData() { 118 - if ( 119 - this.isUpvote || 120 - this.isRepost || 121 - this.isTrend || 122 - this.isReply || 123 - this.isMention 124 - ) { 111 + if (this.isUpvote || this.isRepost || this.isReply || this.isMention) { 125 112 return !this.additionalPost 126 113 } 127 114 return false 128 115 } 129 116 130 - get isInvite() { 131 - return ( 132 - this.isAssertion && 133 - AppBskyGraphAssertion.isRecord(this.record) && 134 - this.record.assertion === APP_BSKY_GRAPH.AssertMember 135 - ) 136 - } 137 - 138 117 get subjectUri(): string { 139 118 if (this.reasonSubject) { 140 119 return this.reasonSubject ··· 142 121 const record = this.record 143 122 if ( 144 123 AppBskyFeedRepost.isRecord(record) || 145 - AppBskyFeedTrend.isRecord(record) || 146 124 AppBskyFeedVote.isRecord(record) 147 125 ) { 148 126 return record.subject.uri ··· 154 132 for (const ns of [ 155 133 AppBskyFeedPost, 156 134 AppBskyFeedRepost, 157 - AppBskyFeedTrend, 158 135 AppBskyFeedVote, 159 136 AppBskyGraphAssertion, 160 137 AppBskyGraphFollow, ··· 185 162 let postUri 186 163 if (this.isReply || this.isMention) { 187 164 postUri = this.uri 188 - } else if (this.isUpvote || this.isRepost || this.isTrend) { 165 + } else if (this.isUpvote || this.isRepost) { 189 166 postUri = this.subjectUri 190 167 } 191 168 if (postUri) {
+3 -40
src/state/models/profile-ui.ts
··· 1 1 import {makeAutoObservable} from 'mobx' 2 2 import {RootStoreModel} from './root-store' 3 3 import {ProfileViewModel} from './profile-view' 4 - import {MembersViewModel} from './members-view' 5 - import {MembershipsViewModel} from './memberships-view' 6 4 import {FeedModel} from './feed-view' 7 5 8 6 export enum Sections { 9 7 Posts = 'Posts', 10 8 PostsWithReplies = 'Posts & replies', 11 - Scenes = 'Scenes', 12 - Trending = 'Trending', 13 - Members = 'Members', 14 9 } 15 10 16 - const USER_SELECTOR_ITEMS = [ 17 - Sections.Posts, 18 - Sections.PostsWithReplies, 19 - Sections.Scenes, 20 - ] 21 - const SCENE_SELECTOR_ITEMS = [Sections.Trending, Sections.Members] 11 + const USER_SELECTOR_ITEMS = [Sections.Posts, Sections.PostsWithReplies] 22 12 23 13 export interface ProfileUiParams { 24 14 user: string ··· 28 18 // data 29 19 profile: ProfileViewModel 30 20 feed: FeedModel 31 - memberships: MembershipsViewModel 32 - members: MembersViewModel 33 21 34 22 // ui state 35 23 selectedViewIndex = 0 ··· 51 39 author: params.user, 52 40 limit: 10, 53 41 }) 54 - this.memberships = new MembershipsViewModel(rootStore, {actor: params.user}) 55 - this.members = new MembersViewModel(rootStore, {actor: params.user}) 56 42 } 57 43 58 - get currentView(): FeedModel | MembershipsViewModel | MembersViewModel { 44 + get currentView(): FeedModel { 59 45 if ( 60 46 this.selectedView === Sections.Posts || 61 - this.selectedView === Sections.PostsWithReplies || 62 - this.selectedView === Sections.Trending 47 + this.selectedView === Sections.PostsWithReplies 63 48 ) { 64 49 return this.feed 65 50 } 66 - if (this.selectedView === Sections.Scenes) { 67 - return this.memberships 68 - } 69 - if (this.selectedView === Sections.Members) { 70 - return this.members 71 - } 72 51 throw new Error(`Invalid selector value: ${this.selectedViewIndex}`) 73 52 } 74 53 ··· 85 64 return this.profile.isUser 86 65 } 87 66 88 - get isScene() { 89 - return this.profile.isScene 90 - } 91 - 92 67 get selectorItems() { 93 68 if (this.isUser) { 94 69 return USER_SELECTOR_ITEMS 95 - } else if (this.isScene) { 96 - return SCENE_SELECTOR_ITEMS 97 70 } else { 98 71 return USER_SELECTOR_ITEMS 99 72 } ··· 119 92 .setup() 120 93 .catch(err => this.rootStore.log.error('Failed to fetch feed', err)), 121 94 ]) 122 - if (this.isUser) { 123 - await this.memberships 124 - .setup() 125 - .catch(err => this.rootStore.log.error('Failed to fetch members', err)) 126 - } 127 - if (this.isScene) { 128 - await this.members 129 - .setup() 130 - .catch(err => this.rootStore.log.error('Failed to fetch members', err)) 131 - } 132 95 } 133 96 134 97 async update() {
-8
src/state/models/profile-view.ts
··· 13 13 import * as apilib from '../lib/api' 14 14 15 15 export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser' 16 - export const ACTOR_TYPE_SCENE = 'app.bsky.system.actorScene' 17 16 18 17 export class ProfileViewMyStateModel { 19 18 follow?: string 20 - member?: string 21 19 muted?: boolean 22 20 23 21 constructor() { ··· 47 45 banner?: string 48 46 followersCount: number = 0 49 47 followsCount: number = 0 50 - membersCount: number = 0 51 48 postsCount: number = 0 52 49 myState = new ProfileViewMyStateModel() 53 50 ··· 83 80 84 81 get isUser() { 85 82 return this.declaration.actorType === ACTOR_TYPE_USER 86 - } 87 - 88 - get isScene() { 89 - return this.declaration.actorType === ACTOR_TYPE_SCENE 90 83 } 91 84 92 85 // public api ··· 216 209 this.banner = res.data.banner 217 210 this.followersCount = res.data.followersCount 218 211 this.followsCount = res.data.followsCount 219 - this.membersCount = res.data.membersCount 220 212 this.postsCount = res.data.postsCount 221 213 if (res.data.myState) { 222 214 Object.assign(this.myState, res.data.myState)
-18
src/state/models/shell-ui.ts
··· 25 25 } 26 26 } 27 27 28 - export class CreateSceneModal { 29 - name = 'create-scene' 30 - 31 - constructor() { 32 - makeAutoObservable(this) 33 - } 34 - } 35 - 36 - export class InviteToSceneModal { 37 - name = 'invite-to-scene' 38 - 39 - constructor(public profileView: ProfileViewModel) { 40 - makeAutoObservable(this) 41 - } 42 - } 43 - 44 28 export class ServerInputModal { 45 29 name = 'server-input' 46 30 ··· 143 127 activeModal: 144 128 | ConfirmModal 145 129 | EditProfileModal 146 - | CreateSceneModal 147 130 | ServerInputModal 148 131 | ReportPostModal 149 132 | ReportAccountModal ··· 191 174 modal: 192 175 | ConfirmModal 193 176 | EditProfileModal 194 - | CreateSceneModal 195 177 | ServerInputModal 196 178 | ReportPostModal 197 179 | ReportAccountModal,
-142
src/state/models/suggested-invites-view.ts
··· 1 - import {makeAutoObservable, runInAction} from 'mobx' 2 - import {RootStoreModel} from './root-store' 3 - import {UserFollowsViewModel, FollowItem} from './user-follows-view' 4 - import {GetAssertionsView} from './get-assertions-view' 5 - import {APP_BSKY_SYSTEM, APP_BSKY_GRAPH} from '@atproto/api' 6 - 7 - export interface SuggestedInvitesViewParams { 8 - sceneDid: string 9 - } 10 - 11 - export class SuggestedInvitesView { 12 - // state 13 - isLoading = false 14 - isRefreshing = false 15 - hasLoaded = false 16 - error = '' 17 - params: SuggestedInvitesViewParams 18 - sceneAssertionsView: GetAssertionsView 19 - myFollowsView: UserFollowsViewModel 20 - 21 - // data 22 - suggestions: FollowItem[] = [] 23 - 24 - constructor( 25 - public rootStore: RootStoreModel, 26 - params: SuggestedInvitesViewParams, 27 - ) { 28 - makeAutoObservable( 29 - this, 30 - { 31 - rootStore: false, 32 - params: false, 33 - }, 34 - {autoBind: true}, 35 - ) 36 - this.params = params 37 - this.sceneAssertionsView = new GetAssertionsView(rootStore, { 38 - author: params.sceneDid, 39 - assertion: APP_BSKY_GRAPH.AssertMember, 40 - }) 41 - this.myFollowsView = new UserFollowsViewModel(rootStore, { 42 - user: rootStore.me.did || '', 43 - }) 44 - } 45 - 46 - get hasContent() { 47 - return this.suggestions.length > 0 48 - } 49 - 50 - get hasError() { 51 - return this.error !== '' 52 - } 53 - 54 - get isEmpty() { 55 - return this.hasLoaded && !this.hasContent 56 - } 57 - 58 - get unconfirmed() { 59 - return this.sceneAssertionsView.unconfirmed 60 - } 61 - 62 - // public api 63 - // = 64 - 65 - async setup() { 66 - await this._fetch(false) 67 - } 68 - 69 - async refresh() { 70 - await this._fetch(true) 71 - } 72 - 73 - async loadMore() { 74 - // TODO 75 - } 76 - 77 - // state transitions 78 - // = 79 - 80 - private _xLoading(isRefreshing = false) { 81 - this.isLoading = true 82 - this.isRefreshing = isRefreshing 83 - this.error = '' 84 - } 85 - 86 - private _xIdle(err?: any) { 87 - this.isLoading = false 88 - this.isRefreshing = false 89 - this.hasLoaded = true 90 - this.error = err ? err.toString() : '' 91 - if (err) { 92 - this.rootStore.log.error('Failed to fetch suggested invites', err) 93 - } 94 - } 95 - 96 - // loader functions 97 - // = 98 - 99 - private async _fetch(isRefreshing = false) { 100 - this._xLoading(isRefreshing) 101 - try { 102 - // TODO need to fetch all! 103 - await this.sceneAssertionsView.setup() 104 - } catch (e: any) { 105 - this.rootStore.log.error( 106 - 'Failed to fetch current scene members in suggested invites', 107 - e, 108 - ) 109 - this._xIdle( 110 - 'Failed to fetch the current scene members. Check your internet connection and try again.', 111 - ) 112 - return 113 - } 114 - try { 115 - await this.myFollowsView.setup() 116 - } catch (e: any) { 117 - this.rootStore.log.error( 118 - 'Failed to fetch current followers in suggested invites', 119 - e, 120 - ) 121 - this._xIdle( 122 - 'Failed to fetch the your current followers. Check your internet connection and try again.', 123 - ) 124 - return 125 - } 126 - 127 - // collect all followed users that arent already in the scene 128 - const newSuggestions: FollowItem[] = [] 129 - for (const follow of this.myFollowsView.follows) { 130 - if (follow.declaration.actorType !== APP_BSKY_SYSTEM.ActorUser) { 131 - continue 132 - } 133 - if (!this.sceneAssertionsView.getBySubject(follow.did)) { 134 - newSuggestions.push(follow) 135 - } 136 - } 137 - runInAction(() => { 138 - this.suggestions = newSuggestions 139 - }) 140 - this._xIdle() 141 - } 142 - }
-243
src/view/com/modals/CreateScene.tsx
··· 1 - import React, {useState} from 'react' 2 - import * as Toast from '../util/Toast' 3 - import { 4 - ActivityIndicator, 5 - StyleSheet, 6 - TouchableOpacity, 7 - View, 8 - } from 'react-native' 9 - import LinearGradient from 'react-native-linear-gradient' 10 - import {BottomSheetScrollView, BottomSheetTextInput} from '@gorhom/bottom-sheet' 11 - import {AppBskyActorCreateScene} from '@atproto/api' 12 - import {ErrorMessage} from '../util/error/ErrorMessage' 13 - import {Text} from '../util/text/Text' 14 - import {useStores} from '../../../state' 15 - import {s, colors, gradients} from '../../lib/styles' 16 - import { 17 - makeValidHandle, 18 - createFullHandle, 19 - enforceLen, 20 - MAX_DISPLAY_NAME, 21 - MAX_DESCRIPTION, 22 - } from '../../../lib/strings' 23 - 24 - export const snapPoints = ['60%'] 25 - 26 - export function Component({}: {}) { 27 - const store = useStores() 28 - const [error, setError] = useState<string>('') 29 - const [isProcessing, setIsProcessing] = useState<boolean>(false) 30 - const [handle, setHandle] = useState<string>('') 31 - const [displayName, setDisplayName] = useState<string>('') 32 - const [description, setDescription] = useState<string>('') 33 - const onPressSave = async () => { 34 - setIsProcessing(true) 35 - if (error) { 36 - setError('') 37 - } 38 - try { 39 - if (!store.me.did) { 40 - return 41 - } 42 - const desc = await store.api.com.atproto.server.getAccountsConfig() 43 - const fullHandle = createFullHandle( 44 - handle, 45 - desc.data.availableUserDomains[0], 46 - ) 47 - // create scene actor 48 - const createSceneRes = await store.api.app.bsky.actor.createScene({ 49 - handle: fullHandle, 50 - }) 51 - // set the scene profile 52 - await store.api.app.bsky.actor 53 - .updateProfile({ 54 - did: createSceneRes.data.did, 55 - displayName, 56 - description, 57 - }) 58 - .catch(e => 59 - // an error here is not critical 60 - store.log.error('Failed to update scene profile during creation', e), 61 - ) 62 - // follow the scene 63 - await store.api.app.bsky.graph.follow 64 - .create( 65 - { 66 - did: store.me.did, 67 - }, 68 - { 69 - subject: { 70 - did: createSceneRes.data.did, 71 - declarationCid: createSceneRes.data.declaration.cid, 72 - }, 73 - createdAt: new Date().toISOString(), 74 - }, 75 - ) 76 - .catch(e => 77 - // an error here is not critical 78 - store.log.error('Failed to follow scene after creation', e), 79 - ) 80 - Toast.show('Scene created') 81 - store.shell.closeModal() 82 - store.nav.navigate(`/profile/${fullHandle}`) 83 - } catch (e: any) { 84 - if (e instanceof AppBskyActorCreateScene.InvalidHandleError) { 85 - setError( 86 - 'The handle can only contain letters, numbers, and dashes, and must start with a letter.', 87 - ) 88 - } else if (e instanceof AppBskyActorCreateScene.HandleNotAvailableError) { 89 - setError(`The handle "${handle}" is not available.`) 90 - } else { 91 - store.log.error('Failed to create scene', e) 92 - setError( 93 - 'Failed to create the scene. Check your internet connection and try again.', 94 - ) 95 - } 96 - setIsProcessing(false) 97 - } 98 - } 99 - const onPressCancel = () => { 100 - store.shell.closeModal() 101 - } 102 - 103 - return ( 104 - <View style={styles.outer}> 105 - <BottomSheetScrollView style={styles.inner}> 106 - <Text style={[styles.title, s.black]}>Create a scene</Text> 107 - <Text style={styles.description}> 108 - Scenes are invite-only groups which aggregate what's popular with 109 - members. 110 - </Text> 111 - <View style={{paddingBottom: 50}}> 112 - <View style={styles.group}> 113 - <Text style={[styles.label, s.black]}>Scene Handle</Text> 114 - <BottomSheetTextInput 115 - style={styles.textInput} 116 - placeholder="e.g. alices-friends" 117 - placeholderTextColor={colors.gray4} 118 - autoCorrect={false} 119 - value={handle} 120 - onChangeText={str => setHandle(makeValidHandle(str))} 121 - /> 122 - </View> 123 - <View style={styles.group}> 124 - <Text style={[styles.label, s.black]}>Scene Display Name</Text> 125 - <BottomSheetTextInput 126 - style={styles.textInput} 127 - placeholder="e.g. Alice's Friends" 128 - placeholderTextColor={colors.gray4} 129 - value={displayName} 130 - onChangeText={v => 131 - setDisplayName(enforceLen(v, MAX_DISPLAY_NAME)) 132 - } 133 - /> 134 - </View> 135 - <View style={styles.group}> 136 - <Text style={[styles.label, s.black]}>Scene Description</Text> 137 - <BottomSheetTextInput 138 - style={[styles.textArea]} 139 - placeholder="e.g. Artists, dog-lovers, and memelords." 140 - placeholderTextColor={colors.gray4} 141 - multiline 142 - value={description} 143 - onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} 144 - /> 145 - </View> 146 - {error !== '' && ( 147 - <View style={s.mb10}> 148 - <ErrorMessage message={error} numberOfLines={3} /> 149 - </View> 150 - )} 151 - {handle.length >= 2 && !isProcessing ? ( 152 - <TouchableOpacity style={s.mt10} onPress={onPressSave}> 153 - <LinearGradient 154 - colors={[gradients.primary.start, gradients.primary.end]} 155 - start={{x: 0, y: 0}} 156 - end={{x: 1, y: 1}} 157 - style={[styles.btn]}> 158 - <Text style={[s.white, s.bold, s.f18]}>Create Scene</Text> 159 - </LinearGradient> 160 - </TouchableOpacity> 161 - ) : ( 162 - <View style={s.mt10}> 163 - <View style={[styles.btn]}> 164 - {isProcessing ? ( 165 - <ActivityIndicator /> 166 - ) : ( 167 - <Text style={[s.gray4, s.bold, s.f18]}>Create Scene</Text> 168 - )} 169 - </View> 170 - </View> 171 - )} 172 - <TouchableOpacity style={s.mt10} onPress={onPressCancel}> 173 - <View style={[styles.btn, {backgroundColor: colors.white}]}> 174 - <Text style={[s.black, s.bold]}>Cancel</Text> 175 - </View> 176 - </TouchableOpacity> 177 - </View> 178 - </BottomSheetScrollView> 179 - </View> 180 - ) 181 - } 182 - 183 - const styles = StyleSheet.create({ 184 - outer: { 185 - flex: 1, 186 - // paddingTop: 20, 187 - }, 188 - title: { 189 - textAlign: 'center', 190 - fontWeight: 'bold', 191 - fontSize: 24, 192 - marginBottom: 12, 193 - }, 194 - description: { 195 - textAlign: 'center', 196 - fontSize: 17, 197 - paddingHorizontal: 22, 198 - color: colors.gray5, 199 - marginBottom: 10, 200 - }, 201 - inner: { 202 - padding: 14, 203 - height: 350, 204 - }, 205 - group: { 206 - marginBottom: 10, 207 - }, 208 - label: { 209 - fontSize: 16, 210 - fontWeight: 'bold', 211 - paddingHorizontal: 4, 212 - paddingBottom: 4, 213 - }, 214 - textInput: { 215 - borderWidth: 1, 216 - borderColor: colors.gray3, 217 - borderRadius: 6, 218 - paddingHorizontal: 14, 219 - paddingVertical: 10, 220 - fontSize: 16, 221 - color: colors.black, 222 - }, 223 - textArea: { 224 - borderWidth: 1, 225 - borderColor: colors.gray3, 226 - borderRadius: 6, 227 - paddingHorizontal: 12, 228 - paddingTop: 10, 229 - fontSize: 16, 230 - color: colors.black, 231 - height: 70, 232 - textAlignVertical: 'top', 233 - }, 234 - btn: { 235 - flexDirection: 'row', 236 - alignItems: 'center', 237 - justifyContent: 'center', 238 - width: '100%', 239 - borderRadius: 32, 240 - padding: 14, 241 - backgroundColor: colors.gray1, 242 - }, 243 - })
-308
src/view/com/modals/InviteToScene.tsx
··· 1 - import React, {useState, useEffect, useMemo} from 'react' 2 - import {observer} from 'mobx-react-lite' 3 - import * as Toast from '../util/Toast' 4 - import { 5 - ActivityIndicator, 6 - FlatList, 7 - StyleSheet, 8 - useWindowDimensions, 9 - View, 10 - } from 'react-native' 11 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 12 - import { 13 - TabView, 14 - SceneMap, 15 - Route, 16 - TabBar, 17 - TabBarProps, 18 - } from 'react-native-tab-view' 19 - import _omit from 'lodash.omit' 20 - import {AtUri} from '../../../third-party/uri' 21 - import {ProfileCard} from '../profile/ProfileCard' 22 - import {ErrorMessage} from '../util/error/ErrorMessage' 23 - import {Text} from '../util/text/Text' 24 - import {useStores} from '../../../state' 25 - import * as apilib from '../../../state/lib/api' 26 - import {ProfileViewModel} from '../../../state/models/profile-view' 27 - import {SuggestedInvitesView} from '../../../state/models/suggested-invites-view' 28 - import {Assertion} from '../../../state/models/get-assertions-view' 29 - import {FollowItem} from '../../../state/models/user-follows-view' 30 - import {s, colors} from '../../lib/styles' 31 - 32 - export const snapPoints = ['70%'] 33 - 34 - export const Component = observer(function Component({ 35 - profileView, 36 - }: { 37 - profileView: ProfileViewModel 38 - }) { 39 - const store = useStores() 40 - const layout = useWindowDimensions() 41 - const [index, setIndex] = useState(0) 42 - const tabRoutes = [ 43 - {key: 'suggestions', title: 'Suggestions'}, 44 - {key: 'pending', title: 'Pending Invites'}, 45 - ] 46 - const [hasSetup, setHasSetup] = useState<boolean>(false) 47 - const [error, setError] = useState<string>('') 48 - const suggestions = useMemo( 49 - () => new SuggestedInvitesView(store, {sceneDid: profileView.did}), 50 - [profileView.did], 51 - ) 52 - const [createdInvites, setCreatedInvites] = useState<Record<string, string>>( 53 - {}, 54 - ) 55 - // TODO: it would be much better if we just used the suggestions view for the deleted pending invites 56 - // but mobx isnt picking up on the state change in suggestions.unconfirmed and I dont have 57 - // time to debug that right now -prf 58 - const [deletedPendingInvites, setDeletedPendingInvites] = useState< 59 - Record<string, boolean> 60 - >({}) 61 - 62 - useEffect(() => { 63 - let aborted = false 64 - if (hasSetup) { 65 - return 66 - } 67 - suggestions.setup().then(() => { 68 - if (aborted) return 69 - setHasSetup(true) 70 - }) 71 - return () => { 72 - aborted = true 73 - } 74 - }, [profileView.did]) 75 - 76 - const onPressInvite = async (follow: FollowItem) => { 77 - setError('') 78 - try { 79 - const assertionUri = await apilib.inviteToScene( 80 - store, 81 - profileView.did, 82 - follow.did, 83 - follow.declaration.cid, 84 - ) 85 - setCreatedInvites({[follow.did]: assertionUri, ...createdInvites}) 86 - Toast.show('Invite sent') 87 - } catch (e: any) { 88 - setError('There was an issue with the invite. Please try again.') 89 - store.log.error('Failed to invite user to scene', e) 90 - } 91 - } 92 - const onPressUndo = async (subjectDid: string, assertionUri: string) => { 93 - setError('') 94 - const urip = new AtUri(assertionUri) 95 - try { 96 - await store.api.app.bsky.graph.assertion.delete({ 97 - did: profileView.did, 98 - rkey: urip.rkey, 99 - }) 100 - setCreatedInvites(_omit(createdInvites, [subjectDid])) 101 - } catch (e: any) { 102 - setError('There was an issue with the invite. Please try again.') 103 - store.log.error('Failed to delete a scene invite', e) 104 - } 105 - } 106 - 107 - const onPressDeleteInvite = async (assertion: Assertion) => { 108 - setError('') 109 - const urip = new AtUri(assertion.uri) 110 - try { 111 - await store.api.app.bsky.graph.assertion.delete({ 112 - did: profileView.did, 113 - rkey: urip.rkey, 114 - }) 115 - setDeletedPendingInvites({ 116 - [assertion.uri]: true, 117 - ...deletedPendingInvites, 118 - }) 119 - Toast.show('Invite removed') 120 - } catch (e: any) { 121 - setError('There was an issue with the invite. Please try again.') 122 - store.log.error('Failed to delete an invite', e) 123 - } 124 - } 125 - 126 - const renderSuggestionItem = ({item}: {item: FollowItem}) => { 127 - const createdInvite = createdInvites[item.did] 128 - return ( 129 - <ProfileCard 130 - did={item.did} 131 - handle={item.handle} 132 - displayName={item.displayName} 133 - avatar={item.avatar} 134 - renderButton={() => 135 - !createdInvite ? ( 136 - <> 137 - <FontAwesomeIcon icon="user-plus" style={[s.mr5]} size={14} /> 138 - <Text style={[s.fw400, s.f14]}>Invite</Text> 139 - </> 140 - ) : ( 141 - <> 142 - <FontAwesomeIcon icon="x" style={[s.mr5]} size={14} /> 143 - <Text style={[s.fw400, s.f14]}>Undo invite</Text> 144 - </> 145 - ) 146 - } 147 - onPressButton={() => 148 - !createdInvite 149 - ? onPressInvite(item) 150 - : onPressUndo(item.did, createdInvite) 151 - } 152 - /> 153 - ) 154 - } 155 - 156 - const renderPendingInviteItem = ({item}: {item: Assertion}) => { 157 - const wasDeleted = deletedPendingInvites[item.uri] 158 - if (wasDeleted) { 159 - return <View /> 160 - } 161 - return ( 162 - <ProfileCard 163 - did={item.subject.did} 164 - handle={item.subject.handle} 165 - displayName={item.subject.displayName} 166 - avatar={item.subject.avatar} 167 - renderButton={() => ( 168 - <> 169 - <FontAwesomeIcon icon="x" style={[s.mr5]} size={14} /> 170 - <Text style={[s.fw400, s.f14]}>Undo invite</Text> 171 - </> 172 - )} 173 - onPressButton={() => onPressDeleteInvite(item)} 174 - /> 175 - ) 176 - } 177 - 178 - const Suggestions = () => ( 179 - <View style={s.flex1}> 180 - {hasSetup ? ( 181 - <View style={s.flex1}> 182 - <View style={styles.todoContainer}> 183 - <Text style={styles.todoLabel}> 184 - User search is still being implemented. For now, you can pick from 185 - your follows below. 186 - </Text> 187 - </View> 188 - {!suggestions.hasContent ? ( 189 - <Text 190 - style={{ 191 - textAlign: 'center', 192 - paddingTop: 10, 193 - paddingHorizontal: 40, 194 - fontWeight: 'bold', 195 - color: colors.gray5, 196 - }}> 197 - {suggestions.myFollowsView.follows.length 198 - ? 'Sorry! You dont follow anybody for us to suggest.' 199 - : 'Sorry! All of the users you follow are members already.'} 200 - </Text> 201 - ) : ( 202 - <FlatList 203 - data={suggestions.suggestions} 204 - keyExtractor={item => item._reactKey} 205 - renderItem={renderSuggestionItem} 206 - style={s.flex1} 207 - /> 208 - )} 209 - </View> 210 - ) : !error ? ( 211 - <ActivityIndicator /> 212 - ) : undefined} 213 - </View> 214 - ) 215 - 216 - const PendingInvites = () => ( 217 - <View style={s.flex1}> 218 - {suggestions.sceneAssertionsView.isLoading ? ( 219 - <ActivityIndicator /> 220 - ) : undefined} 221 - <View style={s.flex1}> 222 - {!suggestions.unconfirmed.length ? ( 223 - <Text 224 - style={{ 225 - textAlign: 'center', 226 - paddingTop: 10, 227 - paddingHorizontal: 40, 228 - fontWeight: 'bold', 229 - color: colors.gray5, 230 - }}> 231 - No pending invites. 232 - </Text> 233 - ) : ( 234 - <FlatList 235 - data={suggestions.unconfirmed} 236 - keyExtractor={item => item._reactKey} 237 - renderItem={renderPendingInviteItem} 238 - style={s.flex1} 239 - /> 240 - )} 241 - </View> 242 - </View> 243 - ) 244 - 245 - const renderScene = SceneMap({ 246 - suggestions: Suggestions, 247 - pending: PendingInvites, 248 - }) 249 - 250 - const renderTabBar = (props: TabBarProps<Route>) => ( 251 - <TabBar 252 - {...props} 253 - style={{backgroundColor: 'white'}} 254 - activeColor="black" 255 - inactiveColor={colors.gray5} 256 - labelStyle={{textTransform: 'none'}} 257 - indicatorStyle={{backgroundColor: colors.purple3}} 258 - /> 259 - ) 260 - 261 - return ( 262 - <View style={s.flex1}> 263 - <Text style={styles.title}> 264 - Invite to {profileView.displayName || profileView.handle} 265 - </Text> 266 - {error !== '' ? ( 267 - <View style={s.p10}> 268 - <ErrorMessage message={error} /> 269 - </View> 270 - ) : undefined} 271 - <TabView 272 - navigationState={{index, routes: tabRoutes}} 273 - renderScene={renderScene} 274 - renderTabBar={renderTabBar} 275 - onIndexChange={setIndex} 276 - initialLayout={{width: layout.width}} 277 - /> 278 - </View> 279 - ) 280 - }) 281 - 282 - const styles = StyleSheet.create({ 283 - title: { 284 - textAlign: 'center', 285 - fontWeight: 'bold', 286 - fontSize: 18, 287 - marginBottom: 4, 288 - }, 289 - todoContainer: { 290 - backgroundColor: colors.pink1, 291 - margin: 10, 292 - padding: 10, 293 - borderRadius: 6, 294 - }, 295 - todoLabel: { 296 - color: colors.pink5, 297 - textAlign: 'center', 298 - }, 299 - 300 - tabBar: { 301 - flexDirection: 'row', 302 - }, 303 - tabItem: { 304 - alignItems: 'center', 305 - padding: 16, 306 - flex: 1, 307 - }, 308 - })
-12
src/view/com/modals/Modal.tsx
··· 9 9 10 10 import * as ConfirmModal from './Confirm' 11 11 import * as EditProfileModal from './EditProfile' 12 - import * as CreateSceneModal from './CreateScene' 13 - import * as InviteToSceneModal from './InviteToScene' 14 12 import * as ServerInputModal from './ServerInput' 15 13 import * as ReportPostModal from './ReportPost' 16 14 import * as ReportAccountModal from './ReportAccount' ··· 53 51 element = ( 54 52 <EditProfileModal.Component 55 53 {...(store.shell.activeModal as models.EditProfileModal)} 56 - /> 57 - ) 58 - } else if (store.shell.activeModal?.name === 'create-scene') { 59 - snapPoints = CreateSceneModal.snapPoints 60 - element = <CreateSceneModal.Component /> 61 - } else if (store.shell.activeModal?.name === 'invite-to-scene') { 62 - snapPoints = InviteToSceneModal.snapPoints 63 - element = ( 64 - <InviteToSceneModal.Component 65 - {...(store.shell.activeModal as models.InviteToSceneModal)} 66 54 /> 67 55 ) 68 56 } else if (store.shell.activeModal?.name === 'server-input') {
+2 -22
src/view/com/notifications/FeedItem.tsx
··· 14 14 import {ErrorMessage} from '../util/error/ErrorMessage' 15 15 import {Post} from '../post/Post' 16 16 import {Link} from '../util/Link' 17 - import {InviteAccepter} from './InviteAccepter' 18 17 import {usePalette} from '../../lib/hooks/usePalette' 19 18 20 19 const MAX_AUTHORS = 8 ··· 26 25 }) { 27 26 const pal = usePalette('default') 28 27 const itemHref = useMemo(() => { 29 - if (item.isUpvote || item.isRepost || item.isTrend) { 28 + if (item.isUpvote || item.isRepost) { 30 29 const urip = new AtUri(item.subjectUri) 31 30 return `/profile/${urip.host}/post/${urip.rkey}` 32 31 } else if (item.isFollow || item.isAssertion) { ··· 82 81 action = 'reposted your post' 83 82 icon = 'retweet' 84 83 iconStyle = [s.green3] 85 - } else if (item.isTrend) { 86 - action = 'Your post is trending with' 87 - icon = 'arrow-trend-up' 88 - iconStyle = [s.red3] 89 84 } else if (item.isReply) { 90 85 action = 'replied to your post' 91 86 icon = ['far', 'comment'] ··· 93 88 action = 'followed you' 94 89 icon = 'user-plus' 95 90 iconStyle = [s.blue3] 96 - } else if (item.isInvite) { 97 - icon = 'users' 98 - iconStyle = [s.blue3] 99 - action = 'invited you to join their scene' 100 91 } else { 101 92 return <></> 102 93 } ··· 173 164 ) : undefined} 174 165 </View> 175 166 <View style={styles.meta}> 176 - {item.isTrend && ( 177 - <Text style={[styles.metaItem, pal.text]}>{action}</Text> 178 - )} 179 167 <Link 180 168 key={authors[0].href} 181 169 style={styles.metaItem} ··· 193 181 </Text> 194 182 </> 195 183 ) : undefined} 196 - {!item.isTrend && ( 197 - <Text style={[styles.metaItem, pal.text]}>{action}</Text> 198 - )} 199 184 <Text style={[styles.metaItem, pal.textLight]}> 200 185 {ago(item.indexedAt)} 201 186 </Text> 202 187 </View> 203 - {item.isUpvote || item.isRepost || item.isTrend ? ( 188 + {item.isUpvote || item.isRepost ? ( 204 189 <AdditionalPostText additionalPost={item.additionalPost} /> 205 190 ) : ( 206 191 <></> 207 192 )} 208 193 </View> 209 194 </View> 210 - {item.isInvite && ( 211 - <View style={styles.addedContainer}> 212 - <InviteAccepter item={item} /> 213 - </View> 214 - )} 215 195 </Link> 216 196 ) 217 197 })
-96
src/view/com/notifications/InviteAccepter.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet, TouchableOpacity, View} from 'react-native' 3 - import LinearGradient from 'react-native-linear-gradient' 4 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 - import * as apilib from '../../../state/lib/api' 6 - import {NotificationsViewItemModel} from '../../../state/models/notifications-view' 7 - import {ConfirmModal} from '../../../state/models/shell-ui' 8 - import {useStores} from '../../../state' 9 - import {ProfileCard} from '../profile/ProfileCard' 10 - import * as Toast from '../util/Toast' 11 - import {Text} from '../util/text/Text' 12 - import {s, colors, gradients} from '../../lib/styles' 13 - 14 - export function InviteAccepter({item}: {item: NotificationsViewItemModel}) { 15 - const store = useStores() 16 - // Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment 17 - const [confirmationUri, setConfirmationUri] = React.useState<string>('') 18 - const isMember = 19 - confirmationUri !== '' || store.me.memberships?.isMemberOf(item.author.did) 20 - const onPressAccept = async () => { 21 - store.shell.openModal( 22 - new ConfirmModal( 23 - 'Join this scene?', 24 - () => ( 25 - <View> 26 - <View style={styles.profileCardContainer}> 27 - <ProfileCard 28 - did={item.author.did} 29 - handle={item.author.handle} 30 - displayName={item.author.displayName} 31 - avatar={item.author.avatar} 32 - /> 33 - </View> 34 - </View> 35 - ), 36 - onPressConfirmAccept, 37 - ), 38 - ) 39 - } 40 - const onPressConfirmAccept = async () => { 41 - const uri = await apilib.acceptSceneInvite(store, { 42 - originator: { 43 - did: item.author.did, 44 - declarationCid: item.author.declaration.cid, 45 - }, 46 - assertion: { 47 - uri: item.uri, 48 - cid: item.cid, 49 - }, 50 - }) 51 - store.me.refreshMemberships() 52 - Toast.show('Invite accepted') 53 - setConfirmationUri(uri) 54 - } 55 - return ( 56 - <View style={styles.container}> 57 - {!isMember ? ( 58 - <TouchableOpacity testID="acceptInviteButton" onPress={onPressAccept}> 59 - <LinearGradient 60 - colors={[gradients.primary.start, gradients.primary.end]} 61 - start={{x: 0, y: 0}} 62 - end={{x: 1, y: 1}} 63 - style={[styles.btn]}> 64 - <Text style={[s.white, s.bold, s.f16]}>Accept Invite</Text> 65 - </LinearGradient> 66 - </TouchableOpacity> 67 - ) : ( 68 - <View testID="inviteAccepted" style={styles.inviteAccepted}> 69 - <FontAwesomeIcon icon="check" size={14} style={s.mr5} /> 70 - <Text style={[s.gray5, s.f15]}>Invite accepted</Text> 71 - </View> 72 - )} 73 - </View> 74 - ) 75 - } 76 - 77 - const styles = StyleSheet.create({ 78 - container: { 79 - flexDirection: 'row', 80 - }, 81 - btn: { 82 - borderRadius: 32, 83 - paddingHorizontal: 18, 84 - paddingVertical: 8, 85 - backgroundColor: colors.gray1, 86 - }, 87 - profileCardContainer: { 88 - borderWidth: 1, 89 - borderColor: colors.gray3, 90 - borderRadius: 6, 91 - }, 92 - inviteAccepted: { 93 - flexDirection: 'row', 94 - alignItems: 'center', 95 - }, 96 - })
+2 -23
src/view/com/onboard/FeatureExplainer.tsx
··· 11 11 import {TabView, SceneMap, Route, TabBarProps} from 'react-native-tab-view' 12 12 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 13 13 import {Text} from '../util/text/Text' 14 - import {UserGroupIcon} from '../../lib/icons' 15 14 import {useStores} from '../../../state' 16 15 import {s} from '../../lib/styles' 17 - import {SCENE_EXPLAINER, TABS_EXPLAINER} from '../../lib/assets' 16 + import {TABS_EXPLAINER} from '../../lib/assets' 18 17 import {TABS_ENABLED} from '../../../build-flags' 19 18 20 19 const Intro = () => ( ··· 28 27 Welcome to <Text style={[s.bold, s.blue3, {fontSize: 56}]}>Bluesky</Text> 29 28 </Text> 30 29 <Text style={[styles.explainerDesc, {fontSize: 24}]}> 31 - Let's do a quick tour through the new features. 32 - </Text> 33 - </View> 34 - ) 35 - 36 - const Scenes = () => ( 37 - <View style={styles.explainer}> 38 - <View style={styles.explainerIcon}> 39 - <View style={s.flex1} /> 40 - <UserGroupIcon style={s.black} size="48" /> 41 - <View style={s.flex1} /> 42 - </View> 43 - <Text style={styles.explainerHeading}>Scenes</Text> 44 - <Text style={styles.explainerDesc}> 45 - Scenes are invite-only groups of users. Follow them to see what's trending 46 - with the scene's members. 47 - </Text> 48 - <Text style={styles.explainerDesc}> 49 - <Image source={SCENE_EXPLAINER} style={styles.explainerImg} /> 30 + This is an early beta. Your feedback is appreciated! 50 31 </Text> 51 32 </View> 52 33 ) ··· 74 55 75 56 const SCENE_MAP = { 76 57 intro: Intro, 77 - scenes: Scenes, 78 58 tabs: Tabs, 79 59 } 80 60 const renderScene = SceneMap(SCENE_MAP) ··· 85 65 const [index, setIndex] = useState(0) 86 66 const routes = [ 87 67 {key: 'intro', title: 'Intro'}, 88 - {key: 'scenes', title: 'Scenes'}, 89 68 TABS_ENABLED ? {key: 'tabs', title: 'Tabs'} : undefined, 90 69 ].filter(Boolean) 91 70
-17
src/view/com/posts/FeedItem.tsx
··· 155 155 </Text> 156 156 </Link> 157 157 )} 158 - {item.reasonTrend && ( 159 - <Link 160 - style={styles.includeReason} 161 - href={`/profile/${item.reasonTrend.by.handle}`} 162 - title={ 163 - item.reasonTrend.by.displayName || item.reasonTrend.by.handle 164 - }> 165 - <FontAwesomeIcon 166 - icon="arrow-trend-up" 167 - style={styles.includeReasonIcon} 168 - /> 169 - <Text type="overline2" style={{color: pal.colors.actionLabel}}> 170 - Trending with{' '} 171 - {item.reasonTrend.by.displayName || item.reasonTrend.by.handle} 172 - </Text> 173 - </Link> 174 - )} 175 158 <View style={styles.layout}> 176 159 <View style={styles.layoutAvi}> 177 160 <Link href={authorHref} title={item.post.author.handle}>
+1 -142
src/view/com/profile/ProfileHeader.tsx
··· 1 - import React, {useMemo} from 'react' 1 + import React from 'react' 2 2 import {observer} from 'mobx-react-lite' 3 3 import {StyleSheet, TouchableOpacity, View} from 'react-native' 4 4 import LinearGradient from 'react-native-linear-gradient' 5 5 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 6 - import {AtUri} from '../../../third-party/uri' 7 6 import {ProfileViewModel} from '../../../state/models/profile-view' 8 7 import {useStores} from '../../../state' 9 8 import { 10 - ConfirmModal, 11 9 EditProfileModal, 12 - InviteToSceneModal, 13 10 ReportAccountModal, 14 11 ProfileImageLightbox, 15 12 } from '../../../state/models/shell-ui' ··· 23 20 import {RichText} from '../util/text/RichText' 24 21 import {UserAvatar} from '../util/UserAvatar' 25 22 import {UserBanner} from '../util/UserBanner' 26 - import {UserInfoText} from '../util/UserInfoText' 27 23 import {usePalette} from '../../lib/hooks/usePalette' 28 24 29 25 export const ProfileHeader = observer(function ProfileHeader({ ··· 35 31 }) { 36 32 const pal = usePalette('default') 37 33 const store = useStores() 38 - const isMember = useMemo( 39 - () => view.isScene && view.myState.member, 40 - [view.myState.member], 41 - ) 42 34 43 35 const onPressAvi = () => { 44 36 store.shell.openLightbox(new ProfileImageLightbox(view)) ··· 64 56 const onPressFollows = () => { 65 57 store.nav.navigate(`/profile/${view.handle}/follows`) 66 58 } 67 - const onPressMembers = () => { 68 - store.nav.navigate(`/profile/${view.handle}/members`) 69 - } 70 - const onPressInviteMembers = () => { 71 - store.shell.openModal(new InviteToSceneModal(view)) 72 - } 73 - const onPressLeaveScene = () => { 74 - store.shell.openModal( 75 - new ConfirmModal( 76 - 'Leave this scene?', 77 - `You'll be able to come back unless your invite is revoked.`, 78 - onPressConfirmLeaveScene, 79 - ), 80 - ) 81 - } 82 - const onPressConfirmLeaveScene = async () => { 83 - if (view.myState.member) { 84 - await store.api.app.bsky.graph.confirmation.delete({ 85 - did: store.me.did || '', 86 - rkey: new AtUri(view.myState.member).rkey, 87 - }) 88 - Toast.show(`Scene left`) 89 - } 90 - onRefreshAll() 91 - } 92 59 const onPressMuteAccount = async () => { 93 60 try { 94 61 await view.muteAccount() ··· 157 124 // = 158 125 const gradient = getGradient(view.handle) 159 126 const isMe = store.me.did === view.did 160 - const isCreator = view.isScene && view.creator === store.me.did 161 127 let dropdownItems: DropdownItem[] | undefined 162 128 if (!isMe) { 163 129 dropdownItems = dropdownItems || [] ··· 170 136 onPress: onPressReportAccount, 171 137 }) 172 138 } 173 - if (isCreator || isMember) { 174 - dropdownItems = dropdownItems || [] 175 - if (isCreator) { 176 - dropdownItems.push({ 177 - label: 'Edit Profile', 178 - onPress: onPressEditProfile, 179 - }) 180 - } 181 - if (isMember) { 182 - dropdownItems.push({ 183 - label: 'Leave Scene...', 184 - onPress: onPressLeaveScene, 185 - }) 186 - } 187 - } 188 139 return ( 189 140 <View style={pal.view}> 190 141 <UserBanner handle={view.handle} banner={view.banner} /> ··· 247 198 </Text> 248 199 </View> 249 200 <View style={styles.handleLine}> 250 - {view.isScene ? ( 251 - <View 252 - style={[ 253 - styles.typeLabelWrapper, 254 - {backgroundColor: pal.colors.backgroundLight}, 255 - ]}> 256 - <Text style={[styles.typeLabel, pal.textLight]}>Scene</Text> 257 - </View> 258 - ) : undefined} 259 201 <Text style={pal.textLight}>@{view.handle}</Text> 260 202 </View> 261 203 <View style={styles.metricsLine}> ··· 283 225 </Text> 284 226 </TouchableOpacity> 285 227 ) : undefined} 286 - {view.isScene ? ( 287 - <TouchableOpacity 288 - testID="profileHeaderMembersButton" 289 - style={[s.flexRow, s.mr10]} 290 - onPress={onPressMembers}> 291 - <Text type="body2" style={[s.bold, s.mr2, pal.text]}> 292 - {view.membersCount} 293 - </Text> 294 - <Text type="body2" style={[pal.textLight]}> 295 - {pluralize(view.membersCount, 'member')} 296 - </Text> 297 - </TouchableOpacity> 298 - ) : undefined} 299 228 <View style={[s.flexRow, s.mr10]}> 300 229 <Text type="body2" style={[s.bold, s.mr2, pal.text]}> 301 230 {view.postsCount} ··· 313 242 entities={view.descriptionEntities} 314 243 /> 315 244 ) : undefined} 316 - {view.isScene && view.creator ? ( 317 - <View style={styles.detailLine}> 318 - <FontAwesomeIcon 319 - icon={['far', 'user']} 320 - style={[pal.textLight, s.mr5]} 321 - /> 322 - <Text type="body2" style={[s.mr2, pal.textLight]}> 323 - Created by 324 - </Text> 325 - <UserInfoText 326 - type="body2" 327 - style={[pal.link]} 328 - did={view.creator} 329 - prefix="@" 330 - asLink 331 - /> 332 - </View> 333 - ) : undefined} 334 - {view.isScene && view.myState.member ? ( 335 - <View style={styles.detailLine}> 336 - <FontAwesomeIcon 337 - icon={['far', 'circle-check']} 338 - style={[pal.textLight, s.mr5]} 339 - /> 340 - <Text type="body2" style={[s.mr2, pal.textLight]}> 341 - You are a member 342 - </Text> 343 - </View> 344 - ) : undefined} 345 245 {view.myState.muted ? ( 346 246 <View style={[styles.detailLine, pal.btn, s.p5]}> 347 247 <FontAwesomeIcon ··· 354 254 </View> 355 255 ) : undefined} 356 256 </View> 357 - {view.isScene && view.creator === store.me.did ? ( 358 - <View style={[styles.sceneAdminContainer, pal.border]}> 359 - <TouchableOpacity 360 - testID="profileHeaderInviteMembersButton" 361 - onPress={onPressInviteMembers}> 362 - <LinearGradient 363 - colors={[gradient[1], gradient[0]]} 364 - start={{x: 0, y: 0}} 365 - end={{x: 1, y: 1}} 366 - style={[styles.btn, styles.gradientBtn, styles.sceneAdminBtn]}> 367 - <FontAwesomeIcon 368 - icon="user-plus" 369 - style={[s.mr5, s.white]} 370 - size={15} 371 - /> 372 - <Text type="button" style={[s.bold, s.white]}> 373 - Invite Members 374 - </Text> 375 - </LinearGradient> 376 - </TouchableOpacity> 377 - </View> 378 - ) : undefined} 379 257 <TouchableOpacity 380 258 testID="profileHeaderAviButton" 381 259 style={[pal.view, {borderColor: pal.colors.background}, styles.avi]} ··· 444 322 flexDirection: 'row', 445 323 marginBottom: 8, 446 324 }, 447 - typeLabelWrapper: { 448 - paddingHorizontal: 4, 449 - borderRadius: 4, 450 - marginRight: 5, 451 - }, 452 - typeLabel: { 453 - fontSize: 15, 454 - fontWeight: 'bold', 455 - }, 456 325 457 326 metricsLine: { 458 327 flexDirection: 'row', ··· 467 336 flexDirection: 'row', 468 337 alignItems: 'center', 469 338 marginBottom: 5, 470 - }, 471 - 472 - sceneAdminContainer: { 473 - borderTopWidth: 1, 474 - borderBottomWidth: 1, 475 - paddingVertical: 12, 476 - paddingHorizontal: 12, 477 - }, 478 - sceneAdminBtn: { 479 - paddingVertical: 8, 480 339 }, 481 340 })
-80
src/view/com/profile/ProfileMembers.tsx
··· 1 - import React, {useEffect} from 'react' 2 - import {observer} from 'mobx-react-lite' 3 - import {ActivityIndicator, FlatList, View} from 'react-native' 4 - import {MembersViewModel, MemberItem} from '../../../state/models/members-view' 5 - import {ProfileCard} from './ProfileCard' 6 - import {ErrorMessage} from '../util/error/ErrorMessage' 7 - import {useStores} from '../../../state' 8 - 9 - export const ProfileMembers = observer(function ProfileMembers({ 10 - name, 11 - }: { 12 - name: string 13 - }) { 14 - const store = useStores() 15 - // Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment 16 - const [view, setView] = React.useState<MembersViewModel | undefined>() 17 - 18 - useEffect(() => { 19 - if (view?.params.actor === name) { 20 - return // no change needed? or trigger refresh? 21 - } 22 - const newView = new MembersViewModel(store, {actor: name}) 23 - setView(newView) 24 - newView 25 - .setup() 26 - .catch(err => store.log.error('Failed to fetch members', err)) 27 - }, [name, view?.params.actor, store]) 28 - 29 - const onRefresh = () => { 30 - view?.refresh() 31 - } 32 - 33 - // loading 34 - // = 35 - if ( 36 - !view || 37 - (view.isLoading && !view.isRefreshing) || 38 - view.params.actor !== name 39 - ) { 40 - return ( 41 - <View testID="profileMembersActivityIndicatorView"> 42 - <ActivityIndicator /> 43 - </View> 44 - ) 45 - } 46 - 47 - // error 48 - // = 49 - if (view.hasError) { 50 - return ( 51 - <View> 52 - <ErrorMessage 53 - message={view.error} 54 - style={{margin: 6}} 55 - onPressTryAgain={onRefresh} 56 - /> 57 - </View> 58 - ) 59 - } 60 - 61 - // loaded 62 - // = 63 - const renderItem = ({item}: {item: MemberItem}) => ( 64 - <ProfileCard 65 - did={item.did} 66 - handle={item.handle} 67 - displayName={item.displayName} 68 - avatar={item.avatar} 69 - /> 70 - ) 71 - return ( 72 - <View testID="profileMembersFlatList"> 73 - <FlatList 74 - data={view.members} 75 - keyExtractor={item => item._reactKey} 76 - renderItem={renderItem} 77 - /> 78 - </View> 79 - ) 80 - })
-2
src/view/index.ts
··· 12 12 import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket' 13 13 import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare' 14 14 import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate' 15 - import {faArrowTrendUp} from '@fortawesome/free-solid-svg-icons/faArrowTrendUp' 16 15 import {faAt} from '@fortawesome/free-solid-svg-icons/faAt' 17 16 import {faBars} from '@fortawesome/free-solid-svg-icons/faBars' 18 17 import {faBell} from '@fortawesome/free-solid-svg-icons/faBell' ··· 81 80 faArrowUpFromBracket, 82 81 faArrowUpRightFromSquare, 83 82 faArrowsRotate, 84 - faArrowTrendUp, 85 83 faAt, 86 84 faBars, 87 85 faBell,
-1
src/view/lib/assets.native.ts
··· 1 1 import {ImageSourcePropType} from 'react-native' 2 2 3 3 export const DEF_AVATAR: ImageSourcePropType = require('../../../public/img/default-avatar.jpg') 4 - export const SCENE_EXPLAINER: ImageSourcePropType = require('../../../public/img/scene-explainer.jpg') 5 4 export const TABS_EXPLAINER: ImageSourcePropType = require('../../../public/img/tabs-explainer.jpg')
-3
src/view/lib/assets.ts
··· 1 1 import {ImageSourcePropType} from 'react-native' 2 2 3 3 export const DEF_AVATAR: ImageSourcePropType = {uri: '/img/default-avatar.jpg'} 4 - export const SCENE_EXPLAINER: ImageSourcePropType = { 5 - uri: '/img/scene-explainer.jpg', 6 - } 7 4 export const TABS_EXPLAINER: ImageSourcePropType = { 8 5 uri: '/img/tabs-explainer.jpg', 9 6 }
-2
src/view/routes.ts
··· 13 13 import {Profile} from './screens/Profile' 14 14 import {ProfileFollowers} from './screens/ProfileFollowers' 15 15 import {ProfileFollows} from './screens/ProfileFollows' 16 - import {ProfileMembers} from './screens/ProfileMembers' 17 16 import {Settings} from './screens/Settings' 18 17 import {Debug} from './screens/Debug' 19 18 import {Log} from './screens/Log' ··· 48 47 r('/profile/(?<name>[^/]+)/followers'), 49 48 ], 50 49 [ProfileFollows, 'Follows', 'users', r('/profile/(?<name>[^/]+)/follows')], 51 - [ProfileMembers, 'Members', 'users', r('/profile/(?<name>[^/]+)/members')], 52 50 [ 53 51 PostThread, 54 52 'Post',
-1
src/view/screens/Notifications.tsx
··· 15 15 return 16 16 } 17 17 store.log.debug('Updating notifications feed') 18 - store.me.refreshMemberships() // needed for the invite notifications 19 18 store.me.notifications 20 19 .update() 21 20 .catch(e => {
+4 -92
src/view/screens/Profile.tsx
··· 1 1 import React, {useEffect, useState} from 'react' 2 2 import {ActivityIndicator, StyleSheet, View} from 'react-native' 3 3 import {observer} from 'mobx-react-lite' 4 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 4 import {ViewSelector} from '../com/util/ViewSelector' 6 5 import {ScreenParams} from '../routes' 7 6 import {ProfileUiModel, Sections} from '../../state/models/profile-ui' 8 - import {MembershipItem} from '../../state/models/memberships-view' 9 7 import {useStores} from '../../state' 10 8 import {ConfirmModal} from '../../state/models/shell-ui' 11 9 import {ProfileHeader} from '../com/profile/ProfileHeader' 12 10 import {FeedItem} from '../com/posts/FeedItem' 13 - import {ProfileCard} from '../com/profile/ProfileCard' 14 11 import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder' 15 12 import {ErrorScreen} from '../com/util/error/ErrorScreen' 16 13 import {ErrorMessage} from '../com/util/error/ErrorMessage' ··· 77 74 const onPressTryAgain = () => { 78 75 uiState.setup() 79 76 } 80 - const onPressRemoveMember = (membership: MembershipItem) => { 81 - store.shell.openModal( 82 - new ConfirmModal( 83 - `Remove ${membership.displayName || membership.handle}?`, 84 - `You'll be able to invite them again if you change your mind.`, 85 - async () => { 86 - await uiState.members.removeMember(membership.did) 87 - Toast.show(`User removed`) 88 - }, 89 - ), 90 - ) 91 - } 92 77 93 78 const onPressCompose = () => { 94 79 store.shell.openComposer({}) ··· 96 81 97 82 // rendering 98 83 // = 99 - 100 - const isSceneCreator = 101 - uiState.isScene && store.me.did === uiState.profile.creator 102 84 103 85 const renderHeader = () => { 104 86 if (!uiState) { ··· 131 113 } else { 132 114 if ( 133 115 uiState.selectedView === Sections.Posts || 134 - uiState.selectedView === Sections.PostsWithReplies || 135 - uiState.selectedView === Sections.Trending 116 + uiState.selectedView === Sections.PostsWithReplies 136 117 ) { 137 118 if (uiState.feed.hasContent) { 138 119 if (uiState.selectedView === Sections.Posts) { ··· 153 134 } 154 135 } else if (uiState.feed.isEmpty) { 155 136 items = items.concat([EMPTY_ITEM]) 156 - if (uiState.profile.isScene) { 157 - renderItem = () => ( 158 - <EmptyState 159 - icon="user-group" 160 - message="As members upvote posts, they will trend here. Follow the scene to see its trending posts in your timeline." 161 - /> 162 - ) 163 - } else { 164 - renderItem = () => ( 165 - <EmptyState 166 - icon={['far', 'message']} 167 - message="No posts yet!" 168 - style={{paddingVertical: 40}} 169 - /> 170 - ) 171 - } 172 - } 173 - } else if (uiState.selectedView === Sections.Scenes) { 174 - if (uiState.memberships.hasContent) { 175 - items = uiState.memberships.memberships.slice() 176 - renderItem = (item: any) => { 177 - return ( 178 - <ProfileCard 179 - did={item.did} 180 - handle={item.handle} 181 - displayName={item.displayName} 182 - avatar={item.avatar} 183 - /> 184 - ) 185 - } 186 - } else if (uiState.memberships.isEmpty) { 187 - items = items.concat([EMPTY_ITEM]) 188 137 renderItem = () => ( 189 138 <EmptyState 190 - icon="user-group" 191 - message="This user hasn't joined any scenes." 192 - /> 193 - ) 194 - } 195 - } else if (uiState.selectedView === Sections.Members) { 196 - if (uiState.members.hasContent) { 197 - items = uiState.members.members.slice() 198 - renderItem = (item: any) => { 199 - const shouldAdmin = isSceneCreator && item.did !== store.me.did 200 - const renderButton = shouldAdmin 201 - ? () => ( 202 - <> 203 - <FontAwesomeIcon 204 - testID="shouldAdminButton" 205 - icon="user-xmark" 206 - style={[s.mr5]} 207 - size={14} 208 - /> 209 - <Text style={[s.fw400, s.f14]}>Remove</Text> 210 - </> 211 - ) 212 - : undefined 213 - return ( 214 - <ProfileCard 215 - did={item.did} 216 - handle={item.handle} 217 - displayName={item.displayName} 218 - avatar={item.avatar} 219 - renderButton={renderButton} 220 - onPressButton={() => onPressRemoveMember(item)} 221 - /> 222 - ) 223 - } 224 - } else if (uiState.members.isEmpty) { 225 - items = items.concat([EMPTY_ITEM]) 226 - renderItem = () => ( 227 - <EmptyState 228 - icon="user-group" 229 - message="This scene doesn't have any members." 139 + icon={['far', 'message']} 140 + message="No posts yet!" 141 + style={{paddingVertical: 40}} 230 142 /> 231 143 ) 232 144 }
-25
src/view/screens/ProfileMembers.tsx
··· 1 - import React, {useEffect} from 'react' 2 - import {View} from 'react-native' 3 - import {ViewHeader} from '../com/util/ViewHeader' 4 - import {ProfileMembers as ProfileMembersComponent} from '../com/profile/ProfileMembers' 5 - import {ScreenParams} from '../routes' 6 - import {useStores} from '../../state' 7 - 8 - export const ProfileMembers = ({navIdx, visible, params}: ScreenParams) => { 9 - const store = useStores() 10 - const {name} = params 11 - 12 - useEffect(() => { 13 - if (visible) { 14 - store.nav.setTitle(navIdx, `Members of ${name}`) 15 - store.shell.setMinimalShellMode(false) 16 - } 17 - }, [store, visible, name]) 18 - 19 - return ( 20 - <View> 21 - <ViewHeader title="Members" subtitle={`of ${name}`} /> 22 - <ProfileMembersComponent name={name} /> 23 - </View> 24 - ) 25 - }
+2 -55
src/view/shell/mobile/Menu.tsx
··· 1 - import React, {useEffect} from 'react' 1 + import React from 'react' 2 2 import { 3 3 ScrollView, 4 4 StyleProp, ··· 11 11 import VersionNumber from 'react-native-version-number' 12 12 import {s, colors} from '../../lib/styles' 13 13 import {useStores} from '../../../state' 14 - import { 15 - HomeIcon, 16 - UserGroupIcon, 17 - BellIcon, 18 - CogIcon, 19 - MagnifyingGlassIcon, 20 - } from '../../lib/icons' 14 + import {HomeIcon, BellIcon, CogIcon, MagnifyingGlassIcon} from '../../lib/icons' 21 15 import {UserAvatar} from '../../com/util/UserAvatar' 22 16 import {Text} from '../../com/util/text/Text' 23 17 import {ToggleButton} from '../../com/util/forms/ToggleButton' 24 - import {CreateSceneModal} from '../../../state/models/shell-ui' 25 18 import {usePalette} from '../../lib/hooks/usePalette' 26 19 27 20 export const Menu = observer( ··· 29 22 const pal = usePalette('default') 30 23 const store = useStores() 31 24 32 - useEffect(() => { 33 - if (visible) { 34 - // trigger a refresh in case memberships have changed recently 35 - // TODO this impacts performance, need to find the right time to do this 36 - // store.me.refreshMemberships() 37 - } 38 - }, [store, visible]) 39 - 40 25 // events 41 26 // = 42 27 ··· 50 35 store.nav.navigate(url) 51 36 } 52 37 } 53 - } 54 - const onPressCreateScene = () => { 55 - onClose() 56 - store.shell.openModal(new CreateSceneModal()) 57 38 } 58 39 59 40 // rendering ··· 151 132 label="Notifications" 152 133 url="/notifications" 153 134 count={store.me.notificationCount} 154 - /> 155 - </View> 156 - <View style={[styles.section, pal.border]}> 157 - <Text type="h5" style={[pal.text, styles.heading]}> 158 - Scenes 159 - </Text> 160 - {store.me.memberships 161 - ? store.me.memberships.memberships.map((membership, i) => ( 162 - <MenuItem 163 - key={i} 164 - icon={ 165 - <UserAvatar 166 - size={34} 167 - displayName={membership.displayName} 168 - handle={membership.handle} 169 - avatar={membership.avatar} 170 - /> 171 - } 172 - label={membership.displayName || membership.handle} 173 - url={`/profile/${membership.handle}`} 174 - /> 175 - )) 176 - : undefined} 177 - </View> 178 - <View style={[styles.section, pal.border]}> 179 - <MenuItem 180 - icon={ 181 - <UserGroupIcon 182 - style={pal.text as StyleProp<ViewStyle>} 183 - size="30" 184 - /> 185 - } 186 - label="Create a scene" 187 - onPress={onPressCreateScene} 188 135 /> 189 136 <MenuItem 190 137 icon={