diff options
Diffstat (limited to 'src')
22 files changed, 435 insertions, 385 deletions
diff --git a/src/lib/analytics/analytics.tsx b/src/lib/analytics/analytics.tsx index d9d53e6a9..ab04bb88f 100644 --- a/src/lib/analytics/analytics.tsx +++ b/src/lib/analytics/analytics.tsx @@ -16,6 +16,8 @@ const segmentClient = createClient({ trackAppLifecycleEvents: false, }) +export const track = segmentClient?.track?.bind?.(segmentClient) as TrackEvent + export function useAnalytics() { const store = useStores() const methods: ClientMethods = useAnalyticsOrig() diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts index 0638c6b77..062149d3d 100644 --- a/src/lib/analytics/types.ts +++ b/src/lib/analytics/types.ts @@ -11,6 +11,7 @@ interface TrackPropertiesMap { // LOGIN / SIGN UP events 'Sign In': {resumedSession: boolean} // CAN BE SERVER 'Create Account': {} // CAN BE SERVER + 'Try Create Account': {} 'Signin:PressedForgotPassword': {} 'Signin:PressedSelectService': {} // COMPOSER / CREATE POST events @@ -30,12 +31,28 @@ interface TrackPropertiesMap { // FEED events 'Feed:onRefresh': {} '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 'FeedItem:PostReply': {} // CAN BE SERVER 'FeedItem:PostRepost': {} // CAN BE SERVER 'FeedItem:PostLike': {} // CAN BE SERVER 'FeedItem:PostDelete': {} // CAN BE SERVER 'FeedItem:ThreadMute': {} // CAN BE SERVER + // PROFILE events + 'Profile:Follow': { + username: string + } + 'Profile:Unfollow': { + username: string + } // PROFILE HEADER events 'ProfileHeader:EditProfileButtonClicked': {} 'ProfileHeader:FollowersButtonClicked': {} @@ -72,7 +89,28 @@ interface TrackPropertiesMap { 'Lists:onEndReached': {} 'CreateMuteList:AvatarSelected': {} 'CreateMuteList:Save': {} // CAN BE SERVER + 'Lists:Subscribe': {} // CAN BE SERVER + 'Lists:Unsubscribe': {} // CAN BE SERVER // 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:onRefresh': {} // MODERATION events diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts index 038e9fc30..d5c9e649e 100644 --- a/src/state/models/content/list.ts +++ b/src/state/models/content/list.ts @@ -11,6 +11,7 @@ import {RootStoreModel} from '../root-store' import * as apilib from 'lib/api/index' import {cleanError} from 'lib/strings/errors' import {bundleAsync} from 'lib/async/bundle' +import {track} from 'lib/analytics/analytics' const PAGE_SIZE = 30 @@ -222,6 +223,7 @@ export class ListModel { await this.rootStore.agent.app.bsky.graph.muteActorList({ list: this.list.uri, }) + track('Lists:Subscribe') await this.refresh() } @@ -232,6 +234,7 @@ export class ListModel { await this.rootStore.agent.app.bsky.graph.unmuteActorList({ list: this.list.uri, }) + track('Lists:Unsubscribe') await this.refresh() } diff --git a/src/state/models/content/post-thread-item.ts b/src/state/models/content/post-thread-item.ts new file mode 100644 index 000000000..c33415507 --- /dev/null +++ b/src/state/models/content/post-thread-item.ts @@ -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() + } +} diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index 577b76e01..0a67c783e 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -1,238 +1,13 @@ import {makeAutoObservable, runInAction} from 'mobx' import { AppBskyFeedGetPostThread as GetPostThread, - AppBskyFeedPost as FeedPost, AppBskyFeedDefs, - RichText, } from '@atproto/api' import {AtUri} from '@atproto/api' import {RootStoreModel} from '../root-store' import * as apilib from 'lib/api/index' import {cleanError} from 'lib/strings/errors' -import {updateDataOptimistically} from 'lib/async/revertible' -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) - } -} +import {PostThreadItemModel} from './post-thread-item' export class PostThreadModel { // state diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index 9d8378f79..34b2ea28e 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -18,6 +18,7 @@ import { filterAccountLabels, filterProfileLabels, } from 'lib/labeling/helpers' +import {track} from 'lib/analytics/analytics' export class ProfileViewerModel { muted?: boolean @@ -127,19 +128,27 @@ export class ProfileModel { } if (followUri) { + // unfollow await this.rootStore.agent.deleteFollow(followUri) runInAction(() => { this.followersCount-- this.viewer.following = undefined this.rootStore.me.follows.removeFollow(this.did) }) + track('Profile:Unfollow', { + username: this.handle, + }) } else { + // follow const res = await this.rootStore.agent.follow(this.did) runInAction(() => { this.followersCount++ this.viewer.following = res.uri this.rootStore.me.follows.addFollow(this.did, res.uri) }) + track('Profile:Follow', { + username: this.handle, + }) } } diff --git a/src/state/models/feeds/custom-feed.ts b/src/state/models/feeds/custom-feed.ts index 8fc1eb1ec..1303952ea 100644 --- a/src/state/models/feeds/custom-feed.ts +++ b/src/state/models/feeds/custom-feed.ts @@ -3,6 +3,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import {RootStoreModel} from 'state/models/root-store' import {sanitizeDisplayName} from 'lib/strings/display-names' import {updateDataOptimistically} from 'lib/async/revertible' +import {track} from 'lib/analytics/analytics' export class CustomFeedModel { // data @@ -56,11 +57,23 @@ export class CustomFeedModel { // = 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() { - 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() { @@ -80,6 +93,8 @@ export class CustomFeedModel { ) } catch (e: any) { this.rootStore.log.error('Failed to like feed', e) + } finally { + track('CustomFeed:Like') } } @@ -100,6 +115,8 @@ export class CustomFeedModel { ) } catch (e: any) { this.rootStore.log.error('Failed to unlike feed', e) + } finally { + track('CustomFeed:Unlike') } } diff --git a/src/state/models/feeds/multi-feed.ts b/src/state/models/feeds/multi-feed.ts index c2ca8d72f..1fc57a86b 100644 --- a/src/state/models/feeds/multi-feed.ts +++ b/src/state/models/feeds/multi-feed.ts @@ -4,7 +4,7 @@ import {bundleAsync} from 'lib/async/bundle' import {RootStoreModel} from '../root-store' import {CustomFeedModel} from './custom-feed' import {PostsFeedModel} from './posts' -import {PostsFeedSliceModel} from './post' +import {PostsFeedSliceModel} from './posts-slice' const FEED_PAGE_SIZE = 10 const FEEDS_PAGE_SIZE = 3 diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts index 18a90ee82..8e3c9b03e 100644 --- a/src/state/models/feeds/post.ts +++ b/src/state/models/feeds/post.ts @@ -1,34 +1,35 @@ 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 {updateDataOptimistically} from 'lib/async/revertible' import {PostLabelInfo, PostModeration} from 'lib/labeling/types' -import {FeedViewPostsSlice} from 'lib/api/feed-manip' import { getEmbedLabels, getEmbedMuted, getEmbedMutedByList, getEmbedBlocking, getEmbedBlockedBy, - getPostModeration, filterAccountLabels, filterProfileLabels, - mergePostModerations, + getPostModeration, } from 'lib/labeling/helpers' +import {track} from 'lib/analytics/analytics' type FeedViewPost = AppBskyFeedDefs.FeedViewPost type ReasonRepost = AppBskyFeedDefs.ReasonRepost type PostView = AppBskyFeedDefs.PostView -let _idCounter = 0 - export class PostsFeedItemModel { // ui state _reactKey: string = '' // data post: PostView - postRecord?: AppBskyFeedPost.Record + postRecord?: FeedPost.Record reply?: FeedViewPost['reply'] reason?: FeedViewPost['reason'] richText?: RichText @@ -40,8 +41,8 @@ export class PostsFeedItemModel { ) { this._reactKey = reactKey this.post = v.post - if (AppBskyFeedPost.isRecord(this.post.record)) { - const valid = AppBskyFeedPost.validateRecord(this.post.record) + 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}) @@ -66,6 +67,14 @@ export class PostsFeedItemModel { makeAutoObservable(this, {rootStore: false}) } + get uri() { + return this.post.uri + } + + get parentUri() { + return this.postRecord?.reply?.parent.uri + } + get rootUri(): string { if (typeof this.reply?.root.uri === 'string') { return this.reply.root.uri @@ -127,139 +136,94 @@ export class PostsFeedItemModel { 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 - }, - ) + try { + if (this.post.viewer.like) { + // unlike + 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 { + // like + 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 + }, + ) + } + } catch (error) { + this.rootStore.log.error('Failed to toggle like', error) + } finally { + track(this.post.viewer.like ? 'Post:Unlike' : 'Post:Like') } } 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 - }, - ) + try { + 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 + }, + ) + } + } catch (error) { + this.rootStore.log.error('Failed to toggle repost', error) + } finally { + track(this.post.viewer.repost ? 'Post:Unrepost' : 'Post:Repost') } } async toggleThreadMute() { - if (this.isThreadMuted) { - this.rootStore.mutedThreads.uris.delete(this.rootUri) - } else { - this.rootStore.mutedThreads.uris.add(this.rootUri) + try { + if (this.isThreadMuted) { + this.rootStore.mutedThreads.uris.delete(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() { - await this.rootStore.agent.deletePost(this.post.uri) - this.rootStore.emitPostDeleted(this.post.uri) - } -} - -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 + try { + 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) + } finally { + track('Post:Delete') } - return i > 0 } } diff --git a/src/state/models/feeds/posts-slice.ts b/src/state/models/feeds/posts-slice.ts new file mode 100644 index 000000000..239bc5b6a --- /dev/null +++ b/src/state/models/feeds/posts-slice.ts @@ -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 + } +} diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 2c6f89c35..cd5e3c056 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -9,11 +9,17 @@ import {bundleAsync} from 'lib/async/bundle' import {RootStoreModel} from '../root-store' import {cleanError} from 'lib/strings/errors' 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 let _idCounter = 0 +type QueryParams = + | GetTimeline.QueryParams + | GetAuthorFeed.QueryParams + | GetCustomFeed.QueryParams + export class PostsFeedModel { // state isLoading = false @@ -24,7 +30,7 @@ export class PostsFeedModel { isBlockedBy = false error = '' loadMoreError = '' - params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams + params: QueryParams hasMore = true loadMoreCursor: string | undefined pollCursor: string | undefined @@ -43,10 +49,7 @@ export class PostsFeedModel { constructor( public rootStore: RootStoreModel, public feedType: 'home' | 'author' | 'custom', - params: - | GetTimeline.QueryParams - | GetAuthorFeed.QueryParams - | GetCustomFeed.QueryParams, + params: QueryParams, ) { makeAutoObservable( this, @@ -218,6 +221,9 @@ export class PostsFeedModel { } } finally { this.lock.release() + if (this.feedType === 'custom') { + track('CustomFeed:LoadMore') + } } }) @@ -416,10 +422,7 @@ export class PostsFeedModel { } protected async _getFeed( - params: - | GetTimeline.QueryParams - | GetAuthorFeed.QueryParams - | GetCustomFeed.QueryParams, + params: QueryParams, ): Promise< GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response > { diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts index 3f83dd6a7..78ffe8858 100644 --- a/src/state/models/ui/create-account.ts +++ b/src/state/models/ui/create-account.ts @@ -7,6 +7,7 @@ import * as EmailValidator from 'email-validator' import {createFullHandle} from 'lib/strings/handles' import {cleanError} from 'lib/strings/errors' 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 @@ -117,6 +118,8 @@ export class CreateAccountModel { this.setIsProcessing(false) this.setError(cleanError(errMsg)) throw e + } finally { + track('Create Account') } } diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts index 40265f7cf..2dd72980d 100644 --- a/src/state/models/ui/saved-feeds.ts +++ b/src/state/models/ui/saved-feeds.ts @@ -3,6 +3,7 @@ import {RootStoreModel} from '../root-store' import {bundleAsync} from 'lib/async/bundle' import {cleanError} from 'lib/strings/errors' import {CustomFeedModel} from '../feeds/custom-feed' +import {track} from 'lib/analytics/analytics' export class SavedFeedsModel { // state @@ -143,8 +144,16 @@ export class SavedFeedsModel { async togglePinnedFeed(feed: CustomFeedModel) { if (!this.isPinned(feed)) { + track('CustomFeed:Pin', { + name: feed.data.displayName, + uri: feed.uri, + }) return this.rootStore.preferences.addPinnedFeed(feed.uri) } else { + track('CustomFeed:Unpin', { + name: feed.data.displayName, + uri: feed.uri, + }) return this.rootStore.preferences.removePinnedFeed(feed.uri) } } @@ -185,6 +194,11 @@ export class SavedFeedsModel { this.rootStore.preferences.savedFeeds, pinned, ) + track('CustomFeed:Reorder', { + name: item.data.displayName, + uri: item.uri, + index: pinned.indexOf(item.uri), + }) } // state transitions diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 97200709b..d6cb1a0a7 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -56,9 +56,10 @@ export const CreateAccount = observer( } else { try { await model.submit() - track('Create Account') } catch { // dont need to handle here + } finally { + track('Try Create Account') } } }, [model, track]) diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx index af4f01874..c76c33938 100644 --- a/src/view/com/auth/login/Login.tsx +++ b/src/view/com/auth/login/Login.tsx @@ -327,7 +327,6 @@ const LoginForm = ({ identifier: fullIdent, password, }) - track('Sign In', {resumedSession: false}) } catch (e: any) { const errMsg = e.toString() store.log.warn('Failed to login', e) @@ -341,6 +340,8 @@ const LoginForm = ({ } else { setError(cleanError(errMsg)) } + } finally { + track('Sign In', {resumedSession: false}) } } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index fb6aaa231..4c73e58bf 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -169,9 +169,6 @@ export const ComposePost = observer(function ComposePost({ knownHandles: autocompleteView.knownHandles, langs: store.preferences.postLanguages, }) - track('Create Post', { - imageCount: gallery.size, - }) } catch (e: any) { if (extLink) { setExtLink({ @@ -183,6 +180,11 @@ export const ComposePost = observer(function ComposePost({ setError(cleanError(e.message)) setIsProcessing(false) return + } finally { + track('Create Post', { + imageCount: gallery.size, + }) + if (replyTo && replyTo.uri) track('Post:Reply') } if (!replyTo) { store.me.mainFeed.addPostToTop(createdPost.uri) diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 610b96507..51f63dbb3 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -9,10 +9,8 @@ import { } from 'react-native' import {AppBskyFeedDefs} from '@atproto/api' import {CenteredView, FlatList} from '../util/Views' -import { - PostThreadModel, - PostThreadItemModel, -} from 'state/models/content/post-thread' +import {PostThreadModel} from 'state/models/content/post-thread' +import {PostThreadItemModel} from 'state/models/content/post-thread-item' import { FontAwesomeIcon, FontAwesomeIconStyle, diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 647468401..002795d77 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -7,7 +7,7 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } 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 {RichText} from '../util/text/RichText' import {Text} from '../util/text/Text' diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index b9d146dee..3eac7ee7b 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -13,10 +13,8 @@ import {observer} from 'mobx-react-lite' import Clipboard from '@react-native-clipboard/clipboard' import {AtUri} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import { - PostThreadModel, - PostThreadItemModel, -} from 'state/models/content/post-thread' +import {PostThreadModel} from 'state/models/content/post-thread' +import {PostThreadItemModel} from 'state/models/content/post-thread-item' import {Link} from '../util/Link' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index 888466200..d75ff1385 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -1,6 +1,6 @@ import React from 'react' 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 {Link} from '../util/Link' import {Text} from '../util/text/Text' diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx index 4149cd49d..c0dcd7980 100644 --- a/src/view/screens/CustomFeed.tsx +++ b/src/view/screens/CustomFeed.tsx @@ -31,12 +31,14 @@ import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' import {DropdownButton, DropdownItem} from 'view/com/util/forms/DropdownButton' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {EmptyState} from 'view/com/util/EmptyState' +import {useAnalytics} from 'lib/analytics/analytics' type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'> export const CustomFeedScreen = withAuthRequired( observer(({route}: Props) => { const store = useStores() const pal = usePalette('default') + const {track} = useAnalytics() const {rkey, name} = route.params const uri = useMemo( () => makeRecordUri(name, 'app.bsky.feed.generator', rkey), @@ -99,7 +101,8 @@ export const CustomFeedScreen = withAuthRequired( const onPressShare = React.useCallback(() => { const url = toShareUrl(`/profile/${name}/feed/${rkey}`) shareUrl(url) - }, [name, rkey]) + track('CustomFeed:Share') + }, [name, rkey, track]) const onScrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: 0, animated: true}) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index f51bda825..390266440 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -9,7 +9,7 @@ import {CenteredView} from '../com/util/Views' import {ScreenHider} from 'view/com/util/moderation/ScreenHider' import {ProfileUiModel, Sections} from 'state/models/ui/profile' 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 {FeedSlice} from '../com/posts/FeedSlice' import {ListCard} from 'view/com/lists/ListCard' |