[APP-705] Metrics revamp pt2 (#896)

* export track function from analytics.tsx

* fix create account tracking

* fix tracking sign in

* add custom feed events

* fix type errors

* refactor create post event

* add profile follow & unfollow events

* refactor PostsFeedSliceModel into its own file

* refactor PostThreadItemModel into its own file

* reorganize code a lil bit

* refactor post-thread-item to use post-feed-item model under the hood

* add post events

* add post reply tracking

* track custom feed load more

* track list subscribe and unsubscribe
zio/stable
Ansh 2023-06-27 08:11:05 -07:00 committed by GitHub
parent bfaa6d73f3
commit a8bbaa06c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 436 additions and 386 deletions

View File

@ -16,6 +16,8 @@ const segmentClient = createClient({
trackAppLifecycleEvents: false, trackAppLifecycleEvents: false,
}) })
export const track = segmentClient?.track?.bind?.(segmentClient) as TrackEvent
export function useAnalytics() { export function useAnalytics() {
const store = useStores() const store = useStores()
const methods: ClientMethods = useAnalyticsOrig() const methods: ClientMethods = useAnalyticsOrig()

View File

@ -11,6 +11,7 @@ interface TrackPropertiesMap {
// LOGIN / SIGN UP events // LOGIN / SIGN UP events
'Sign In': {resumedSession: boolean} // CAN BE SERVER 'Sign In': {resumedSession: boolean} // CAN BE SERVER
'Create Account': {} // CAN BE SERVER 'Create Account': {} // CAN BE SERVER
'Try Create Account': {}
'Signin:PressedForgotPassword': {} 'Signin:PressedForgotPassword': {}
'Signin:PressedSelectService': {} 'Signin:PressedSelectService': {}
// COMPOSER / CREATE POST events // COMPOSER / CREATE POST events
@ -30,12 +31,28 @@ interface TrackPropertiesMap {
// FEED events // FEED events
'Feed:onRefresh': {} 'Feed:onRefresh': {}
'Feed:onEndReached': {} 'Feed:onEndReached': {}
// POST events
'Post:Like': {} // CAN BE SERVER
'Post:Unlike': {} // CAN BE SERVER
'Post:Repost': {} // CAN BE SERVER
'Post:Unrepost': {} // CAN BE SERVER
'Post:Delete': {} // CAN BE SERVER
'Post:ThreadMute': {} // CAN BE SERVER
'Post:ThreadUnmute': {} // CAN BE SERVER
'Post:Reply': {} // CAN BE SERVER
// FEED ITEM events // FEED ITEM events
'FeedItem:PostReply': {} // CAN BE SERVER 'FeedItem:PostReply': {} // CAN BE SERVER
'FeedItem:PostRepost': {} // CAN BE SERVER 'FeedItem:PostRepost': {} // CAN BE SERVER
'FeedItem:PostLike': {} // CAN BE SERVER 'FeedItem:PostLike': {} // CAN BE SERVER
'FeedItem:PostDelete': {} // CAN BE SERVER 'FeedItem:PostDelete': {} // CAN BE SERVER
'FeedItem:ThreadMute': {} // CAN BE SERVER 'FeedItem:ThreadMute': {} // CAN BE SERVER
// PROFILE events
'Profile:Follow': {
username: string
}
'Profile:Unfollow': {
username: string
}
// PROFILE HEADER events // PROFILE HEADER events
'ProfileHeader:EditProfileButtonClicked': {} 'ProfileHeader:EditProfileButtonClicked': {}
'ProfileHeader:FollowersButtonClicked': {} 'ProfileHeader:FollowersButtonClicked': {}
@ -72,7 +89,28 @@ interface TrackPropertiesMap {
'Lists:onEndReached': {} 'Lists:onEndReached': {}
'CreateMuteList:AvatarSelected': {} 'CreateMuteList:AvatarSelected': {}
'CreateMuteList:Save': {} // CAN BE SERVER 'CreateMuteList:Save': {} // CAN BE SERVER
'Lists:Subscribe': {} // CAN BE SERVER
'Lists:Unsubscribe': {} // CAN BE SERVER
// CUSTOM FEED events // CUSTOM FEED events
'CustomFeed:Save': {}
'CustomFeed:Unsave': {}
'CustomFeed:Like': {}
'CustomFeed:Unlike': {}
'CustomFeed:Share': {}
'CustomFeed:Pin': {
uri: string
name: string
}
'CustomFeed:Unpin': {
uri: string
name: string
}
'CustomFeed:Reorder': {
uri: string
name: string
index: number
}
'CustomFeed:LoadMore': {}
'MultiFeed:onEndReached': {} 'MultiFeed:onEndReached': {}
'MultiFeed:onRefresh': {} 'MultiFeed:onRefresh': {}
// MODERATION events // MODERATION events

View File

@ -11,6 +11,7 @@ import {RootStoreModel} from '../root-store'
import * as apilib from 'lib/api/index' import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
import {track} from 'lib/analytics/analytics'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
@ -222,6 +223,7 @@ export class ListModel {
await this.rootStore.agent.app.bsky.graph.muteActorList({ await this.rootStore.agent.app.bsky.graph.muteActorList({
list: this.list.uri, list: this.list.uri,
}) })
track('Lists:Subscribe')
await this.refresh() await this.refresh()
} }
@ -232,6 +234,7 @@ export class ListModel {
await this.rootStore.agent.app.bsky.graph.unmuteActorList({ await this.rootStore.agent.app.bsky.graph.unmuteActorList({
list: this.list.uri, list: this.list.uri,
}) })
track('Lists:Unsubscribe')
await this.refresh() await this.refresh()
} }

View File

@ -0,0 +1,141 @@
import {makeAutoObservable} from 'mobx'
import {
AppBskyFeedPost as FeedPost,
AppBskyFeedDefs,
RichText,
} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
import {PostsFeedItemModel} from '../feeds/post'
type PostView = AppBskyFeedDefs.PostView
// NOTE: this model uses the same data as PostsFeedItemModel, but is used for
// rendering a single post in a thread view, and has additional state
// for rendering the thread view, but calls the same data methods
// as PostsFeedItemModel
// TODO: refactor as an extension or subclass of PostsFeedItemModel
export class PostThreadItemModel {
// ui state
_reactKey: string = ''
_depth = 0
_isHighlightedPost = false
_showParentReplyLine = false
_showChildReplyLine = false
_hasMore = false
// data
data: PostsFeedItemModel
post: PostView
postRecord?: FeedPost.Record
richText?: RichText
parent?:
| PostThreadItemModel
| AppBskyFeedDefs.NotFoundPost
| AppBskyFeedDefs.BlockedPost
replies?: (PostThreadItemModel | AppBskyFeedDefs.NotFoundPost)[]
constructor(
public rootStore: RootStoreModel,
v: AppBskyFeedDefs.ThreadViewPost,
) {
this._reactKey = `thread-${v.post.uri}`
this.data = new PostsFeedItemModel(rootStore, this._reactKey, v)
this.post = this.data.post
this.postRecord = this.data.postRecord
this.richText = this.data.richText
// replies and parent are handled via assignTreeModels
makeAutoObservable(this, {rootStore: false})
}
get uri() {
return this.post.uri
}
get parentUri() {
return this.postRecord?.reply?.parent.uri
}
get rootUri(): string {
if (this.postRecord?.reply?.root.uri) {
return this.postRecord.reply.root.uri
}
return this.post.uri
}
get isThreadMuted() {
return this.rootStore.mutedThreads.uris.has(this.rootUri)
}
get labelInfo(): PostLabelInfo {
return this.data.labelInfo
}
get moderation(): PostModeration {
return this.data.moderation
}
assignTreeModels(
v: AppBskyFeedDefs.ThreadViewPost,
highlightedPostUri: string,
includeParent = true,
includeChildren = true,
) {
// parents
if (includeParent && v.parent) {
if (AppBskyFeedDefs.isThreadViewPost(v.parent)) {
const parentModel = new PostThreadItemModel(this.rootStore, v.parent)
parentModel._depth = this._depth - 1
parentModel._showChildReplyLine = true
if (v.parent.parent) {
parentModel._showParentReplyLine = true
parentModel.assignTreeModels(
v.parent,
highlightedPostUri,
true,
false,
)
}
this.parent = parentModel
} else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) {
this.parent = v.parent
} else if (AppBskyFeedDefs.isBlockedPost(v.parent)) {
this.parent = v.parent
}
}
// replies
if (includeChildren && v.replies) {
const replies = []
for (const item of v.replies) {
if (AppBskyFeedDefs.isThreadViewPost(item)) {
const itemModel = new PostThreadItemModel(this.rootStore, item)
itemModel._depth = this._depth + 1
itemModel._showParentReplyLine =
itemModel.parentUri !== highlightedPostUri && replies.length === 0
if (item.replies?.length) {
itemModel._showChildReplyLine = true
itemModel.assignTreeModels(item, highlightedPostUri, false, true)
}
replies.push(itemModel)
} else if (AppBskyFeedDefs.isNotFoundPost(item)) {
replies.push(item)
}
}
this.replies = replies
}
}
async toggleLike() {
this.data.toggleLike()
}
async toggleRepost() {
this.data.toggleRepost()
}
async toggleThreadMute() {
this.data.toggleThreadMute()
}
async delete() {
this.data.delete()
}
}

View File

@ -1,238 +1,13 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import { import {
AppBskyFeedGetPostThread as GetPostThread, AppBskyFeedGetPostThread as GetPostThread,
AppBskyFeedPost as FeedPost,
AppBskyFeedDefs, AppBskyFeedDefs,
RichText,
} from '@atproto/api' } from '@atproto/api'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import * as apilib from 'lib/api/index' import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {updateDataOptimistically} from 'lib/async/revertible' import {PostThreadItemModel} from './post-thread-item'
import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
import {
getEmbedLabels,
getEmbedMuted,
getEmbedMutedByList,
getEmbedBlocking,
getEmbedBlockedBy,
filterAccountLabels,
filterProfileLabels,
getPostModeration,
} from 'lib/labeling/helpers'
export class PostThreadItemModel {
// ui state
_reactKey: string = ''
_depth = 0
_isHighlightedPost = false
_showParentReplyLine = false
_showChildReplyLine = false
_hasMore = false
// data
post: AppBskyFeedDefs.PostView
postRecord?: FeedPost.Record
parent?:
| PostThreadItemModel
| AppBskyFeedDefs.NotFoundPost
| AppBskyFeedDefs.BlockedPost
replies?: (PostThreadItemModel | AppBskyFeedDefs.NotFoundPost)[]
richText?: RichText
get uri() {
return this.post.uri
}
get parentUri() {
return this.postRecord?.reply?.parent.uri
}
get rootUri(): string {
if (this.postRecord?.reply?.root.uri) {
return this.postRecord.reply.root.uri
}
return this.uri
}
get isThreadMuted() {
return this.rootStore.mutedThreads.uris.has(this.rootUri)
}
get labelInfo(): PostLabelInfo {
return {
postLabels: (this.post.labels || []).concat(
getEmbedLabels(this.post.embed),
),
accountLabels: filterAccountLabels(this.post.author.labels),
profileLabels: filterProfileLabels(this.post.author.labels),
isMuted:
this.post.author.viewer?.muted ||
getEmbedMuted(this.post.embed) ||
false,
mutedByList:
this.post.author.viewer?.mutedByList ||
getEmbedMutedByList(this.post.embed),
isBlocking:
!!this.post.author.viewer?.blocking ||
getEmbedBlocking(this.post.embed) ||
false,
isBlockedBy:
!!this.post.author.viewer?.blockedBy ||
getEmbedBlockedBy(this.post.embed) ||
false,
}
}
get moderation(): PostModeration {
return getPostModeration(this.rootStore, this.labelInfo)
}
constructor(
public rootStore: RootStoreModel,
v: AppBskyFeedDefs.ThreadViewPost,
) {
this._reactKey = `thread-${v.post.uri}`
this.post = v.post
if (FeedPost.isRecord(this.post.record)) {
const valid = FeedPost.validateRecord(this.post.record)
if (valid.success) {
this.postRecord = this.post.record
this.richText = new RichText(this.postRecord, {cleanNewlines: true})
} else {
rootStore.log.warn(
'Received an invalid app.bsky.feed.post record',
valid.error,
)
}
} else {
rootStore.log.warn(
'app.bsky.feed.getPostThread served an unexpected record type',
this.post.record,
)
}
// replies and parent are handled via assignTreeModels
makeAutoObservable(this, {rootStore: false})
}
assignTreeModels(
v: AppBskyFeedDefs.ThreadViewPost,
highlightedPostUri: string,
includeParent = true,
includeChildren = true,
) {
// parents
if (includeParent && v.parent) {
if (AppBskyFeedDefs.isThreadViewPost(v.parent)) {
const parentModel = new PostThreadItemModel(this.rootStore, v.parent)
parentModel._depth = this._depth - 1
parentModel._showChildReplyLine = true
if (v.parent.parent) {
parentModel._showParentReplyLine = true
parentModel.assignTreeModels(
v.parent,
highlightedPostUri,
true,
false,
)
}
this.parent = parentModel
} else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) {
this.parent = v.parent
} else if (AppBskyFeedDefs.isBlockedPost(v.parent)) {
this.parent = v.parent
}
}
// replies
if (includeChildren && v.replies) {
const replies = []
for (const item of v.replies) {
if (AppBskyFeedDefs.isThreadViewPost(item)) {
const itemModel = new PostThreadItemModel(this.rootStore, item)
itemModel._depth = this._depth + 1
itemModel._showParentReplyLine =
itemModel.parentUri !== highlightedPostUri && replies.length === 0
if (item.replies?.length) {
itemModel._showChildReplyLine = true
itemModel.assignTreeModels(item, highlightedPostUri, false, true)
}
replies.push(itemModel)
} else if (AppBskyFeedDefs.isNotFoundPost(item)) {
replies.push(item)
}
}
this.replies = replies
}
}
async toggleLike() {
this.post.viewer = this.post.viewer || {}
if (this.post.viewer.like) {
const url = this.post.viewer.like
await updateDataOptimistically(
this.post,
() => {
this.post.likeCount = (this.post.likeCount || 0) - 1
this.post.viewer!.like = undefined
},
() => this.rootStore.agent.deleteLike(url),
)
} else {
await updateDataOptimistically(
this.post,
() => {
this.post.likeCount = (this.post.likeCount || 0) + 1
this.post.viewer!.like = 'pending'
},
() => this.rootStore.agent.like(this.post.uri, this.post.cid),
res => {
this.post.viewer!.like = res.uri
},
)
}
}
async toggleRepost() {
this.post.viewer = this.post.viewer || {}
if (this.post.viewer?.repost) {
const url = this.post.viewer.repost
await updateDataOptimistically(
this.post,
() => {
this.post.repostCount = (this.post.repostCount || 0) - 1
this.post.viewer!.repost = undefined
},
() => this.rootStore.agent.deleteRepost(url),
)
} else {
await updateDataOptimistically(
this.post,
() => {
this.post.repostCount = (this.post.repostCount || 0) + 1
this.post.viewer!.repost = 'pending'
},
() => this.rootStore.agent.repost(this.post.uri, this.post.cid),
res => {
this.post.viewer!.repost = res.uri
},
)
}
}
async toggleThreadMute() {
if (this.isThreadMuted) {
this.rootStore.mutedThreads.uris.delete(this.rootUri)
} else {
this.rootStore.mutedThreads.uris.add(this.rootUri)
}
}
async delete() {
await this.rootStore.agent.deletePost(this.post.uri)
this.rootStore.emitPostDeleted(this.post.uri)
}
}
export class PostThreadModel { export class PostThreadModel {
// state // state

View File

@ -18,6 +18,7 @@ import {
filterAccountLabels, filterAccountLabels,
filterProfileLabels, filterProfileLabels,
} from 'lib/labeling/helpers' } from 'lib/labeling/helpers'
import {track} from 'lib/analytics/analytics'
export class ProfileViewerModel { export class ProfileViewerModel {
muted?: boolean muted?: boolean
@ -127,19 +128,27 @@ export class ProfileModel {
} }
if (followUri) { if (followUri) {
// unfollow
await this.rootStore.agent.deleteFollow(followUri) await this.rootStore.agent.deleteFollow(followUri)
runInAction(() => { runInAction(() => {
this.followersCount-- this.followersCount--
this.viewer.following = undefined this.viewer.following = undefined
this.rootStore.me.follows.removeFollow(this.did) this.rootStore.me.follows.removeFollow(this.did)
}) })
track('Profile:Unfollow', {
username: this.handle,
})
} else { } else {
// follow
const res = await this.rootStore.agent.follow(this.did) const res = await this.rootStore.agent.follow(this.did)
runInAction(() => { runInAction(() => {
this.followersCount++ this.followersCount++
this.viewer.following = res.uri this.viewer.following = res.uri
this.rootStore.me.follows.addFollow(this.did, res.uri) this.rootStore.me.follows.addFollow(this.did, res.uri)
}) })
track('Profile:Follow', {
username: this.handle,
})
} }
} }

View File

@ -3,6 +3,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from 'state/models/root-store' import {RootStoreModel} from 'state/models/root-store'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {updateDataOptimistically} from 'lib/async/revertible' import {updateDataOptimistically} from 'lib/async/revertible'
import {track} from 'lib/analytics/analytics'
export class CustomFeedModel { export class CustomFeedModel {
// data // data
@ -56,11 +57,23 @@ export class CustomFeedModel {
// = // =
async save() { async save() {
await this.rootStore.preferences.addSavedFeed(this.uri) try {
await this.rootStore.preferences.addSavedFeed(this.uri)
} catch (error) {
this.rootStore.log.error('Failed to save feed', error)
} finally {
track('CustomFeed:Save')
}
} }
async unsave() { async unsave() {
await this.rootStore.preferences.removeSavedFeed(this.uri) try {
await this.rootStore.preferences.removeSavedFeed(this.uri)
} catch (error) {
this.rootStore.log.error('Failed to unsave feed', error)
} finally {
track('CustomFeed:Unsave')
}
} }
async like() { async like() {
@ -80,6 +93,8 @@ export class CustomFeedModel {
) )
} catch (e: any) { } catch (e: any) {
this.rootStore.log.error('Failed to like feed', e) this.rootStore.log.error('Failed to like feed', e)
} finally {
track('CustomFeed:Like')
} }
} }
@ -100,6 +115,8 @@ export class CustomFeedModel {
) )
} catch (e: any) { } catch (e: any) {
this.rootStore.log.error('Failed to unlike feed', e) this.rootStore.log.error('Failed to unlike feed', e)
} finally {
track('CustomFeed:Unlike')
} }
} }

