Implement like and repost

zio/stable
Paul Frazee 2022-07-22 11:18:47 -05:00
parent cc8a170204
commit 0ec0ba996f
12 changed files with 307 additions and 40 deletions

View File

@ -15,7 +15,7 @@
"dependencies": { "dependencies": {
"@adxp/auth": "*", "@adxp/auth": "*",
"@adxp/common": "*", "@adxp/common": "*",
"@adxp/mock-api": "git+ssh://git@github.com:bluesky-social/adx-mock-api.git#19dc93e569fa71ae3de85876b3707afd47a6fe8c", "@adxp/mock-api": "git+ssh://git@github.com:bluesky-social/adx-mock-api.git#0159e865560c12fb7004862c7d9d48420ed93878",
"@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1",

View File

@ -4,7 +4,16 @@
*/ */
// import {ReactNativeStore} from './auth' // import {ReactNativeStore} from './auth'
import {AdxClient, AdxRepoClient, AdxUri, bsky} from '@adxp/mock-api' import {
AdxClient,
AdxRepoClient,
AdxRepoCollectionClient,
AdxUri,
bsky,
SchemaOpt,
ListRecordsResponseValidated,
GetRecordResponseValidated,
} from '@adxp/mock-api'
import * as storage from './storage' import * as storage from './storage'
import {postTexts} from './mock-data/post-texts' import {postTexts} from './mock-data/post-texts'
import {replyTexts} from './mock-data/reply-texts' import {replyTexts} from './mock-data/reply-texts'
@ -19,6 +28,78 @@ export async function setup(adx: AdxClient) {
) )
} }
export async function like(adx: AdxClient, user: string, uri: string) {
await adx.repo(user, true).collection('blueskyweb.xyz:Likes').create('Like', {
$type: 'blueskyweb.xyz:Like',
subject: uri,
createdAt: new Date().toISOString(),
})
}
export async function unlike(adx: AdxClient, user: string, uri: string) {
const coll = adx.repo(user, true).collection('blueskyweb.xyz:Likes')
const numDels = await deleteWhere(coll, 'Like', record => {
return record.value.subject === uri
})
return numDels > 0
}
export async function repost(adx: AdxClient, user: string, uri: string) {
await adx
.repo(user, true)
.collection('blueskyweb.xyz:Posts')
.create('Repost', {
$type: 'blueskyweb.xyz:Repost',
subject: uri,
createdAt: new Date().toISOString(),
})
}
export async function unrepost(adx: AdxClient, user: string, uri: string) {
const coll = adx.repo(user, true).collection('blueskyweb.xyz:Posts')
const numDels = await deleteWhere(coll, 'Repost', record => {
return record.value.subject === uri
})
return numDels > 0
}
type WherePred = (record: GetRecordResponseValidated) => Boolean
async function deleteWhere(
coll: AdxRepoCollectionClient,
schema: SchemaOpt,
cond: WherePred,
) {
const toDelete: string[] = []
iterateAll(coll, schema, record => {
if (cond(record)) {
toDelete.push(record.key)
}
})
for (const key of toDelete) {
await coll.del(key)
}
return toDelete.length
}
type IterateAllCb = (record: GetRecordResponseValidated) => void
async function iterateAll(
coll: AdxRepoCollectionClient,
schema: SchemaOpt,
cb: IterateAllCb,
) {
let cursor
let res: ListRecordsResponseValidated
do {
res = await coll.list(schema, {after: cursor, limit: 100})
for (const record of res.records) {
if (record.valid) {
cb(record)
cursor = record.key
}
}
} while (res.records.length === 100)
}
// TEMPORARY // TEMPORARY
// mock api config // mock api config
// ======= // =======

View File

