diff --git a/__mocks__/state-mock.ts b/__mocks__/state-mock.ts index d19cfb4a53..afa44f3843 100644 --- a/__mocks__/state-mock.ts +++ b/__mocks__/state-mock.ts @@ -6,6 +6,7 @@ import {SessionModel} from '../src/state/models/session' import {NavigationModel} from '../src/state/models/navigation' import {ShellUiModel} from '../src/state/models/shell-ui' import {MeModel} from '../src/state/models/me' +import {MyFollowsModel} from '../src/state/models/my-follows' import {OnboardModel} from '../src/state/models/onboard' import {ProfilesViewModel} from '../src/state/models/profiles-view' import {LinkMetasViewModel} from '../src/state/models/link-metas-view' @@ -53,9 +54,8 @@ export const mockedProfileStore = { followsCount: 0, membersCount: 0, postsCount: 0, - myState: { - follow: '', - member: '', + viewer: { + following: '', }, rootStore: {} as RootStoreModel, hasContent: true, @@ -572,6 +572,10 @@ export const mockedShellStore = { openLightbox: jest.fn(), } as ShellUiModel +export const mockedFollowsStore = { + isFollowing: jest.fn().mockReturnValue(false), +} as MyFollowsModel + export const mockedMeStore = { serialize: jest.fn(), hydrate: jest.fn(), @@ -585,6 +589,7 @@ export const mockedMeStore = { memberships: mockedMembershipsStore, mainFeed: mockedFeedStore, notifications: mockedNotificationsStore, + follows: mockedFollowsStore, clear: jest.fn(), load: jest.fn(), clearNotificationCount: jest.fn(), diff --git a/package.json b/package.json index d0cf9f9ea2..66f39cd3ab 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "e2e": "detox test --configuration ios.sim.debug --take-screenshots all" }, "dependencies": { - "@atproto/api": "^0.1.1", + "@atproto/api": "^0.1.2", "@atproto/lexicon": "^0.0.4", "@atproto/xrpc": "^0.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4", @@ -80,7 +80,7 @@ "zod": "^3.20.2" }, "devDependencies": { - "@atproto/pds": "^0.0.2", + "@atproto/pds": "^0.0.3", "@babel/core": "^7.20.0", "@babel/preset-env": "^7.20.0", "@babel/runtime": "^7.20.0", diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 0cca679acf..5ba5442aa4 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -3,6 +3,7 @@ import notifee from '@notifee/react-native' import {RootStoreModel} from './root-store' import {FeedModel} from './feed-view' import {NotificationsViewModel} from './notifications-view' +import {MyFollowsModel} from './my-follows' import {isObj, hasProp} from '../lib/type-guards' import {displayNotificationFromModel} from '../../view/lib/notifee' @@ -15,6 +16,7 @@ export class MeModel { notificationCount: number = 0 mainFeed: FeedModel notifications: NotificationsViewModel + follows: MyFollowsModel constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -26,6 +28,7 @@ export class MeModel { algorithm: 'reverse-chronological', }) this.notifications = new NotificationsViewModel(this.rootStore, {}) + this.follows = new MyFollowsModel(this.rootStore) } clear() { @@ -104,6 +107,9 @@ export class MeModel { this.notifications.setup().catch(e => { this.rootStore.log.error('Failed to setup notifications model', e) }), + this.follows.fetch().catch(e => { + this.rootStore.log.error('Failed to load my follows', e) + }), ]) // request notifications permission once the user has logged in diff --git a/src/state/models/my-follows.ts b/src/state/models/my-follows.ts new file mode 100644 index 0000000000..5b485e4945 --- /dev/null +++ b/src/state/models/my-follows.ts @@ -0,0 +1,109 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} from '@atproto/api' +import {RootStoreModel} from './root-store' +import {bundleAsync} from '../../lib/async/bundle' + +const CACHE_TTL = 1000 * 60 * 60 // hourly +type FollowsListResponse = Awaited> +type FollowsListResponseRecord = FollowsListResponse['records'][0] +type Profile = + | AppBskyActorProfile.ViewBasic + | AppBskyActorProfile.View + | AppBskyActorRef.WithInfo + +/** + * This model is used to maintain a synced local cache of the user's + * follows. It should be periodically refreshed and updated any time + * the user makes a change to their follows. + */ +export class MyFollowsModel { + // data + followDidToRecordMap: Record = {} + lastSync = 0 + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + // public api + // = + + fetchIfNeeded = bundleAsync(async () => { + if ( + Object.keys(this.followDidToRecordMap).length === 0 || + Date.now() - this.lastSync > CACHE_TTL + ) { + return await this.fetch() + } + }) + + fetch = bundleAsync(async () => { + this.rootStore.log.debug('MyFollowsModel:fetch running full fetch') + let before + let records: FollowsListResponseRecord[] = [] + do { + const res: FollowsListResponse = + await this.rootStore.api.app.bsky.graph.follow.list({ + user: this.rootStore.me.did, + before, + }) + records = records.concat(res.records) + before = res.cursor + } while (typeof before !== 'undefined') + runInAction(() => { + this.followDidToRecordMap = {} + for (const record of records) { + this.followDidToRecordMap[record.value.subject.did] = record.uri + } + this.lastSync = Date.now() + }) + }) + + isFollowing(did: string) { + return !!this.followDidToRecordMap[did] + } + + getFollowUri(did: string): string { + const v = this.followDidToRecordMap[did] + if (!v) { + throw new Error('Not a followed user') + } + return v + } + + addFollow(did: string, recordUri: string) { + this.followDidToRecordMap[did] = recordUri + } + + removeFollow(did: string) { + delete this.followDidToRecordMap[did] + } + + /** + * Use this to incrementally update the cache as views provide information + */ + hydrate(did: string, recordUri: string | undefined) { + if (recordUri) { + this.followDidToRecordMap[did] = recordUri + } else { + delete this.followDidToRecordMap[did] + } + } + + /** + * Use this to incrementally update the cache as views provide information + */ + hydrateProfiles(profiles: Profile[]) { + for (const profile of profiles) { + if (profile.viewer) { + this.hydrate(profile.did, profile.viewer.following) + } + } + } +} diff --git a/src/state/models/profile-view.ts b/src/state/models/profile-view.ts index df86ebd9c6..dc0b1fbc63 100644 --- a/src/state/models/profile-view.ts +++ b/src/state/models/profile-view.ts @@ -15,9 +15,10 @@ import {cleanError} from '../../lib/strings' export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser' -export class ProfileViewMyStateModel { - follow?: string +export class ProfileViewViewerModel { muted?: boolean + following?: string + followedBy?: string constructor() { makeAutoObservable(this) @@ -47,7 +48,7 @@ export class ProfileViewModel { followersCount: number = 0 followsCount: number = 0 postsCount: number = 0 - myState = new ProfileViewMyStateModel() + viewer = new ProfileViewViewerModel() // added data descriptionEntities?: Entity[] @@ -98,11 +99,24 @@ export class ProfileViewModel { if (!this.rootStore.me.did) { throw new Error('Not logged in') } - if (this.myState.follow) { - await apilib.unfollow(this.rootStore, this.myState.follow) + + const follows = this.rootStore.me.follows + const followUri = follows.isFollowing(this.did) + ? follows.getFollowUri(this.did) + : undefined + + // guard against this view getting out of sync with the follows cache + if (followUri !== this.viewer.following) { + this.viewer.following = followUri + return + } + + if (followUri) { + await apilib.unfollow(this.rootStore, followUri) runInAction(() => { this.followersCount-- - this.myState.follow = undefined + this.viewer.following = undefined + this.rootStore.me.follows.removeFollow(this.did) }) } else { const res = await apilib.follow( @@ -112,7 +126,8 @@ export class ProfileViewModel { ) runInAction(() => { this.followersCount++ - this.myState.follow = res.uri + this.viewer.following = res.uri + this.rootStore.me.follows.addFollow(this.did, res.uri) }) } } @@ -153,13 +168,13 @@ export class ProfileViewModel { async muteAccount() { await this.rootStore.api.app.bsky.graph.mute({user: this.did}) - this.myState.muted = true + this.viewer.muted = true await this.refresh() } async unmuteAccount() { await this.rootStore.api.app.bsky.graph.unmute({user: this.did}) - this.myState.muted = false + this.viewer.muted = false await this.refresh() } @@ -211,8 +226,9 @@ export class ProfileViewModel { this.followersCount = res.data.followersCount this.followsCount = res.data.followsCount this.postsCount = res.data.postsCount - if (res.data.myState) { - Object.assign(this.myState, res.data.myState) + if (res.data.viewer) { + Object.assign(this.viewer, res.data.viewer) + this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following) } this.descriptionEntities = extractEntities(this.description || '') } diff --git a/src/state/models/reposted-by-view.ts b/src/state/models/reposted-by-view.ts index 54b9a67cd7..8d56370557 100644 --- a/src/state/models/reposted-by-view.ts +++ b/src/state/models/reposted-by-view.ts @@ -1,6 +1,9 @@ import {makeAutoObservable, runInAction} from 'mobx' import {AtUri} from '../../third-party/uri' -import {AppBskyFeedGetRepostedBy as GetRepostedBy} from '@atproto/api' +import { + AppBskyFeedGetRepostedBy as GetRepostedBy, + AppBskyActorRef as ActorRef, +} from '@atproto/api' import {RootStoreModel} from './root-store' import {bundleAsync} from '../../lib/async/bundle' import {cleanError} from '../../lib/strings' @@ -8,7 +11,7 @@ import * as apilib from '../lib/api' const PAGE_SIZE = 30 -export type RepostedByItem = GetRepostedBy.RepostedBy +export type RepostedByItem = ActorRef.WithInfo export class RepostedByViewModel { // state @@ -127,5 +130,6 @@ export class RepostedByViewModel { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.repostedBy = this.repostedBy.concat(res.data.repostedBy) + this.rootStore.me.follows.hydrateProfiles(res.data.repostedBy) } } diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 8806184937..fb93aaae87 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -153,6 +153,7 @@ export class RootStoreModel { } try { await this.me.fetchNotifications() + await this.me.follows.fetchIfNeeded() } catch (e: any) { this.log.error('Failed to fetch latest state', e) } diff --git a/src/state/models/suggested-actors-view.ts b/src/state/models/suggested-actors-view.ts index 59d9ac5eec..7fbe25b514 100644 --- a/src/state/models/suggested-actors-view.ts +++ b/src/state/models/suggested-actors-view.ts @@ -1,8 +1,5 @@ import {makeAutoObservable, runInAction} from 'mobx' -import { - AppBskyActorGetSuggestions as GetSuggestions, - AppBskyActorProfile as Profile, -} from '@atproto/api' +import {AppBskyActorProfile as Profile} from '@atproto/api' import {RootStoreModel} from './root-store' import {cleanError} from '../../lib/strings' import {bundleAsync} from '../../lib/async/bundle' @@ -14,7 +11,7 @@ import { const PAGE_SIZE = 30 -export type SuggestedActor = GetSuggestions.Actor | Profile.View +export type SuggestedActor = Profile.ViewBasic | Profile.View const getSuggestionList = ({serviceUrl}: {serviceUrl: string}) => { if (serviceUrl.includes('localhost')) { @@ -142,10 +139,15 @@ export class SuggestedActorsViewModel { } while (actors.length) runInAction(() => { - this.hardCodedSuggestions = profiles.filter( - profile => - !profile.myState?.follow && profile.did !== this.rootStore.me.did, - ) + this.hardCodedSuggestions = profiles.filter(profile => { + if (this.rootStore.me.follows.isFollowing(profile.did)) { + return false + } + if (profile.did === this.rootStore.me.did) { + return false + } + return true + }) }) } catch (e) { this.rootStore.log.error( diff --git a/src/state/models/user-followers-view.ts b/src/state/models/user-followers-view.ts index 299b251d64..6e5a083cc8 100644 --- a/src/state/models/user-followers-view.ts +++ b/src/state/models/user-followers-view.ts @@ -9,7 +9,7 @@ import {bundleAsync} from '../../lib/async/bundle' const PAGE_SIZE = 30 -export type FollowerItem = GetFollowers.Follower +export type FollowerItem = ActorRef.WithInfo export class UserFollowersViewModel { // state @@ -116,5 +116,6 @@ export class UserFollowersViewModel { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.followers = this.followers.concat(res.data.followers) + this.rootStore.me.follows.hydrateProfiles(res.data.followers) } } diff --git a/src/state/models/user-follows-view.ts b/src/state/models/user-follows-view.ts index 3f7049ea99..7a7e61e14b 100644 --- a/src/state/models/user-follows-view.ts +++ b/src/state/models/user-follows-view.ts @@ -9,7 +9,7 @@ import {bundleAsync} from '../../lib/async/bundle' const PAGE_SIZE = 30 -export type FollowItem = GetFollows.Follow +export type FollowItem = ActorRef.WithInfo export class UserFollowsViewModel { // state @@ -116,5 +116,6 @@ export class UserFollowsViewModel { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.follows = this.follows.concat(res.data.follows) + this.rootStore.me.follows.hydrateProfiles(res.data.follows) } } diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx index 4b57b8e4bd..83c605e3ce 100644 --- a/src/view/com/discover/SuggestedFollows.tsx +++ b/src/view/com/discover/SuggestedFollows.tsx @@ -1,47 +1,27 @@ -import React, {useEffect, useState} from 'react' -import { - ActivityIndicator, - FlatList, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import LinearGradient from 'react-native-linear-gradient' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import React from 'react' +import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' -import _omit from 'lodash.omit' import {ErrorScreen} from '../util/error/ErrorScreen' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' -import {UserAvatar} from '../util/UserAvatar' -import * as Toast from '../util/Toast' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {useStores} from '../../../state' -import * as apilib from '../../../state/lib/api' import { SuggestedActorsViewModel, SuggestedActor, } from '../../../state/models/suggested-actors-view' -import {s, gradients} from '../../lib/styles' +import {s} from '../../lib/styles' import {usePalette} from '../../lib/hooks/usePalette' export const SuggestedFollows = observer( - ({ - onNoSuggestions, - asLinks, - }: { - onNoSuggestions?: () => void - asLinks?: boolean - }) => { + ({onNoSuggestions}: {onNoSuggestions?: () => void}) => { const pal = usePalette('default') const store = useStores() - const [follows, setFollows] = useState>({}) const view = React.useMemo( () => new SuggestedActorsViewModel(store), [store], ) - useEffect(() => { + React.useEffect(() => { view .loadMore() .catch((err: any) => @@ -49,7 +29,7 @@ export const SuggestedFollows = observer( ) }, [view, store.log]) - useEffect(() => { + React.useEffect(() => { if (!view.isLoading && !view.hasError && !view.hasContent) { onNoSuggestions?.() } @@ -70,48 +50,16 @@ export const SuggestedFollows = observer( ) } - const onPressFollow = async (item: SuggestedActor) => { - try { - const res = await apilib.follow(store, item.did, item.declaration.cid) - setFollows({[item.did]: res.uri, ...follows}) - } catch (e: any) { - store.log.error('Failed fo create follow', e) - Toast.show('An issue occurred, please try again.') - } - } - const onPressUnfollow = async (item: SuggestedActor) => { - try { - await apilib.unfollow(store, follows[item.did]) - setFollows(_omit(follows, [item.did])) - } catch (e: any) { - store.log.error('Failed fo delete follow', e) - Toast.show('An issue occurred, please try again.') - } - } - const renderItem = ({item}: {item: SuggestedActor}) => { - if (asLinks) { - return ( - - - - ) - } return ( - ) } @@ -150,75 +98,6 @@ export const SuggestedFollows = observer( }, ) -const User = ({ - item, - follow, - onPressFollow, - onPressUnfollow, -}: { - item: SuggestedActor - follow: string | undefined - onPressFollow: (item: SuggestedActor) => void - onPressUnfollow: (item: SuggestedActor) => void -}) => { - const pal = usePalette('default') - return ( - - - - - - - - {item.displayName || item.handle} - - - @{item.handle} - - - - {follow ? ( - onPressUnfollow(item)}> - - - Unfollow - - - - ) : ( - onPressFollow(item)}> - - - Follow - - - )} - - - {item.description ? ( - - - {item.description} - - - ) : undefined} - - ) -} - const styles = StyleSheet.create({ container: { height: '100%', @@ -231,48 +110,4 @@ const styles = StyleSheet.create({ height: 200, paddingTop: 20, }, - - actor: { - borderTopWidth: 1, - paddingHorizontal: 6, - }, - actorMeta: { - flexDirection: 'row', - }, - actorAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - actorContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - }, - actorBtn: { - paddingRight: 10, - paddingTop: 10, - }, - actorDetails: { - paddingLeft: 60, - paddingRight: 10, - paddingBottom: 10, - }, - - gradientBtn: { - paddingHorizontal: 24, - paddingVertical: 6, - }, - secondaryBtn: { - paddingHorizontal: 14, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 7, - borderRadius: 50, - marginLeft: 6, - }, }) diff --git a/src/view/com/discover/WhoToFollow.tsx b/src/view/com/discover/WhoToFollow.tsx index 880ab6ea3d..99ad3ecf48 100644 --- a/src/view/com/discover/WhoToFollow.tsx +++ b/src/view/com/discover/WhoToFollow.tsx @@ -6,23 +6,16 @@ import { View, } from 'react-native' import {observer} from 'mobx-react-lite' -import _omit from 'lodash.omit' import {useStores} from '../../../state' -import { - SuggestedActorsViewModel, - SuggestedActor, -} from '../../../state/models/suggested-actors-view' -import * as apilib from '../../../state/lib/api' -import {s} from '../../lib/styles' -import {ProfileCard} from '../profile/ProfileCard' -import * as Toast from '../util/Toast' +import {SuggestedActorsViewModel} from '../../../state/models/suggested-actors-view' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {Text} from '../util/text/Text' +import {s} from '../../lib/styles' import {usePalette} from '../../lib/hooks/usePalette' export const WhoToFollow = observer(() => { const pal = usePalette('default') const store = useStores() - const [follows, setFollows] = React.useState>({}) const suggestedActorsView = React.useMemo( () => new SuggestedActorsViewModel(store, {pageSize: 5}), [store], @@ -35,25 +28,6 @@ export const WhoToFollow = observer(() => { const onPressLoadMoreSuggestedActors = () => { suggestedActorsView.loadMore() } - const onToggleFollow = async (item: SuggestedActor) => { - if (follows[item.did]) { - try { - await apilib.unfollow(store, follows[item.did]) - setFollows(_omit(follows, [item.did])) - } catch (e: any) { - store.log.error('Failed fo delete follow', e) - Toast.show('An issue occurred, please try again.') - } - } else { - try { - const res = await apilib.follow(store, item.did, item.declaration.cid) - setFollows({[item.did]: res.uri, ...follows}) - } catch (e: any) { - store.log.error('Failed fo create follow', e) - Toast.show('An issue occurred, please try again.') - } - } - } return ( <> {(suggestedActorsView.hasContent || suggestedActorsView.isLoading) && ( @@ -65,18 +39,14 @@ export const WhoToFollow = observer(() => { <> {suggestedActorsView.suggestions.map(item => ( - ( - onToggleFollow(item)} - /> - )} /> ))} @@ -100,25 +70,6 @@ export const WhoToFollow = observer(() => { ) }) -function FollowBtn({ - isFollowing, - onPress, -}: { - isFollowing: boolean - onPress: () => void -}) { - const pal = usePalette('default') - return ( - - - - {isFollowing ? 'Unfollow' : 'Follow'} - - - - ) -} - const styles = StyleSheet.create({ heading: { fontWeight: 'bold', @@ -135,14 +86,4 @@ const styles = StyleSheet.create({ paddingLeft: 16, paddingVertical: 12, }, - - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 7, - borderRadius: 50, - marginLeft: 6, - paddingHorizontal: 14, - }, }) diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index 02d61b47b6..9c02b9b5b1 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -5,12 +5,9 @@ import { RepostedByViewModel, RepostedByItem, } from '../../../state/models/reposted-by-view' -import {UserAvatar} from '../util/UserAvatar' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {ErrorMessage} from '../util/error/ErrorMessage' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' import {useStores} from '../../../state' -import {s, colors} from '../../lib/styles' export const PostRepostedBy = observer(function PostRepostedBy({ uri, @@ -61,7 +58,15 @@ export const PostRepostedBy = observer(function PostRepostedBy({ // loaded // = const renderItem = ({item}: {item: RepostedByItem}) => ( - + ) return ( { - return ( - - - - - - - {item.displayName || item.handle} - @{item.handle} - - - - ) -} - const styles = StyleSheet.create({ - outer: { - marginTop: 1, - backgroundColor: colors.white, - }, - layout: { - flexDirection: 'row', - }, - layoutAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - avi: { - width: 40, - height: 40, - borderRadius: 20, - resizeMode: 'cover', - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, footer: { height: 200, paddingTop: 20, diff --git a/src/view/com/post-thread/PostVotedBy.tsx b/src/view/com/post-thread/PostVotedBy.tsx index f167a3ab7e..99da57d21c 100644 --- a/src/view/com/post-thread/PostVotedBy.tsx +++ b/src/view/com/post-thread/PostVotedBy.tsx @@ -1,14 +1,10 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native' -import {VotesViewModel, VotesItem} from '../../../state/models/votes-view' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' +import {VotesViewModel, VoteItem} from '../../../state/models/votes-view' import {ErrorMessage} from '../util/error/ErrorMessage' -import {UserAvatar} from '../util/UserAvatar' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {useStores} from '../../../state' -import {s} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' export const PostVotedBy = observer(function PostVotedBy({ uri, @@ -56,7 +52,17 @@ export const PostVotedBy = observer(function PostVotedBy({ // loaded // = - const renderItem = ({item}: {item: VotesItem}) => + const renderItem = ({item}: {item: VoteItem}) => ( + + ) return ( { - const pal = usePalette('default') - - return ( - - - - - - - - {item.actor.displayName || item.actor.handle} - - - @{item.actor.handle} - - - - - ) -} - const styles = StyleSheet.create({ - outer: { - marginTop: 1, - }, - layout: { - flexDirection: 'row', - }, - layoutAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - avi: { - width: 40, - height: 40, - borderRadius: 20, - resizeMode: 'cover', - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, footer: { height: 200, paddingTop: 20, diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index ce2f978bbf..6655123cc3 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -1,22 +1,28 @@ import React from 'react' -import {StyleSheet, View} from 'react-native' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {observer} from 'mobx-react-lite' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' +import * as Toast from '../util/Toast' import {s} from '../../lib/styles' import {usePalette} from '../../lib/hooks/usePalette' +import {useStores} from '../../../state' +import * as apilib from '../../../state/lib/api' export function ProfileCard({ handle, displayName, avatar, description, + isFollowedBy, renderButton, }: { handle: string displayName?: string avatar?: string description?: string + isFollowedBy?: boolean renderButton?: () => JSX.Element }) { const pal = usePalette('default') @@ -36,12 +42,19 @@ export function ProfileCard({ /> - + {displayName || handle} - + @{handle} + {isFollowedBy && ( + + + Follows You + + + )} {renderButton ? ( {renderButton()} @@ -58,6 +71,84 @@ export function ProfileCard({ ) } +export const ProfileCardWithFollowBtn = observer( + ({ + did, + declarationCid, + handle, + displayName, + avatar, + description, + isFollowedBy, + }: { + did: string + declarationCid: string + handle: string + displayName?: string + avatar?: string + description?: string + isFollowedBy?: boolean + }) => { + const store = useStores() + const isMe = store.me.handle === handle + const isFollowing = store.me.follows.isFollowing(did) + const onToggleFollow = async () => { + if (store.me.follows.isFollowing(did)) { + try { + await apilib.unfollow(store, store.me.follows.getFollowUri(did)) + store.me.follows.removeFollow(did) + } catch (e: any) { + store.log.error('Failed fo delete follow', e) + Toast.show('An issue occurred, please try again.') + } + } else { + try { + const res = await apilib.follow(store, did, declarationCid) + store.me.follows.addFollow(did, res.uri) + } catch (e: any) { + store.log.error('Failed fo create follow', e) + Toast.show('An issue occurred, please try again.') + } + } + } + return ( + ( + + ) + } + /> + ) + }, +) + +function FollowBtn({ + isFollowing, + onPress, +}: { + isFollowing: boolean + onPress: () => void +}) { + const pal = usePalette('default') + return ( + + + + {isFollowing ? 'Unfollow' : 'Follow'} + + + + ) +} + const styles = StyleSheet.create({ outer: { borderTopWidth: 1, @@ -93,4 +184,15 @@ const styles = StyleSheet.create({ paddingRight: 10, paddingBottom: 10, }, + pill: { + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + }, + btn: { + paddingVertical: 7, + borderRadius: 50, + marginLeft: 6, + paddingHorizontal: 14, + }, }) diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index 00207c4d20..b6d5f6adfd 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -5,13 +5,9 @@ import { UserFollowersViewModel, FollowerItem, } from '../../../state/models/user-followers-view' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' import {ErrorMessage} from '../util/error/ErrorMessage' -import {UserAvatar} from '../util/UserAvatar' +import {ProfileCardWithFollowBtn} from './ProfileCard' import {useStores} from '../../../state' -import {s} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' export const ProfileFollowers = observer(function ProfileFollowers({ name, @@ -62,7 +58,15 @@ export const ProfileFollowers = observer(function ProfileFollowers({ // loaded // = const renderItem = ({item}: {item: FollowerItem}) => ( - + ) return ( { - const pal = usePalette('default') - return ( - - - - - - - - {item.displayName || item.handle} - - - @{item.handle} - - - - - ) -} - const styles = StyleSheet.create({ - outer: { - borderTopWidth: 1, - }, - layout: { - flexDirection: 'row', - }, - layoutAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, footer: { height: 200, paddingTop: 20, diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index 2e67873c82..c5e61e5634 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -5,13 +5,9 @@ import { UserFollowsViewModel, FollowItem, } from '../../../state/models/user-follows-view' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' import {ErrorMessage} from '../util/error/ErrorMessage' -import {UserAvatar} from '../util/UserAvatar' +import {ProfileCardWithFollowBtn} from './ProfileCard' import {useStores} from '../../../state' -import {s} from '../../lib/styles' -import {usePalette} from '../../lib/hooks/usePalette' export const ProfileFollows = observer(function ProfileFollows({ name, @@ -62,7 +58,15 @@ export const ProfileFollows = observer(function ProfileFollows({ // loaded // = const renderItem = ({item}: {item: FollowItem}) => ( - + ) return ( { - const pal = usePalette('default') - return ( - - - - - - - - {item.displayName || item.handle} - - - @{item.handle} - - - - - ) -} - const styles = StyleSheet.create({ - outer: { - borderTopWidth: 1, - }, - layout: { - flexDirection: 'row', - }, - layoutAvi: { - width: 60, - paddingLeft: 10, - paddingTop: 10, - paddingBottom: 10, - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, footer: { height: 200, paddingTop: 20, diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 60f7fa4747..e235359c0f 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -51,7 +51,7 @@ export const ProfileHeader = observer(function ProfileHeader({ view?.toggleFollowing().then( () => { Toast.show( - `${view.myState.follow ? 'Following' : 'No longer following'} ${ + `${view.viewer.following ? 'Following' : 'No longer following'} ${ view.displayName || view.handle }`, ) @@ -140,8 +140,8 @@ export const ProfileHeader = observer(function ProfileHeader({ let dropdownItems: DropdownItem[] = [{label: 'Share', onPress: onPressShare}] if (!isMe) { dropdownItems.push({ - label: view.myState.muted ? 'Unmute Account' : 'Mute Account', - onPress: view.myState.muted ? onPressUnmuteAccount : onPressMuteAccount, + label: view.viewer.muted ? 'Unmute Account' : 'Mute Account', + onPress: view.viewer.muted ? onPressUnmuteAccount : onPressMuteAccount, }) dropdownItems.push({ label: 'Report Account', @@ -164,7 +164,7 @@ export const ProfileHeader = observer(function ProfileHeader({ ) : ( <> - {view.myState.follow ? ( + {store.me.follows.isFollowing(view.did) ? ( @@ -213,6 +213,13 @@ export const ProfileHeader = observer(function ProfileHeader({ + {view.viewer.followedBy ? ( + + + Follows you + + + ) : undefined} @{view.handle} @@ -257,14 +264,14 @@ export const ProfileHeader = observer(function ProfileHeader({ entities={view.descriptionEntities} /> ) : undefined} - {view.myState.muted ? ( + {view.viewer.muted ? ( - Account muted. + Account muted ) : undefined} @@ -371,6 +378,12 @@ const styles = StyleSheet.create({ marginBottom: 5, }, + pill: { + borderRadius: 4, + paddingHorizontal: 6, + paddingVertical: 2, + }, + br40: {borderRadius: 40}, br50: {borderRadius: 50}, }) diff --git a/yarn.lock b/yarn.lock index 4029b33819..bb30349f38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19,10 +19,10 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@atproto/api@^0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.1.1.tgz#0215dd80b98b8698f9e08932d4cdaf52676cb56c" - integrity sha512-OMKLXuWxsdaHmmZ8XzwZ1uzWpq1sr9cuzZJLAbWJz0HRDQ3a0tg1uI80XvDYHFep0qgVoT2OKJNtB23Wp7nKoQ== +"@atproto/api@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.1.2.tgz#66102f9203ba499432bc5aeb30cd19313ab2e4fc" + integrity sha512-lDcFGkrk0J7rkIPSie18xS7sO3IL6DsosX8GgoeqCNeVaDuphBRaFCcpBUWf0q4fHrpmdgghGo4ulefyKHTIFQ== dependencies: "@atproto/xrpc" "*" typed-emitter "^2.1.0" @@ -90,10 +90,10 @@ resolved "https://registry.yarnpkg.com/@atproto/nsid/-/nsid-0.0.1.tgz#0cdc00cefe8f0b1385f352b9f57b3ad37fff09a4" integrity sha512-t5M6/CzWBVYoBbIvfKDpqPj/+ZmyoK9ydZSStcTXosJ27XXwOPhz0VDUGKK2SM9G5Y7TPes8S5KTAU0UdVYFCw== -"@atproto/pds@^0.0.2": - version "0.0.2" - resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.0.2.tgz#3665a24ce1f3a5696e46fea5448c5f849e38b7e0" - integrity sha512-TCTVKJWaUxF6EQJ6hobhO19bLaoW2Nk8xn/mySnQMN7ZHlYD9Hv+eRVY+PWLpnAuGetCkTbZAspg84eG2lKGTA== +"@atproto/pds@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.0.3.tgz#118a1d51687664f085f8e1c19ae3ac1646dc69b2" + integrity sha512-l5iGJNyQs73V/mQWkcg4NXNGbnfXfv+Yg3g8nqwtun409iAAeYZimA1Tt0JV/hyq+Oz3antG0VvLQQEtNVUpVQ== dependencies: "@atproto/common" "*" "@atproto/crypto" "*" @@ -124,6 +124,7 @@ pino "^8.6.1" pino-http "^8.2.1" sharp "^0.31.2" + typed-emitter "^2.1.0" uint8arrays "3.0.0" "@atproto/plc@*":