Add "follows you" information and sync follow state between views (#215)

* Bump @atproto/api@0.1.2 and update API usage

* Add 'follows you' pill to profile header (close #110)

* Add 'follows you' to followers and follows (close #103)

* Update reposted-by and liked-by views to use the same components as followers and following

* Create a local follows cache MyFollowsModel to keep views in sync (close #205)

* Add incremental hydration to the MyFollows model

* Fix tests

* Update deps

* Fix lint

* Fix to paginated fetches

* Fix reference

* Fix potential state-desync issue
This commit is contained in:
Paul Frazee
2023-02-16 14:34:04 -06:00
committed by GitHub
parent ad4ccade51
commit 70ec76970c
19 changed files with 370 additions and 515 deletions
+8 -3
View File
@@ -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(),
+2 -2
View File
@@ -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",
+6
View File
@@ -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
+109
View File
@@ -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<ReturnType<FollowRecord['list']>>
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<string, string> = {}
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)
}
}
}
}
+27 -11
View File
@@ -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 || '')
}
+6 -2
View File
@@ -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)
}
}
+1
View File
@@ -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)
}
+11 -9
View File
@@ -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(
+2 -1
View File
@@ -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)
}
}
+2 -1
View File
@@ -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)
}
}
+14 -179
View File
@@ -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<Record<string, string>>({})
const view = React.useMemo<SuggestedActorsViewModel>(
() => 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 (
<Link
key={item.did}
href={`/profile/${item.handle}`}
title={item.displayName || item.handle}>
<User
item={item}
follow={follows[item.did]}
onPressFollow={onPressFollow}
onPressUnfollow={onPressUnfollow}
/>
</Link>
)
}
return (
<User
<ProfileCardWithFollowBtn
key={item.did}
item={item}
follow={follows[item.did]}
onPressFollow={onPressFollow}
onPressUnfollow={onPressUnfollow}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
description={item.description}
/>
)
}
@@ -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 (
<View style={[styles.actor, pal.view, pal.border]}>
<View style={styles.actorMeta}>
<View style={styles.actorAvi}>
<UserAvatar
size={40}
displayName={item.displayName}
handle={item.handle}
avatar={item.avatar}
/>
</View>
<View style={styles.actorContent}>
<Text type="title-sm" style={pal.text} numberOfLines={1}>
{item.displayName || item.handle}
</Text>
<Text style={pal.textLight} numberOfLines={1}>
@{item.handle}
</Text>
</View>
<View style={styles.actorBtn}>
{follow ? (
<TouchableOpacity onPress={() => onPressUnfollow(item)}>
<View style={[styles.btn, styles.secondaryBtn, pal.btn]}>
<Text type="button" style={pal.text}>
Unfollow
</Text>
</View>
</TouchableOpacity>
) : (
<TouchableOpacity onPress={() => onPressFollow(item)}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn, styles.gradientBtn]}>
<FontAwesomeIcon
icon="plus"
style={[s.white, s.mr5]}
size={15}
/>
<Text style={[s.white, s.fw600, s.f15]}>Follow</Text>
</LinearGradient>
</TouchableOpacity>
)}
</View>
</View>
{item.description ? (
<View style={styles.actorDetails}>
<Text style={pal.text} numberOfLines={4}>
{item.description}
</Text>
</View>
) : undefined}
</View>
)
}
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,
},
})
+6 -65
View File
@@ -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<Record<string, string>>({})
const suggestedActorsView = React.useMemo<SuggestedActorsViewModel>(
() => 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(() => {
<>
<View style={[pal.border, styles.bottomBorder]}>
{suggestedActorsView.suggestions.map(item => (
<ProfileCard
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
description={item.description}
renderButton={() => (
<FollowBtn
isFollowing={!!follows[item.did]}
onPress={() => onToggleFollow(item)}
/>
)}
/>
))}
</View>
@@ -100,25 +70,6 @@ export const WhoToFollow = observer(() => {
)
})
function FollowBtn({
isFollowing,
onPress,
}: {
isFollowing: boolean
onPress: () => void
}) {
const pal = usePalette('default')
return (
<TouchableOpacity onPress={onPress}>
<View style={[styles.btn, pal.btn]}>
<Text type="button" style={[pal.text]}>
{isFollowing ? 'Unfollow' : 'Follow'}
</Text>
</View>
</TouchableOpacity>
)
}
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,
},
})
+10 -55
View File
@@ -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}) => (
<RepostedByItemCom item={item} />
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
isFollowedBy={!!item.viewer?.followedBy}
/>
)
return (
<FlatList
@@ -82,57 +87,7 @@ export const PostRepostedBy = observer(function PostRepostedBy({
)
})
const RepostedByItemCom = ({item}: {item: RepostedByItem}) => {
return (
<Link
style={styles.outer}
href={`/profile/${item.handle}`}
title={item.handle}
noFeedback>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar
size={40}
displayName={item.displayName}
handle={item.handle}
avatar={item.avatar}
/>
</View>
<View style={styles.layoutContent}>
<Text style={[s.f15, s.bold]}>{item.displayName || item.handle}</Text>
<Text style={[s.f14, s.gray5]}>@{item.handle}</Text>
</View>
</View>
</Link>
)
}
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,
+13 -62
View File
@@ -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}) => <LikedByItem item={item} />
const renderItem = ({item}: {item: VoteItem}) => (
<ProfileCardWithFollowBtn
key={item.actor.did}
did={item.actor.did}
declarationCid={item.actor.declaration.cid}
handle={item.actor.handle}
displayName={item.actor.displayName}
avatar={item.actor.avatar}
isFollowedBy={!!item.actor.viewer?.followedBy}
/>
)
return (
<FlatList
data={view.votes}
@@ -76,62 +82,7 @@ export const PostVotedBy = observer(function PostVotedBy({
)
})
const LikedByItem = ({item}: {item: VotesItem}) => {
const pal = usePalette('default')
return (
<Link
style={[styles.outer, pal.view]}
href={`/profile/${item.actor.handle}`}
title={item.actor.handle}
noFeedback>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar
size={40}
displayName={item.actor.displayName}
handle={item.actor.handle}
avatar={item.actor.avatar}
/>
</View>
<View style={styles.layoutContent}>
<Text style={[s.f15, s.bold, pal.text]}>
{item.actor.displayName || item.actor.handle}
</Text>
<Text style={[s.f14, s.gray5, pal.textLight]}>
@{item.actor.handle}
</Text>
</View>
</View>
</Link>
)
}
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,
+105 -3
View File
@@ -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({
/>
</View>
<View style={styles.layoutContent}>
<Text style={[s.bold, pal.text]} numberOfLines={1}>
<Text type="lg" style={[s.bold, pal.text]} numberOfLines={1}>
{displayName || handle}
</Text>
<Text type="sm" style={[pal.textLight]} numberOfLines={1}>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
@{handle}
</Text>
{isFollowedBy && (
<View style={s.flexRow}>
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs">Follows You</Text>
</View>
</View>
)}
</View>
{renderButton ? (
<View style={styles.layoutButton}>{renderButton()}</View>
@@ -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 (
<ProfileCard
handle={handle}
displayName={displayName}
avatar={avatar}
description={description}
isFollowedBy={isFollowedBy}
renderButton={
isMe
? undefined
: () => (
<FollowBtn isFollowing={isFollowing} onPress={onToggleFollow} />
)
}
/>
)
},
)
function FollowBtn({
isFollowing,
onPress,
}: {
isFollowing: boolean
onPress: () => void
}) {
const pal = usePalette('default')
return (
<TouchableOpacity onPress={onPress}>
<View style={[styles.btn, pal.btn]}>
<Text type="button" style={[pal.text]}>
{isFollowing ? 'Unfollow' : 'Follow'}
</Text>
</View>
</TouchableOpacity>
)
}
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,
},
})
+10 -54
View File
@@ -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}) => (
<User key={item.did} item={item} />
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
isFollowedBy={!!item.viewer?.followedBy}
/>
)
return (
<FlatList
@@ -83,55 +87,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({
)
})
const User = ({item}: {item: FollowerItem}) => {
const pal = usePalette('default')
return (
<Link
style={[styles.outer, pal.view, pal.border]}
href={`/profile/${item.handle}`}
title={item.handle}
noFeedback>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar
size={40}
displayName={item.displayName}
handle={item.handle}
avatar={item.avatar}
/>
</View>
<View style={styles.layoutContent}>
<Text style={[s.bold, pal.text]}>
{item.displayName || item.handle}
</Text>
<Text type="sm" style={[pal.textLight]}>
@{item.handle}
</Text>
</View>
</View>
</Link>
)
}
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,
+10 -54
View File
@@ -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}) => (
<User key={item.did} item={item} />
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
isFollowedBy={!!item.viewer?.followedBy}
/>
)
return (
<FlatList
@@ -83,55 +87,7 @@ export const ProfileFollows = observer(function ProfileFollows({
)
})
const User = ({item}: {item: FollowItem}) => {
const pal = usePalette('default')
return (
<Link
style={[styles.outer, pal.view, pal.border]}
href={`/profile/${item.handle}`}
title={item.handle}
noFeedback>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar
size={40}
displayName={item.displayName}
handle={item.handle}
avatar={item.avatar}
/>
</View>
<View style={styles.layoutContent}>
<Text style={[s.bold, pal.text]}>
{item.displayName || item.handle}
</Text>
<Text type="sm" style={[pal.textLight]}>
@{item.handle}
</Text>
</View>
</View>
</Link>
)
}
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,
+19 -6
View File
@@ -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({
</TouchableOpacity>
) : (
<>
{view.myState.follow ? (
{store.me.follows.isFollowing(view.did) ? (
<TouchableOpacity
onPress={onPressToggleFollow}
style={[styles.btn, styles.mainBtn, pal.btn]}>
@@ -213,6 +213,13 @@ export const ProfileHeader = observer(function ProfileHeader({
</Text>
</View>
<View style={styles.handleLine}>
{view.viewer.followedBy ? (
<View style={[styles.pill, pal.btn, s.mr5]}>
<Text type="xs" style={[pal.text]}>
Follows you
</Text>
</View>
) : undefined}
<Text style={pal.textLight}>@{view.handle}</Text>
</View>
<View style={styles.metricsLine}>
@@ -257,14 +264,14 @@ export const ProfileHeader = observer(function ProfileHeader({
entities={view.descriptionEntities}
/>
) : undefined}
{view.myState.muted ? (
{view.viewer.muted ? (
<View style={[styles.detailLine, pal.btn, s.p5]}>
<FontAwesomeIcon
icon={['far', 'eye-slash']}
style={[pal.text, s.mr5]}
/>
<Text type="md" style={[s.mr2, pal.text]}>
Account muted.
Account muted
</Text>
</View>
) : undefined}
@@ -371,6 +378,12 @@ const styles = StyleSheet.create({
marginBottom: 5,
},
pill: {
borderRadius: 4,
paddingHorizontal: 6,
paddingVertical: 2,
},
br40: {borderRadius: 40},
br50: {borderRadius: 50},
})
+9 -8
View File
@@ -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@*":