View File

@ -4,7 +4,7 @@ import {bundleAsync} from 'lib/async/bundle'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {CustomFeedModel} from './custom-feed' import {CustomFeedModel} from './custom-feed'
import {PostsFeedModel} from './posts' import {PostsFeedModel} from './posts'
import {PostsFeedSliceModel} from './post' import {PostsFeedSliceModel} from './posts-slice'
const FEED_PAGE_SIZE = 10 const FEED_PAGE_SIZE = 10
const FEEDS_PAGE_SIZE = 3 const FEEDS_PAGE_SIZE = 3

View File

@ -1,34 +1,35 @@
import {makeAutoObservable} from 'mobx' import {makeAutoObservable} from 'mobx'
import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api' import {
AppBskyFeedPost as FeedPost,
AppBskyFeedDefs,
RichText,
} from '@atproto/api'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {updateDataOptimistically} from 'lib/async/revertible' import {updateDataOptimistically} from 'lib/async/revertible'
import {PostLabelInfo, PostModeration} from 'lib/labeling/types' import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
import {FeedViewPostsSlice} from 'lib/api/feed-manip'
import { import {
getEmbedLabels, getEmbedLabels,
getEmbedMuted, getEmbedMuted,
getEmbedMutedByList, getEmbedMutedByList,
getEmbedBlocking, getEmbedBlocking,
getEmbedBlockedBy, getEmbedBlockedBy,
getPostModeration,
filterAccountLabels, filterAccountLabels,
filterProfileLabels, filterProfileLabels,
mergePostModerations, getPostModeration,
} from 'lib/labeling/helpers' } from 'lib/labeling/helpers'
import {track} from 'lib/analytics/analytics'
type FeedViewPost = AppBskyFeedDefs.FeedViewPost type FeedViewPost = AppBskyFeedDefs.FeedViewPost
type ReasonRepost = AppBskyFeedDefs.ReasonRepost type ReasonRepost = AppBskyFeedDefs.ReasonRepost
type PostView = AppBskyFeedDefs.PostView type PostView = AppBskyFeedDefs.PostView
let _idCounter = 0
export class PostsFeedItemModel { export class PostsFeedItemModel {
// ui state // ui state
_reactKey: string = '' _reactKey: string = ''
// data // data
post: PostView post: PostView
postRecord?: AppBskyFeedPost.Record postRecord?: FeedPost.Record
reply?: FeedViewPost['reply'] reply?: FeedViewPost['reply']
reason?: FeedViewPost['reason'] reason?: FeedViewPost['reason']
richText?: RichText richText?: RichText
@ -40,8 +41,8 @@ export class PostsFeedItemModel {
) { ) {
this._reactKey = reactKey this._reactKey = reactKey
this.post = v.post this.post = v.post
if (AppBskyFeedPost.isRecord(this.post.record)) { if (FeedPost.isRecord(this.post.record)) {
const valid = AppBskyFeedPost.validateRecord(this.post.record) const valid = FeedPost.validateRecord(this.post.record)
if (valid.success) { if (valid.success) {
this.postRecord = this.post.record this.postRecord = this.post.record
this.richText = new RichText(this.postRecord, {cleanNewlines: true}) this.richText = new RichText(this.postRecord, {cleanNewlines: true})
@ -66,6 +67,14 @@ export class PostsFeedItemModel {
makeAutoObservable(this, {rootStore: false}) makeAutoObservable(this, {rootStore: false})
} }
get uri() {
return this.post.uri
}
get parentUri() {
return this.postRecord?.reply?.parent.uri
}
get rootUri(): string { get rootUri(): string {
if (typeof this.reply?.root.uri === 'string') { if (typeof this.reply?.root.uri === 'string') {
return this.reply.root.uri return this.reply.root.uri
@ -127,139 +136,94 @@ export class PostsFeedItemModel {
async toggleLike() { async toggleLike() {
this.post.viewer = this.post.viewer || {} this.post.viewer = this.post.viewer || {}
if (this.post.viewer.like) { try {
const url = this.post.viewer.like if (this.post.viewer.like) {
await updateDataOptimistically( // unlike
this.post, const url = this.post.viewer.like
() => { await updateDataOptimistically(
this.post.likeCount = (this.post.likeCount || 0) - 1 this.post,
this.post.viewer!.like = undefined () => {
}, this.post.likeCount = (this.post.likeCount || 0) - 1
() => this.rootStore.agent.deleteLike(url), this.post.viewer!.like = undefined
) },
} else { () => this.rootStore.agent.deleteLike(url),
await updateDataOptimistically( )
this.post, } else {
() => { // like
this.post.likeCount = (this.post.likeCount || 0) + 1 await updateDataOptimistically(
this.post.viewer!.like = 'pending' this.post,
}, () => {
() => this.rootStore.agent.like(this.post.uri, this.post.cid), this.post.likeCount = (this.post.likeCount || 0) + 1
res => { this.post.viewer!.like = 'pending'
this.post.viewer!.like = res.uri },
}, () => this.rootStore.agent.like(this.post.uri, this.post.cid),
) res => {
this.post.viewer!.like = res.uri
},
)
}
} catch (error) {
this.rootStore.log.error('Failed to toggle like', error)
} finally {
track(this.post.viewer.like ? 'Post:Unlike' : 'Post:Like')
} }
} }
async toggleRepost() { async toggleRepost() {
this.post.viewer = this.post.viewer || {} this.post.viewer = this.post.viewer || {}
if (this.post.viewer?.repost) { try {
const url = this.post.viewer.repost if (this.post.viewer?.repost) {
await updateDataOptimistically( const url = this.post.viewer.repost
this.post, await updateDataOptimistically(
() => { this.post,
this.post.repostCount = (this.post.repostCount || 0) - 1 () => {
this.post.viewer!.repost = undefined this.post.repostCount = (this.post.repostCount || 0) - 1
}, this.post.viewer!.repost = undefined
() => this.rootStore.agent.deleteRepost(url), },
) () => this.rootStore.agent.deleteRepost(url),
} else { )
await updateDataOptimistically( } else {
this.post, await updateDataOptimistically(
() => { this.post,
this.post.repostCount = (this.post.repostCount || 0) + 1 () => {
this.post.viewer!.repost = 'pending' this.post.repostCount = (this.post.repostCount || 0) + 1
}, this.post.viewer!.repost = 'pending'
() => this.rootStore.agent.repost(this.post.uri, this.post.cid), },
res => { () => this.rootStore.agent.repost(this.post.uri, this.post.cid),
this.post.viewer!.repost = res.uri res => {
}, this.post.viewer!.repost = res.uri
) },
)
}
} catch (error) {
this.rootStore.log.error('Failed to toggle repost', error)
} finally {
track(this.post.viewer.repost ? 'Post:Unrepost' : 'Post:Repost')
} }
} }
async toggleThreadMute() { async toggleThreadMute() {
if (this.isThreadMuted) { try {
this.rootStore.mutedThreads.uris.delete(this.rootUri) if (this.isThreadMuted) {
} else { this.rootStore.mutedThreads.uris.delete(this.rootUri)
this.rootStore.mutedThreads.uris.add(this.rootUri) } else {
this.rootStore.mutedThreads.uris.add(this.rootUri)
}
} catch (error) {
this.rootStore.log.error('Failed to toggle thread mute', error)
} finally {
track(this.isThreadMuted ? 'Post:ThreadUnmute' : 'Post:ThreadMute')
} }
} }
async delete() { async delete() {
await this.rootStore.agent.deletePost(this.post.uri) try {
this.rootStore.emitPostDeleted(this.post.uri) await this.rootStore.agent.deletePost(this.post.uri)
} this.rootStore.emitPostDeleted(this.post.uri)
} } catch (error) {
this.rootStore.log.error('Failed to delete post', error)
export class PostsFeedSliceModel { } finally {
// ui state track('Post:Delete')
_reactKey: string = '' }
// data
items: PostsFeedItemModel[] = []
constructor(
public rootStore: RootStoreModel,
reactKey: string,
slice: FeedViewPostsSlice,
) {
this._reactKey = reactKey
for (const item of slice.items) {
this.items.push(
new PostsFeedItemModel(rootStore, `slice-${_idCounter++}`, item),
)
}
makeAutoObservable(this, {rootStore: false})
}
get uri() {
if (this.isReply) {
return this.items[1].post.uri
}
return this.items[0].post.uri
}
get isThread() {
return (
this.items.length > 1 &&
this.items.every(
item => item.post.author.did === this.items[0].post.author.did,
)
)
}
get isReply() {
return this.items.length > 1 && !this.isThread
}
get rootItem() {
if (this.isReply) {
return this.items[1]
}
return this.items[0]
}
get moderation() {
return mergePostModerations(this.items.map(item => item.moderation))
}
containsUri(uri: string) {
return !!this.items.find(item => item.post.uri === uri)
}
isThreadParentAt(i: number) {
if (this.items.length === 1) {
return false
}
return i < this.items.length - 1
}
isThreadChildAt(i: number) {
if (this.items.length === 1) {
return false
}
return i > 0
} }
} }

View File

@ -0,0 +1,78 @@
import {makeAutoObservable} from 'mobx'
import {RootStoreModel} from '../root-store'
import {FeedViewPostsSlice} from 'lib/api/feed-manip'
import {mergePostModerations} from 'lib/labeling/helpers'
import {PostsFeedItemModel} from './post'
let _idCounter = 0
export class PostsFeedSliceModel {
// ui state
_reactKey: string = ''
// data
items: PostsFeedItemModel[] = []
constructor(
public rootStore: RootStoreModel,
reactKey: string,
slice: FeedViewPostsSlice,
) {
this._reactKey = reactKey
for (const item of slice.items) {
this.items.push(
new PostsFeedItemModel(rootStore, `slice-${_idCounter++}`, item),
)
}
makeAutoObservable(this, {rootStore: false})
}
get uri() {
if (this.isReply) {
return this.items[1].post.uri
}
return this.items[0].post.uri
}
get isThread() {
return (
this.items.length > 1 &&
this.items.every(
item => item.post.author.did === this.items[0].post.author.did,
)
)
}
get isReply() {
return this.items.length > 1 && !this.isThread
}
get rootItem() {
if (this.isReply) {
return this.items[1]
}
return this.items[0]
}
get moderation() {
return mergePostModerations(this.items.map(item => item.moderation))
}
containsUri(uri: string) {
return !!this.items.find(item => item.post.uri === uri)
}
isThreadParentAt(i: number) {
if (this.items.length === 1) {
return false
}
return i < this.items.length - 1
}
isThreadChildAt(i: number) {
if (this.items.length === 1) {
return false
}
return i > 0
}
}

View File

@ -9,11 +9,17 @@ import {bundleAsync} from 'lib/async/bundle'
import {RootStoreModel} from '../root-store' import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
import {PostsFeedSliceModel} from './post' import {PostsFeedSliceModel} from './posts-slice'
import {track} from 'lib/analytics/analytics'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
let _idCounter = 0 let _idCounter = 0
type QueryParams =
| GetTimeline.QueryParams
| GetAuthorFeed.QueryParams
| GetCustomFeed.QueryParams
export class PostsFeedModel { export class PostsFeedModel {
// state // state
isLoading = false isLoading = false
@ -24,7 +30,7 @@ export class PostsFeedModel {
isBlockedBy = false isBlockedBy = false
error = '' error = ''
loadMoreError = '' loadMoreError = ''
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams params: QueryParams
hasMore = true hasMore = true
loadMoreCursor: string | undefined loadMoreCursor: string | undefined
pollCursor: string | undefined pollCursor: string | undefined
@ -43,10 +49,7 @@ export class PostsFeedModel {
constructor( constructor(
public rootStore: RootStoreModel, public rootStore: RootStoreModel,
public feedType: 'home' | 'author' | 'custom', public feedType: 'home' | 'author' | 'custom',
params: params: QueryParams,
| GetTimeline.QueryParams
| GetAuthorFeed.QueryParams
| GetCustomFeed.QueryParams,
) { ) {
makeAutoObservable( makeAutoObservable(
this, this,
@ -218,6 +221,9 @@ export class PostsFeedModel {
} }
} finally { } finally {
this.lock.release() this.lock.release()
if (this.feedType === 'custom') {
track('CustomFeed:LoadMore')
}
} }
}) })
@ -416,10 +422,7 @@ export class PostsFeedModel {
} }
protected async _getFeed( protected async _getFeed(
params: params: QueryParams,
| GetTimeline.QueryParams
| GetAuthorFeed.QueryParams
| GetCustomFeed.QueryParams,
): Promise< ): Promise<
GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response
> { > {

View File

@ -7,6 +7,7 @@ import * as EmailValidator from 'email-validator'
import {createFullHandle} from 'lib/strings/handles' import {createFullHandle} from 'lib/strings/handles'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {getAge} from 'lib/strings/time' import {getAge} from 'lib/strings/time'
import {track} from 'lib/analytics/analytics'
const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
@ -117,6 +118,8 @@ export class CreateAccountModel {
this.setIsProcessing(false) this.setIsProcessing(false)
this.setError(cleanError(errMsg)) this.setError(cleanError(errMsg))
throw e throw e
} finally {
track('Create Account')
} }
} }

View File

@ -3,6 +3,7 @@ import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle' import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {CustomFeedModel} from '../feeds/custom-feed' import {CustomFeedModel} from '../feeds/custom-feed'
import {track} from 'lib/analytics/analytics'
export class SavedFeedsModel { export class SavedFeedsModel {
// state // state
@ -143,8 +144,16 @@ export class SavedFeedsModel {
async togglePinnedFeed(feed: CustomFeedModel) { async togglePinnedFeed(feed: CustomFeedModel) {
if (!this.isPinned(feed)) { if (!this.isPinned(feed)) {
track('CustomFeed:Pin', {
name: feed.data.displayName,
uri: feed.uri,
})
return this.rootStore.preferences.addPinnedFeed(feed.uri) return this.rootStore.preferences.addPinnedFeed(feed.uri)
} else { } else {
track('CustomFeed:Unpin', {
name: feed.data.displayName,
uri: feed.uri,
})
return this.rootStore.preferences.removePinnedFeed(feed.uri) return this.rootStore.preferences.removePinnedFeed(feed.uri)
} }
} }
@ -185,6 +194,11 @@ export class SavedFeedsModel {
this.rootStore.preferences.savedFeeds, this.rootStore.preferences.savedFeeds,
pinned, pinned,
) )
track('CustomFeed:Reorder', {
name: item.data.displayName,
uri: item.uri,
index: pinned.indexOf(item.uri),
})
} }
// state transitions // state transitions

View File

@ -56,9 +56,10 @@ export const CreateAccount = observer(
} else { } else {
try { try {
await model.submit() await model.submit()
track('Create Account')
} catch { } catch {
// dont need to handle here // dont need to handle here
} finally {
track('Try Create Account')
} }
} }
}, [model, track]) }, [model, track])

View File

@ -327,7 +327,6 @@ const LoginForm = ({
identifier: fullIdent, identifier: fullIdent,
password, password,
}) })
track('Sign In', {resumedSession: false})
} catch (e: any) { } catch (e: any) {
const errMsg = e.toString() const errMsg = e.toString()
store.log.warn('Failed to login', e) store.log.warn('Failed to login', e)
@ -341,6 +340,8 @@ const LoginForm = ({
} else { } else {
setError(cleanError(errMsg)) setError(cleanError(errMsg))
} }
} finally {
track('Sign In', {resumedSession: false})
} }
} }