@ -1,6 +1,17 @@
import {makeAutoObservable} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {bsky} from '@adxp/mock-api' import {bsky} from '@adxp/mock-api'
import _omit from 'lodash.omit'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import * as apilib from '../lib/api'
export class FeedViewItemMyStateModel {
hasLiked: boolean = false
hasReposted: boolean = false
constructor() {
makeAutoObservable(this)
}
}
export class FeedViewItemModel implements bsky.FeedView.FeedItem { export class FeedViewItemModel implements bsky.FeedView.FeedItem {
// ui state // ui state
@ -19,11 +30,51 @@ export class FeedViewItemModel implements bsky.FeedView.FeedItem {
repostCount: number = 0 repostCount: number = 0
likeCount: number = 0 likeCount: number = 0
indexedAt: string = '' indexedAt: string = ''
myState = new FeedViewItemMyStateModel()
constructor(reactKey: string, v: bsky.FeedView.FeedItem) { constructor(
makeAutoObservable(this) public rootStore: RootStoreModel,
reactKey: string,
v: bsky.FeedView.FeedItem,
) {
makeAutoObservable(this, {rootStore: false})
this._reactKey = reactKey this._reactKey = reactKey
Object.assign(this, v) Object.assign(this, _omit(v, 'myState'))
if (v.myState) {
Object.assign(this.myState, v.myState)
}
}
async toggleLike() {
if (this.myState.hasLiked) {
await apilib.unlike(this.rootStore.api, 'alice.com', this.uri)
runInAction(() => {
this.likeCount--
this.myState.hasLiked = false
})
} else {
await apilib.like(this.rootStore.api, 'alice.com', this.uri)
runInAction(() => {
this.likeCount++
this.myState.hasLiked = true
})
}
}
async toggleRepost() {
if (this.myState.hasReposted) {
await apilib.unrepost(this.rootStore.api, 'alice.com', this.uri)
runInAction(() => {
this.repostCount--
this.myState.hasReposted = false
})
} else {
await apilib.repost(this.rootStore.api, 'alice.com', this.uri)
runInAction(() => {
this.repostCount++
this.myState.hasReposted = true
})
}
} }
} }
@ -177,6 +228,6 @@ export class FeedViewModel implements bsky.FeedView.Response {
private _append(keyId: number, item: bsky.FeedView.FeedItem) { private _append(keyId: number, item: bsky.FeedView.FeedItem) {
// TODO: validate .record // TODO: validate .record
this.feed.push(new FeedViewItemModel(`item-${keyId}`, item)) this.feed.push(new FeedViewItemModel(this.rootStore, `item-${keyId}`, item))
} }
} }

View File

