diff options
Diffstat (limited to 'src')
28 files changed, 734 insertions, 587 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/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index d6ece48aa..ba03fe1b5 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -7,6 +7,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker' import {ImageModel} from '../media/image' import {ListModel} from '../content/list' import {GalleryModel} from '../media/gallery' +import {StyleProp, ViewStyle} from 'react-native' export type ColorMode = 'system' | 'light' | 'dark' @@ -20,6 +21,8 @@ export interface ConfirmModal { message: string | (() => JSX.Element) onPressConfirm: () => void | Promise<void> onPressCancel?: () => void | Promise<void> + confirmBtnText?: string + confirmBtnStyle?: StyleProp<ViewStyle> } export interface EditProfileModal { 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 52b90b6c7..fc324d3e5 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -92,18 +92,24 @@ export const ComposePost = observer(function ComposePost({ const onPressCancel = useCallback(() => { if (graphemeLength > 0 || !gallery.isEmpty) { + if (store.shell.activeModals.some(modal => modal.name === 'confirm')) { + store.shell.closeModal() + } store.shell.openModal({ name: 'confirm', - title: 'Cancel draft', - onPressConfirm: onClose, + title: 'Discard draft', + onPressConfirm: hackfixOnClose, onPressCancel: () => { store.shell.closeModal() }, - message: "Are you sure you'd like to cancel this draft?", + message: "Are you sure you'd like to discard this draft?", + confirmBtnText: 'Discard', + confirmBtnStyle: {backgroundColor: colors.red4}, }) + } else { + hackfixOnClose() } - hackfixOnClose() - }, [store, hackfixOnClose, graphemeLength, gallery, onClose]) + }, [store, hackfixOnClose, graphemeLength, gallery]) // initial setup useEffect(() => { @@ -114,14 +120,10 @@ export const ComposePost = observer(function ComposePost({ const onEscape = useCallback( (e: KeyboardEvent) => { if (e.key === 'Escape') { - if (store.shell.activeModals.some(modal => modal.name === 'confirm')) { - store.shell.closeModal() - } - onPressCancel() } }, - [store, onPressCancel], + [onPressCancel], ) useEffect(() => { if (isDesktopWeb) { @@ -172,9 +174,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({ @@ -186,6 +185,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) { await store.me.mainFeed.addPostToTop(createdPost.uri) @@ -227,13 +231,13 @@ export const ComposePost = observer(function ComposePost({ <View style={[s.flex1, viewStyles]} aria-modal accessibilityViewIsModal> <View style={styles.topbar}> <TouchableOpacity - testID="composerCancelButton" + testID="composerDiscardButton" onPress={onPressCancel} onAccessibilityEscape={onPressCancel} accessibilityRole="button" - accessibilityLabel="Cancel" - accessibilityHint="Closes post composer"> - <Text style={[pal.link, s.f18]}>Cancel</Text> + accessibilityLabel="Discard" + accessibilityHint="Closes post composer and discards post draft"> + <Text style={[pal.link, s.f18, styles.discard]}>Discard</Text> </TouchableOpacity> <View style={s.flex1} /> {isProcessing ? ( @@ -386,6 +390,9 @@ const styles = StyleSheet.create({ paddingHorizontal: 20, height: 55, }, + discard: { + color: colors.red3, + }, postBtn: { borderRadius: 20, paddingHorizontal: 20, diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index f46c05333..c226d25cc 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -1,16 +1,16 @@ -import React, {useCallback} from 'react' +import React from 'react' import {ImageStyle, Keyboard} from 'react-native' import {GalleryModel} from 'state/models/media/gallery' import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {colors} from 'lib/styles' +import {s, colors} from 'lib/styles' import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {ImageModel} from 'state/models/media/image' import {Image} from 'expo-image' import {Text} from 'view/com/util/text/Text' import {isDesktopWeb} from 'platform/detection' import {openAltTextModal} from 'lib/media/alt-text' import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' interface Props { gallery: GalleryModel @@ -18,67 +18,39 @@ interface Props { export const Gallery = observer(function ({gallery}: Props) { const store = useStores() - const getImageStyle = useCallback(() => { - let side: number + const pal = usePalette('default') - if (gallery.size === 1) { - side = 250 - } else { - side = (isDesktopWeb ? 560 : 350) / gallery.size - } + let side: number - return { - height: side, - width: side, - } - }, [gallery]) - - const imageStyle = getImageStyle() - const handleAddImageAltText = useCallback( - (image: ImageModel) => { - Keyboard.dismiss() - openAltTextModal(store, image) - }, - [store], - ) - const handleRemovePhoto = useCallback( - (image: ImageModel) => { - gallery.remove(image) - }, - [gallery], - ) + if (gallery.size === 1) { + side = 250 + } else { + side = (isDesktopWeb ? 560 : 350) / gallery.size + } - const handleEditPhoto = useCallback( - (image: ImageModel) => { - gallery.edit(image) - }, - [gallery], - ) + const imageStyle = { + height: side, + width: side, + } const isOverflow = !isDesktopWeb && gallery.size > 2 - const imageControlLabelStyle = { - borderRadius: 5, - paddingHorizontal: 10, - position: 'absolute' as const, - zIndex: 1, - ...(isOverflow - ? { - left: 4, - bottom: 4, - } - : isDesktopWeb && gallery.size < 3 - ? { - left: 8, - top: 8, - } - : { - left: 4, - top: 4, - }), - } + const altTextControlStyle = isOverflow + ? { + left: 4, + bottom: 4, + } + : isDesktopWeb && gallery.size < 3 + ? { + left: 8, + top: 8, + } + : { + left: 4, + top: 4, + } - const imageControlsSubgroupStyle = { + const imageControlsStyle = { display: 'flex' as const, flexDirection: 'row' as const, position: 'absolute' as const, @@ -103,63 +75,90 @@ export const Gallery = observer(function ({gallery}: Props) { } return !gallery.isEmpty ? ( - <View testID="selectedPhotosView" style={styles.gallery}> - {gallery.images.map(image => ( - <View key={`selected-image-${image.path}`} style={[imageStyle]}> - <TouchableOpacity - testID="altTextButton" - accessibilityRole="button" - accessibilityLabel="Add alt text" - accessibilityHint="" - onPress={() => { - handleAddImageAltText(image) - }} - style={imageControlLabelStyle}> - <Text style={styles.imageControlTextContent}>ALT</Text> - </TouchableOpacity> - <View style={imageControlsSubgroupStyle}> + <> + <View testID="selectedPhotosView" style={styles.gallery}> + {gallery.images.map(image => ( + <View key={`selected-image-${image.path}`} style={[imageStyle]}> <TouchableOpacity - testID="editPhotoButton" + testID="altTextButton" accessibilityRole="button" - accessibilityLabel="Edit image" + accessibilityLabel="Add alt text" accessibilityHint="" onPress={() => { - handleEditPhoto(image) + Keyboard.dismiss() + openAltTextModal(store, image) }} - style={styles.imageControl}> - <FontAwesomeIcon - icon="pen" - size={12} - style={{color: colors.white}} - /> + style={[styles.altTextControl, altTextControlStyle]}> + <Text style={styles.altTextControlLabel}>ALT</Text> + {image.altText.length > 0 ? ( + <FontAwesomeIcon + icon="check" + size={10} + style={{color: colors.green3}} + /> + ) : undefined} </TouchableOpacity> + <View style={imageControlsStyle}> + <TouchableOpacity + testID="editPhotoButton" + accessibilityRole="button" + accessibilityLabel="Edit image" + accessibilityHint="" + onPress={() => gallery.edit(image)} + style={styles.imageControl}> + <FontAwesomeIcon + icon="pen" + size={12} + style={{color: colors.white}} + /> + </TouchableOpacity> + <TouchableOpacity + testID="removePhotoButton" + accessibilityRole="button" + accessibilityLabel="Remove image" + accessibilityHint="" + onPress={() => gallery.remove(image)} + style={styles.imageControl}> + <FontAwesomeIcon + icon="xmark" + size={16} + style={{color: colors.white}} + /> + </TouchableOpacity> + </View> <TouchableOpacity - testID="removePhotoButton" accessibilityRole="button" - accessibilityLabel="Remove image" + accessibilityLabel="Add alt text" accessibilityHint="" - onPress={() => handleRemovePhoto(image)} - style={styles.imageControl}> - <FontAwesomeIcon - icon="xmark" - size={16} - style={{color: colors.white}} - /> - </TouchableOpacity> - </View> + onPress={() => { + Keyboard.dismiss() + openAltTextModal(store, image) + }} + style={styles.altTextHiddenRegion} + /> - <Image - testID="selectedPhotoImage" - style={[styles.image, imageStyle] as ImageStyle} - source={{ - uri: image.cropped?.path ?? image.path, - }} - accessible={true} - accessibilityIgnoresInvertColors - /> + <Image + testID="selectedPhotoImage" + style={[styles.image, imageStyle] as ImageStyle} + source={{ + uri: image.cropped?.path ?? image.path, + }} + accessible={true} + accessibilityIgnoresInvertColors + /> + </View> + ))} + </View> + <View style={[styles.reminder]}> + <View style={[styles.infoIcon, pal.viewLight]}> + <FontAwesomeIcon icon="info" size={12} color={pal.colors.text} /> </View> - ))} - </View> + <Text type="sm" style={[pal.textLight, s.flex1]}> + Alt text describes images for blind and low-vision users, and helps + give context to everyone. + </Text> + </View> + </> ) : null }) @@ -179,19 +178,46 @@ const styles = StyleSheet.create({ height: 24, borderRadius: 12, backgroundColor: 'rgba(0, 0, 0, 0.75)', - borderWidth: 0.5, alignItems: 'center', justifyContent: 'center', }, - imageControlTextContent: { + altTextControl: { + position: 'absolute', + zIndex: 1, borderRadius: 6, + backgroundColor: 'rgba(0, 0, 0, 0.75)', + paddingHorizontal: 8, + paddingVertical: 3, + flexDirection: 'row', + alignItems: 'center', + }, + altTextControlLabel: { color: 'white', fontSize: 12, fontWeight: 'bold', letterSpacing: 1, - backgroundColor: 'rgba(0, 0, 0, 0.75)', - borderWidth: 0.5, - paddingHorizontal: 10, - paddingVertical: 3, + }, + altTextHiddenRegion: { + position: 'absolute', + left: 4, + right: 4, + bottom: 4, + top: 30, + zIndex: 1, + }, + + reminder: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + borderRadius: 8, + paddingVertical: 14, + }, + infoIcon: { + width: 22, + height: 22, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', }, }) diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx index 07270d557..e1145a0fe 100644 --- a/src/view/com/modals/AltImage.tsx +++ b/src/view/com/modals/AltImage.tsx @@ -1,5 +1,15 @@ -import React, {useCallback, useState} from 'react' -import {StyleSheet, TextInput, TouchableOpacity, View} from 'react-native' +import React, {useMemo, useCallback, useState} from 'react' +import { + ImageStyle, + KeyboardAvoidingView, + ScrollView, + StyleSheet, + TextInput, + TouchableOpacity, + View, + useWindowDimensions, +} from 'react-native' +import {Image} from 'expo-image' import {usePalette} from 'lib/hooks/usePalette' import {gradients, s} from 'lib/styles' import {enforceLen} from 'lib/strings/helpers' @@ -8,7 +18,7 @@ import {useTheme} from 'lib/ThemeContext' import {Text} from '../util/text/Text' import LinearGradient from 'react-native-linear-gradient' import {useStores} from 'state/index' -import {isDesktopWeb} from 'platform/detection' +import {isDesktopWeb, isAndroid} from 'platform/detection' import {ImageModel} from 'state/models/media/image' export const snapPoints = ['fullscreen'] @@ -22,6 +32,24 @@ export function Component({image}: Props) { const store = useStores() const theme = useTheme() const [altText, setAltText] = useState(image.altText) + const windim = useWindowDimensions() + + const imageStyles = useMemo<ImageStyle>(() => { + const maxWidth = isDesktopWeb ? 450 : windim.width + if (image.height > image.width) { + return { + resizeMode: 'contain', + width: '100%', + aspectRatio: 1, + borderRadius: 8, + } + } + return { + width: '100%', + height: (maxWidth / image.width) * image.height, + borderRadius: 8, + } + }, [image, windim]) const onPressSave = useCallback(() => { image.setAltText(altText) @@ -33,69 +61,94 @@ export function Component({image}: Props) { } return ( - <View - testID="altTextImageModal" - style={[pal.view, styles.container, s.flex1]} - nativeID="imageAltText"> - <Text style={[styles.title, pal.text]}>Add alt text</Text> - <TextInput - testID="altTextImageInput" - style={[styles.textArea, pal.border, pal.text]} - keyboardAppearance={theme.colorScheme} - multiline - value={altText} - onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} - accessibilityLabel="Image alt text" - accessibilityHint="Sets image alt text for screenreaders" - accessibilityLabelledBy="imageAltText" - /> - <View style={styles.buttonControls}> - <TouchableOpacity - testID="altTextImageSaveBtn" - onPress={onPressSave} - accessibilityLabel="Save alt text" - accessibilityHint={`Saves alt text, which reads: ${altText}`} - accessibilityRole="button"> - <LinearGradient - colors={[gradients.blueLight.start, gradients.blueLight.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.button]}> - <Text type="button-lg" style={[s.white, s.bold]}> - Save - </Text> - </LinearGradient> - </TouchableOpacity> - <TouchableOpacity - testID="altTextImageCancelBtn" - onPress={onPressCancel} - accessibilityRole="button" - accessibilityLabel="Cancel add image alt text" - accessibilityHint="Exits adding alt text to image" - onAccessibilityEscape={onPressCancel}> - <View style={[styles.button]}> - <Text type="button-lg" style={[pal.textLight]}> - Cancel - </Text> + <KeyboardAvoidingView + behavior={isAndroid ? 'height' : 'padding'} + style={[pal.view, styles.container]}> + <ScrollView + testID="altTextImageModal" + style={styles.scrollContainer} + keyboardShouldPersistTaps="always" + nativeID="imageAltText"> + <View style={styles.scrollInner}> + <View style={[pal.viewLight, styles.imageContainer]}> + <Image + testID="selectedPhotoImage" + style={imageStyles} + source={{ + uri: image.cropped?.path ?? image.path, + }} + accessible={true} + accessibilityIgnoresInvertColors + /> + </View> + <TextInput + testID="altTextImageInput" + style={[styles.textArea, pal.border, pal.text]} + keyboardAppearance={theme.colorScheme} + multiline + placeholder="Add alt text" + placeholderTextColor={pal.colors.textLight} + value={altText} + onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} + accessibilityLabel="Image alt text" + accessibilityHint="" + accessibilityLabelledBy="imageAltText" + autoFocus + /> + <View style={styles.buttonControls}> + <TouchableOpacity + testID="altTextImageSaveBtn" + onPress={onPressSave} + accessibilityLabel="Save alt text" + accessibilityHint={`Saves alt text, which reads: ${altText}`} + accessibilityRole="button"> + <LinearGradient + colors={[gradients.blueLight.start, gradients.blueLight.end]} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[styles.button]}> + <Text type="button-lg" style={[s.white, s.bold]}> + Save + </Text> + </LinearGradient> + </TouchableOpacity> + <TouchableOpacity + testID="altTextImageCancelBtn" + onPress={onPressCancel} + accessibilityRole="button" + accessibilityLabel="Cancel add image alt text" + accessibilityHint="" + onAccessibilityEscape={onPressCancel}> + <View style={[styles.button]}> + <Text type="button-lg" style={[pal.textLight]}> + Cancel + </Text> + </View> + </TouchableOpacity> </View> - </TouchableOpacity> - </View> - </View> + </View> + </ScrollView> + </KeyboardAvoidingView> ) } const styles = StyleSheet.create({ container: { - gap: 18, - paddingVertical: isDesktopWeb ? 0 : 18, - paddingHorizontal: isDesktopWeb ? 0 : 12, + flex: 1, height: '100%', width: '100%', + paddingVertical: isDesktopWeb ? 0 : 18, + }, + scrollContainer: { + flex: 1, + height: '100%', + paddingHorizontal: isDesktopWeb ? 0 : 12, + }, + scrollInner: { + gap: 12, }, - title: { - textAlign: 'center', - fontWeight: 'bold', - fontSize: 24, + imageContainer: { + borderRadius: 8, }, textArea: { borderWidth: 1, diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx index 11e1a6334..f9bc0de14 100644 --- a/src/view/com/modals/Confirm.tsx +++ b/src/view/com/modals/Confirm.tsx @@ -12,6 +12,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage' import {cleanError} from 'lib/strings/errors' import {usePalette} from 'lib/hooks/usePalette' import {isDesktopWeb} from 'platform/detection' +import type {ConfirmModal} from 'state/models/ui/shell' export const snapPoints = ['50%'] @@ -20,12 +21,9 @@ export function Component({ message, onPressConfirm, onPressCancel, -}: { - title: string - message: string | (() => JSX.Element) - onPressConfirm: () => void | Promise<void> - onPressCancel?: () => void | Promise<void> -}) { + confirmBtnText, + confirmBtnStyle, +}: ConfirmModal) { const pal = usePalette('default') const store = useStores() const [isProcessing, setIsProcessing] = useState<boolean>(false) @@ -68,11 +66,13 @@ export function Component({ <TouchableOpacity testID="confirmBtn" onPress={onPress} - style={[styles.btn]} + style={[styles.btn, confirmBtnStyle]} accessibilityRole="button" accessibilityLabel="Confirm" accessibilityHint=""> - <Text style={[s.white, s.bold, s.f18]}>Confirm</Text> + <Text style={[s.white, s.bold, s.f18]}> + {confirmBtnText ?? 'Confirm'} + </Text> </TouchableOpacity> )} {onPressCancel === undefined ? null : ( 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/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index 723db289c..a7a64b171 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -45,23 +45,28 @@ export const GalleryItem: FC<GalleryItemProps> = ({ accessibilityIgnoresInvertColors /> </TouchableOpacity> - {image.alt === '' ? null : <Text style={styles.alt}>ALT</Text>} + {image.alt === '' ? null : ( + <View style={styles.altContainer}> + <Text style={styles.alt}>ALT</Text> + </View> + )} </View> ) } const styles = StyleSheet.create({ - alt: { + altContainer: { backgroundColor: 'rgba(0, 0, 0, 0.75)', borderRadius: 6, - color: 'white', - fontSize: 12, - fontWeight: 'bold', - letterSpacing: 1, - paddingHorizontal: 10, + paddingHorizontal: 6, paddingVertical: 3, position: 'absolute', left: 6, bottom: 6, }, + alt: { + color: 'white', + fontSize: 10, + fontWeight: 'bold', + }, }) diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 53ef17318..7f2244b7b 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -126,7 +126,11 @@ export function PostEmbeds({ onPress={() => openLightbox(0)} onPressIn={() => onPressIn(0)} style={styles.singleImage}> - {alt === '' ? null : <Text style={styles.alt}>ALT</Text>} + {alt === '' ? null : ( + <View style={styles.altContainer}> + <Text style={styles.alt}>ALT</Text> + </View> + )} </AutoSizedImage> </View> ) @@ -201,17 +205,18 @@ const styles = StyleSheet.create({ borderRadius: 8, marginTop: 4, }, - alt: { + altContainer: { backgroundColor: 'rgba(0, 0, 0, 0.75)', borderRadius: 6, - color: 'white', - fontSize: 12, - fontWeight: 'bold', - letterSpacing: 1, - paddingHorizontal: 10, + paddingHorizontal: 6, paddingVertical: 3, position: 'absolute', left: 6, bottom: 6, }, + alt: { + color: 'white', + fontSize: 10, + fontWeight: 'bold', + }, }) 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' |