View File

@ -169,9 +169,6 @@ export const ComposePost = observer(function ComposePost({
knownHandles: autocompleteView.knownHandles, knownHandles: autocompleteView.knownHandles,
langs: store.preferences.postLanguages, langs: store.preferences.postLanguages,
}) })
track('Create Post', {
imageCount: gallery.size,
})
} catch (e: any) { } catch (e: any) {
if (extLink) { if (extLink) {
setExtLink({ setExtLink({
@ -183,6 +180,11 @@ export const ComposePost = observer(function ComposePost({
setError(cleanError(e.message)) setError(cleanError(e.message))
setIsProcessing(false) setIsProcessing(false)
return return
} finally {
track('Create Post', {
imageCount: gallery.size,
})
if (replyTo && replyTo.uri) track('Post:Reply')
} }
if (!replyTo) { if (!replyTo) {
store.me.mainFeed.addPostToTop(createdPost.uri) store.me.mainFeed.addPostToTop(createdPost.uri)

View File

@ -9,10 +9,8 @@ import {
} from 'react-native' } from 'react-native'
import {AppBskyFeedDefs} from '@atproto/api' import {AppBskyFeedDefs} from '@atproto/api'
import {CenteredView, FlatList} from '../util/Views' import {CenteredView, FlatList} from '../util/Views'
import { import {PostThreadModel} from 'state/models/content/post-thread'
PostThreadModel, import {PostThreadItemModel} from 'state/models/content/post-thread-item'
PostThreadItemModel,
} from 'state/models/content/post-thread'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,

View File

@ -7,7 +7,7 @@ import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {PostThreadItemModel} from 'state/models/content/post-thread' import {PostThreadItemModel} from 'state/models/content/post-thread-item'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {RichText} from '../util/text/RichText' import {RichText} from '../util/text/RichText'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'

View File

@ -13,10 +13,8 @@ import {observer} from 'mobx-react-lite'
import Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import { import {PostThreadModel} from 'state/models/content/post-thread'
PostThreadModel, import {PostThreadItemModel} from 'state/models/content/post-thread-item'
PostThreadItemModel,
} from 'state/models/content/post-thread'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {UserInfoText} from '../util/UserInfoText' import {UserInfoText} from '../util/UserInfoText'
import {PostMeta} from '../util/PostMeta' import {PostMeta} from '../util/PostMeta'

View File

@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import {PostsFeedSliceModel} from 'state/models/feeds/post' import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'

View File

@ -31,12 +31,14 @@ import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {DropdownButton, DropdownItem} from 'view/com/util/forms/DropdownButton' import {DropdownButton, DropdownItem} from 'view/com/util/forms/DropdownButton'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {EmptyState} from 'view/com/util/EmptyState' import {EmptyState} from 'view/com/util/EmptyState'
import {useAnalytics} from 'lib/analytics/analytics'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'>
export const CustomFeedScreen = withAuthRequired( export const CustomFeedScreen = withAuthRequired(
observer(({route}: Props) => { observer(({route}: Props) => {
const store = useStores() const store = useStores()
const pal = usePalette('default') const pal = usePalette('default')
const {track} = useAnalytics()
const {rkey, name} = route.params const {rkey, name} = route.params
const uri = useMemo( const uri = useMemo(
() => makeRecordUri(name, 'app.bsky.feed.generator', rkey), () => makeRecordUri(name, 'app.bsky.feed.generator', rkey),
@ -99,7 +101,8 @@ export const CustomFeedScreen = withAuthRequired(
const onPressShare = React.useCallback(() => { const onPressShare = React.useCallback(() => {
const url = toShareUrl(`/profile/${name}/feed/${rkey}`) const url = toShareUrl(`/profile/${name}/feed/${rkey}`)
shareUrl(url) shareUrl(url)
}, [name, rkey]) track('CustomFeed:Share')
}, [name, rkey, track])
const onScrollToTop = React.useCallback(() => { const onScrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: 0, animated: true}) scrollElRef.current?.scrollToOffset({offset: 0, animated: true})

View File

@ -9,7 +9,7 @@ import {CenteredView} from '../com/util/Views'
import {ScreenHider} from 'view/com/util/moderation/ScreenHider' import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
import {ProfileUiModel, Sections} from 'state/models/ui/profile' import {ProfileUiModel, Sections} from 'state/models/ui/profile'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {PostsFeedSliceModel} from 'state/models/feeds/post' import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice'
import {ProfileHeader} from '../com/profile/ProfileHeader' import {ProfileHeader} from '../com/profile/ProfileHeader'
import {FeedSlice} from '../com/posts/FeedSlice' import {FeedSlice} from '../com/posts/FeedSlice'
import {ListCard} from 'view/com/lists/ListCard' import {ListCard} from 'view/com/lists/ListCard'