@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
import {bsky, AdxUri} from '@adxp/mock-api' import {bsky, AdxUri} from '@adxp/mock-api'
import _omit from 'lodash.omit' import _omit from 'lodash.omit'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import * as apilib from '../lib/api'
function* reactKeyGenerator(): Generator<string> { function* reactKeyGenerator(): Generator<string> {
let counter = 0 let counter = 0
@ -10,6 +11,15 @@ function* reactKeyGenerator(): Generator<string> {
} }
} }
export class PostThreadViewPostMyStateModel {
hasLiked: boolean = false
hasReposted: boolean = false
constructor() {
makeAutoObservable(this)
}
}
export class PostThreadViewPostModel implements bsky.PostThreadView.Post { export class PostThreadViewPostModel implements bsky.PostThreadView.Post {
// ui state // ui state
_reactKey: string = '' _reactKey: string = ''
@ -30,12 +40,20 @@ export class PostThreadViewPostModel implements bsky.PostThreadView.Post {
repostCount: number = 0 repostCount: number = 0
likeCount: number = 0 likeCount: number = 0
indexedAt: string = '' indexedAt: string = ''
myState = new PostThreadViewPostMyStateModel()
constructor(reactKey: string, v?: bsky.PostThreadView.Post) { constructor(
makeAutoObservable(this) public rootStore: RootStoreModel,
reactKey: string,
v?: bsky.PostThreadView.Post,
) {
makeAutoObservable(this, {rootStore: false})
this._reactKey = reactKey this._reactKey = reactKey
if (v) { if (v) {
Object.assign(this, _omit(v, 'parent', 'replies')) // copy everything but the replies and the parent Object.assign(this, _omit(v, 'parent', 'replies', 'myState')) // replies and parent are handled via assignTreeModels
if (v.myState) {
Object.assign(this.myState, v.myState)
}
} }
} }
@ -44,6 +62,7 @@ export class PostThreadViewPostModel implements bsky.PostThreadView.Post {
if (v.parent) { if (v.parent) {
// TODO: validate .record // TODO: validate .record
const parentModel = new PostThreadViewPostModel( const parentModel = new PostThreadViewPostModel(
this.rootStore,
keyGen.next().value, keyGen.next().value,
v.parent, v.parent,
) )
@ -58,7 +77,11 @@ export class PostThreadViewPostModel implements bsky.PostThreadView.Post {
const replies = [] const replies = []
for (const item of v.replies) { for (const item of v.replies) {
// TODO: validate .record // TODO: validate .record
const itemModel = new PostThreadViewPostModel(keyGen.next().value, item) const itemModel = new PostThreadViewPostModel(
this.rootStore,
keyGen.next().value,
item,
)
itemModel._depth = this._depth + 1 itemModel._depth = this._depth + 1
if (item.replies) { if (item.replies) {
itemModel.assignTreeModels(keyGen, item) itemModel.assignTreeModels(keyGen, item)
@ -68,10 +91,41 @@ export class PostThreadViewPostModel implements bsky.PostThreadView.Post {
this.replies = replies this.replies = replies
} }
} }
}
const UNLOADED_THREAD = new PostThreadViewPostModel('')
export class PostThreadViewModel implements bsky.PostThreadView.Response { async toggleLike() {
if (this.myState.hasLiked) {
await apilib.unlike(this.rootStore.api, 'alice.com', this.uri)
runInAction(() => {
this.likeCount--
this.myState.hasLiked = false
})
} else {
await apilib.like(this.rootStore.api, 'alice.com', this.uri)
runInAction(() => {
this.likeCount++
this.myState.hasLiked = true
})
}
}
async toggleRepost() {
if (this.myState.hasReposted) {
await apilib.unrepost(this.rootStore.api, 'alice.com', this.uri)
runInAction(() => {
this.repostCount--
this.myState.hasReposted = false
})
} else {
await apilib.repost(this.rootStore.api, 'alice.com', this.uri)
runInAction(() => {
this.repostCount++
this.myState.hasReposted = true
})
}
}
}
export class PostThreadViewModel {
// state // state
isLoading = false isLoading = false
isRefreshing = false isRefreshing = false
@ -81,7 +135,7 @@ export class PostThreadViewModel implements bsky.PostThreadView.Response {
params: bsky.PostThreadView.Params params: bsky.PostThreadView.Params
// data // data
thread: PostThreadViewPostModel = UNLOADED_THREAD thread?: PostThreadViewPostModel
constructor( constructor(
public rootStore: RootStoreModel, public rootStore: RootStoreModel,
@ -99,7 +153,7 @@ export class PostThreadViewModel implements bsky.PostThreadView.Response {
} }
get hasContent() { get hasContent() {
return this.thread !== UNLOADED_THREAD return typeof this.thread !== 'undefined'
} }
get hasError() { get hasError() {
@ -177,7 +231,11 @@ export class PostThreadViewModel implements bsky.PostThreadView.Response {
private _replaceAll(res: bsky.PostThreadView.Response) { private _replaceAll(res: bsky.PostThreadView.Response) {
// TODO: validate .record // TODO: validate .record
const keyGen = reactKeyGenerator() const keyGen = reactKeyGenerator()
const thread = new PostThreadViewPostModel(keyGen.next().value, res.thread) const thread = new PostThreadViewPostModel(
this.rootStore,
keyGen.next().value,
res.thread,
)
thread._isHighlightedPost = true thread._isHighlightedPost = true
thread.assignTreeModels(keyGen, res.thread) thread.assignTreeModels(keyGen, res.thread)
this.thread = thread this.thread = thread

View File

@ -2,6 +2,14 @@ import {makeAutoObservable} from 'mobx'
import {bsky} from '@adxp/mock-api' import {bsky} from '@adxp/mock-api'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
export class ProfileViewMyStateModel {
hasFollowed: boolean = false
constructor() {
makeAutoObservable(this)
}
}
export class ProfileViewModel implements bsky.ProfileView.Response { export class ProfileViewModel implements bsky.ProfileView.Response {
// state // state
isLoading = false isLoading = false
@ -19,6 +27,7 @@ export class ProfileViewModel implements bsky.ProfileView.Response {
followsCount: number = 0 followsCount: number = 0
postsCount: number = 0 postsCount: number = 0
badges: bsky.ProfileView.Badge[] = [] badges: bsky.ProfileView.Badge[] = []
myState = new ProfileViewMyStateModel()
constructor( constructor(
public rootStore: RootStoreModel, public rootStore: RootStoreModel,
@ -101,5 +110,8 @@ export class ProfileViewModel implements bsky.ProfileView.Response {
this.followsCount = res.followsCount this.followsCount = res.followsCount
this.postsCount = res.postsCount this.postsCount = res.postsCount
this.badges = res.badges this.badges = res.badges
if (res.myState) {
Object.assign(this.myState, res.myState)
}
} }
} }

View File

@ -30,6 +30,16 @@ export const FeedItem = observer(function FeedItem({
name: item.author.name, name: item.author.name,
}) })
} }
const onPressToggleRepost = () => {
item
.toggleRepost()
.catch(e => console.error('Failed to toggle repost', record, e))
}
const onPressToggleLike = () => {
item
.toggleLike()
.catch(e => console.error('Failed to toggle like', record, e))
}
return ( return (
<TouchableOpacity style={styles.outer} onPress={onPressOuter}> <TouchableOpacity style={styles.outer} onPress={onPressOuter}>
@ -75,21 +85,34 @@ export const FeedItem = observer(function FeedItem({
/> />
<Text>{item.replyCount}</Text> <Text>{item.replyCount}</Text>
</View> </View>
<View style={styles.ctrl}> <TouchableOpacity style={styles.ctrl} onPress={onPressToggleRepost}>
<FontAwesomeIcon <FontAwesomeIcon
style={styles.ctrlIcon} style={
item.myState.hasReposted
? styles.ctrlIconReposted
: styles.ctrlIcon
}
icon="retweet" icon="retweet"
size={22} size={22}
/> />
<Text>{item.repostCount}</Text> <Text
</View> style={
<View style={styles.ctrl}> item.myState.hasReposted ? [s.bold, s.green] : undefined
}>
{item.repostCount}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.ctrl} onPress={onPressToggleLike}>
<FontAwesomeIcon <FontAwesomeIcon
style={styles.ctrlIcon} style={
icon={['far', 'heart']} item.myState.hasLiked ? styles.ctrlIconLiked : styles.ctrlIcon
}
icon={[item.myState.hasLiked ? 'fas' : 'far', 'heart']}
/> />
<Text>{item.likeCount}</Text> <Text style={item.myState.hasLiked ? [s.bold, s.red] : undefined}>
</View> {item.likeCount}
</Text>
</TouchableOpacity>
<View style={styles.ctrl}> <View style={styles.ctrl}>
<FontAwesomeIcon <FontAwesomeIcon
style={styles.ctrlIcon} style={styles.ctrlIcon}
@ -158,4 +181,12 @@ const styles = StyleSheet.create({
marginRight: 5, marginRight: 5,
color: 'gray', color: 'gray',
}, },
ctrlIconReposted: {
marginRight: 5,
color: 'green',
},
ctrlIconLiked: {
marginRight: 5,
color: 'red',
},
}) })

View File

@ -56,7 +56,7 @@ export const PostThread = observer(function PostThread({
// loaded // loaded
// = // =
const posts = Array.from(flattenThread(view.thread)) const posts = view.thread ? Array.from(flattenThread(view.thread)) : []
const renderItem = ({item}: {item: PostThreadViewPostModel}) => ( const renderItem = ({item}: {item: PostThreadViewPostModel}) => (
<PostThreadItem item={item} onNavigateContent={onNavigateContent} /> <PostThreadItem item={item} onNavigateContent={onNavigateContent} />
) )

View File

@ -40,6 +40,16 @@ export const PostThreadItem = observer(function PostThreadItem({
name: item.author.name, name: item.author.name,
}) })
} }
const onPressToggleRepost = () => {
item
.toggleRepost()
.catch(e => console.error('Failed to toggle repost', record, e))
}
const onPressToggleLike = () => {
item
.toggleLike()
.catch(e => console.error('Failed to toggle like', record, e))
}
return ( return (
<TouchableOpacity style={styles.outer} onPress={onPressOuter}> <TouchableOpacity style={styles.outer} onPress={onPressOuter}>
@ -108,21 +118,34 @@ export const PostThreadItem = observer(function PostThreadItem({
/> />
<Text>{item.replyCount}</Text> <Text>{item.replyCount}</Text>
</View> </View>
<View style={styles.ctrl}> <TouchableOpacity style={styles.ctrl} onPress={onPressToggleRepost}>
<FontAwesomeIcon <FontAwesomeIcon
style={styles.ctrlIcon} style={
item.myState.hasReposted
? styles.ctrlIconReposted
: styles.ctrlIcon
}
icon="retweet" icon="retweet"
size={22} size={22}
/> />
<Text>{item.repostCount}</Text> <Text
</View> style={
<View style={styles.ctrl}> item.myState.hasReposted ? [s.bold, s.green] : undefined
}>
{item.repostCount}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.ctrl} onPress={onPressToggleLike}>
<FontAwesomeIcon <FontAwesomeIcon
style={styles.ctrlIcon} style={
icon={['far', 'heart']} item.myState.hasLiked ? styles.ctrlIconLiked : styles.ctrlIcon
}
icon={[item.myState.hasLiked ? 'fas' : 'far', 'heart']}
/> />
<Text>{item.likeCount}</Text> <Text style={item.myState.hasLiked ? [s.bold, s.red] : undefined}>
</View> {item.likeCount}
</Text>
</TouchableOpacity>
<View style={styles.ctrl}> <View style={styles.ctrl}>
<FontAwesomeIcon <FontAwesomeIcon
style={styles.ctrlIcon} style={styles.ctrlIcon}
@ -205,4 +228,12 @@ const styles = StyleSheet.create({
marginRight: 5, marginRight: 5,
color: 'gray', color: 'gray',
}, },
ctrlIconReposted: {
marginRight: 5,
color: 'green',
},
ctrlIconLiked: {
marginRight: 5,
color: 'red',
},
}) })

View File

@ -6,6 +6,7 @@ import {faBars} from '@fortawesome/free-solid-svg-icons/faBars'
import {faBell} from '@fortawesome/free-solid-svg-icons/faBell' import {faBell} from '@fortawesome/free-solid-svg-icons/faBell'
import {faComment} from '@fortawesome/free-regular-svg-icons/faComment' import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart' import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart'
import {faHeart as fasHeart} from '@fortawesome/free-solid-svg-icons/faHeart'
import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse' import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse'
import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass' import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass'
import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare' import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSquare'
@ -38,6 +39,7 @@ export function setup() {
faBell, faBell,
faComment, faComment,
faHeart, faHeart,
fasHeart,
faHouse, faHouse,
faMagnifyingGlass, faMagnifyingGlass,
faRetweet, faRetweet,

View File

@ -34,6 +34,9 @@ export const s = StyleSheet.create({
// colors // colors
black: {color: 'black'}, black: {color: 'black'},
gray: {color: 'gray'}, gray: {color: 'gray'},
blue: {color: 'blue'},
green: {color: 'green'},
red: {color: 'red'},
// margins // margins
mr2: {marginRight: 2}, mr2: {marginRight: 2},

View File

@ -1,8 +1,6 @@
Paul's todo list Paul's todo list
- Posts in Thread and Feed view - Posts in Thread and Feed view
- Like btn
- Repost btn
- Share btn - Share btn
- Thread view - Thread view
- View likes list - View likes list

View File

@ -55,9 +55,9 @@
ucans "0.9.0-alpha3" ucans "0.9.0-alpha3"
uint8arrays "^3.0.0" uint8arrays "^3.0.0"
"@adxp/mock-api@git+ssh://git@github.com:bluesky-social/adx-mock-api.git#19dc93e569fa71ae3de85876b3707afd47a6fe8c": "@adxp/mock-api@git+ssh://git@github.com:bluesky-social/adx-mock-api.git#0159e865560c12fb7004862c7d9d48420ed93878":
version "0.0.1" version "0.0.1"
resolved "git+ssh://git@github.com:bluesky-social/adx-mock-api.git#19dc93e569fa71ae3de85876b3707afd47a6fe8c" resolved "git+ssh://git@github.com:bluesky-social/adx-mock-api.git#0159e865560c12fb7004862c7d9d48420ed93878"
dependencies: dependencies:
ajv "^8.11.0" ajv "^8.11.0"
ajv-formats "^2.1.1" ajv-formats "^2.1.1"