diff options
Diffstat (limited to 'src')
25 files changed, 305 insertions, 755 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx index d45376ef1..2422491e2 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -67,6 +67,7 @@ import {getRoutingInstrumentation} from 'lib/sentry' import {bskyTitle} from 'lib/strings/headings' import {JSX} from 'react/jsx-runtime' import {timeout} from 'lib/async/timeout' +import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed' const navigationRef = createNavigationContainerRef<AllNavigatorParams>() @@ -219,6 +220,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { component={SavedFeeds} options={{title: title('Edit My Feeds')}} /> + <Stack.Screen + name="PreferencesHomeFeed" + component={PreferencesHomeFeed} + options={{title: title('Home Feed Preferences')}} + /> </> ) } diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 472289b40..60b0f2641 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -105,6 +105,7 @@ export class FeedTuner { tune( feed: FeedViewPost[], tunerFns: FeedTunerFn[] = [], + {dryRun}: {dryRun: boolean} = {dryRun: false}, ): FeedViewPostsSlice[] { let slices: FeedViewPostsSlice[] = [] @@ -156,9 +157,11 @@ export class FeedTuner { } } - for (const slice of slices) { - for (const item of slice.items) { - this.seenUris.add(item.post.uri) + if (!dryRun) { + for (const slice of slices) { + for (const item of slice.items) { + this.seenUris.add(item.post.uri) + } } } diff --git a/src/lib/hooks/usePermissions.ts b/src/lib/hooks/usePermissions.ts index 9cb4a80dd..138f3eaca 100644 --- a/src/lib/hooks/usePermissions.ts +++ b/src/lib/hooks/usePermissions.ts @@ -29,9 +29,14 @@ export function usePhotoLibraryPermission() { if (res?.granted) { return true - } else if (!res || res?.status === 'undetermined' || res?.canAskAgain) { - const updatedRes = await requestPermission() - return updatedRes?.granted + } else if (!res || res.status === 'undetermined' || res?.canAskAgain) { + const {canAskAgain, granted, status} = await requestPermission() + + if (!canAskAgain && status === 'undetermined') { + openPermissionAlert('photo library') + } + + return granted } else { openPermissionAlert('photo library') return false diff --git a/src/lib/labeling/helpers.ts b/src/lib/labeling/helpers.ts deleted file mode 100644 index 447b0a99a..000000000 --- a/src/lib/labeling/helpers.ts +++ /dev/null @@ -1,436 +0,0 @@ -import { - AppBskyActorDefs, - AppBskyGraphDefs, - AppBskyEmbedRecordWithMedia, - AppBskyEmbedRecord, - AppBskyEmbedImages, - AppBskyEmbedExternal, -} from '@atproto/api' -import { - CONFIGURABLE_LABEL_GROUPS, - ILLEGAL_LABEL_GROUP, - ALWAYS_FILTER_LABEL_GROUP, - ALWAYS_WARN_LABEL_GROUP, - UNKNOWN_LABEL_GROUP, -} from './const' -import { - Label, - LabelValGroup, - ModerationBehaviorCode, - ModerationBehavior, - PostModeration, - ProfileModeration, - PostLabelInfo, - ProfileLabelInfo, -} from './types' -import {RootStoreModel} from 'state/index' - -type Embed = - | AppBskyEmbedRecord.View - | AppBskyEmbedImages.View - | AppBskyEmbedExternal.View - | AppBskyEmbedRecordWithMedia.View - | {$type: string; [k: string]: unknown} - -export function getLabelValueGroup(labelVal: string): LabelValGroup { - let id: keyof typeof CONFIGURABLE_LABEL_GROUPS - for (id in CONFIGURABLE_LABEL_GROUPS) { - if (ILLEGAL_LABEL_GROUP.values.includes(labelVal)) { - return ILLEGAL_LABEL_GROUP - } - if (ALWAYS_FILTER_LABEL_GROUP.values.includes(labelVal)) { - return ALWAYS_FILTER_LABEL_GROUP - } - if (ALWAYS_WARN_LABEL_GROUP.values.includes(labelVal)) { - return ALWAYS_WARN_LABEL_GROUP - } - if (CONFIGURABLE_LABEL_GROUPS[id].values.includes(labelVal)) { - return CONFIGURABLE_LABEL_GROUPS[id] - } - } - return UNKNOWN_LABEL_GROUP -} - -export function getPostModeration( - store: RootStoreModel, - postInfo: PostLabelInfo, -): PostModeration { - const accountPref = store.preferences.getLabelPreference( - postInfo.accountLabels, - ) - const profilePref = store.preferences.getLabelPreference( - postInfo.profileLabels, - ) - const postPref = store.preferences.getLabelPreference(postInfo.postLabels) - - // avatar - let avatar = { - warn: accountPref.pref === 'hide' || accountPref.pref === 'warn', - blur: - postInfo.isBlocking || - accountPref.pref === 'hide' || - accountPref.pref === 'warn' || - profilePref.pref === 'hide' || - profilePref.pref === 'warn', - } - - // hide no-override cases - if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') { - return hidePostNoOverride(accountPref.desc.warning) - } - if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') { - return hidePostNoOverride(profilePref.desc.warning) - } - if (postPref.pref === 'hide' && postPref.desc.id === 'illegal') { - return hidePostNoOverride(postPref.desc.warning) - } - - // hide cases - if (postInfo.isBlocking) { - return { - avatar, - list: hide('Post from an account you blocked.'), - thread: hide('Post from an account you blocked.'), - view: warn('Post from an account you blocked.'), - } - } - if (postInfo.isBlockedBy) { - return { - avatar, - list: hide('Post from an account that has blocked you.'), - thread: hide('Post from an account that has blocked you.'), - view: warn('Post from an account that has blocked you.'), - } - } - if (accountPref.pref === 'hide') { - return { - avatar, - list: hide(accountPref.desc.warning), - thread: hide(accountPref.desc.warning), - view: warn(accountPref.desc.warning), - } - } - if (profilePref.pref === 'hide') { - return { - avatar, - list: hide(profilePref.desc.warning), - thread: hide(profilePref.desc.warning), - view: warn(profilePref.desc.warning), - } - } - if (postPref.pref === 'hide') { - return { - avatar, - list: hide(postPref.desc.warning), - thread: hide(postPref.desc.warning), - view: warn(postPref.desc.warning), - } - } - - // muting - if (postInfo.isMuted) { - let msg = 'Post from an account you muted.' - if (postInfo.mutedByList) { - msg = `Muted by ${postInfo.mutedByList.name}` - } - return { - avatar, - list: isMute(hide(msg)), - thread: isMute(warn(msg)), - view: isMute(warn(msg)), - } - } - - // warning cases - if (postPref.pref === 'warn') { - if (postPref.desc.isAdultImagery) { - return { - avatar, - list: warnImages(postPref.desc.warning), - thread: warnImages(postPref.desc.warning), - view: warnImages(postPref.desc.warning), - } - } - return { - avatar, - list: warnContent(postPref.desc.warning), - thread: warnContent(postPref.desc.warning), - view: warnContent(postPref.desc.warning), - } - } - if (accountPref.pref === 'warn') { - return { - avatar, - list: warnContent(accountPref.desc.warning), - thread: warnContent(accountPref.desc.warning), - view: warnContent(accountPref.desc.warning), - } - } - - return { - avatar, - list: show(), - thread: show(), - view: show(), - } -} - -export function mergePostModerations( - moderations: PostModeration[], -): PostModeration { - const merged: PostModeration = { - avatar: {warn: false, blur: false}, - list: show(), - thread: show(), - view: show(), - } - for (const mod of moderations) { - if (mod.list.behavior === ModerationBehaviorCode.Hide) { - merged.list = mod.list - } - if (mod.thread.behavior === ModerationBehaviorCode.Hide) { - merged.thread = mod.thread - } - if (mod.view.behavior === ModerationBehaviorCode.Hide) { - merged.view = mod.view - } - } - return merged -} - -export function getProfileModeration( - store: RootStoreModel, - profileInfo: ProfileLabelInfo, -): ProfileModeration { - const accountPref = store.preferences.getLabelPreference( - profileInfo.accountLabels, - ) - const profilePref = store.preferences.getLabelPreference( - profileInfo.profileLabels, - ) - - // avatar - let avatar = { - warn: accountPref.pref === 'hide' || accountPref.pref === 'warn', - blur: - profileInfo.isBlocking || - accountPref.pref === 'hide' || - accountPref.pref === 'warn' || - profilePref.pref === 'hide' || - profilePref.pref === 'warn', - } - - // hide no-override cases - if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') { - return hideProfileNoOverride(accountPref.desc.warning) - } - if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') { - return hideProfileNoOverride(profilePref.desc.warning) - } - - // hide cases - if (accountPref.pref === 'hide') { - return { - avatar, - list: hide(accountPref.desc.warning), - view: hide(accountPref.desc.warning), - } - } - if (profilePref.pref === 'hide') { - return { - avatar, - list: hide(profilePref.desc.warning), - view: hide(profilePref.desc.warning), - } - } - - // warn cases - if (accountPref.pref === 'warn') { - return { - avatar, - list: - profileInfo.isBlocking || profileInfo.isBlockedBy - ? hide('Blocked account') - : warn(accountPref.desc.warning), - view: warn(accountPref.desc.warning), - } - } - // we don't warn for this - // if (profilePref.pref === 'warn') { - // return { - // avatar, - // list: warn(profilePref.desc.warning), - // view: warn(profilePref.desc.warning), - // } - // } - - return { - avatar, - list: profileInfo.isBlocking ? hide('Blocked account') : show(), - view: show(), - } -} - -export function getProfileViewBasicLabelInfo( - profile: AppBskyActorDefs.ProfileViewBasic, -): ProfileLabelInfo { - return { - accountLabels: filterAccountLabels(profile.labels), - profileLabels: filterProfileLabels(profile.labels), - isMuted: profile.viewer?.muted || false, - isBlocking: !!profile.viewer?.blocking || false, - isBlockedBy: !!profile.viewer?.blockedBy || false, - } -} - -export function getEmbedLabels(embed?: Embed): Label[] { - if (!embed) { - return [] - } - if ( - AppBskyEmbedRecord.isView(embed) && - AppBskyEmbedRecord.isViewRecord(embed.record) - ) { - return embed.record.labels || [] - } - return [] -} - -export function getEmbedMuted(embed?: Embed): boolean { - if (!embed) { - return false - } - if ( - AppBskyEmbedRecord.isView(embed) && - AppBskyEmbedRecord.isViewRecord(embed.record) - ) { - return !!embed.record.author.viewer?.muted - } - return false -} - -export function getEmbedMutedByList( - embed?: Embed, -): AppBskyGraphDefs.ListViewBasic | undefined { - if (!embed) { - return undefined - } - if ( - AppBskyEmbedRecord.isView(embed) && - AppBskyEmbedRecord.isViewRecord(embed.record) - ) { - return embed.record.author.viewer?.mutedByList - } - return undefined -} - -export function getEmbedBlocking(embed?: Embed): boolean { - if (!embed) { - return false - } - if ( - AppBskyEmbedRecord.isView(embed) && - AppBskyEmbedRecord.isViewRecord(embed.record) - ) { - return !!embed.record.author.viewer?.blocking - } - return false -} - -export function getEmbedBlockedBy(embed?: Embed): boolean { - if (!embed) { - return false - } - if ( - AppBskyEmbedRecord.isView(embed) && - AppBskyEmbedRecord.isViewRecord(embed.record) - ) { - return !!embed.record.author.viewer?.blockedBy - } - return false -} - -export function filterAccountLabels(labels?: Label[]): Label[] { - if (!labels) { - return [] - } - return labels.filter( - label => !label.uri.endsWith('/app.bsky.actor.profile/self'), - ) -} - -export function filterProfileLabels(labels?: Label[]): Label[] { - if (!labels) { - return [] - } - return labels.filter(label => - label.uri.endsWith('/app.bsky.actor.profile/self'), - ) -} - -// internal methods -// = - -function show() { - return { - behavior: ModerationBehaviorCode.Show, - } -} - -function hidePostNoOverride(reason: string) { - return { - avatar: {warn: true, blur: true}, - list: hideNoOverride(reason), - thread: hideNoOverride(reason), - view: hideNoOverride(reason), - } -} - -function hideProfileNoOverride(reason: string) { - return { - avatar: {warn: true, blur: true}, - list: hideNoOverride(reason), - view: hideNoOverride(reason), - } -} - -function hideNoOverride(reason: string) { - return { - behavior: ModerationBehaviorCode.Hide, - reason, - noOverride: true, - } -} - -function hide(reason: string) { - return { - behavior: ModerationBehaviorCode.Hide, - reason, - } -} - -function warn(reason: string) { - return { - behavior: ModerationBehaviorCode.Warn, - reason, - } -} - -function warnContent(reason: string) { - return { - behavior: ModerationBehaviorCode.WarnContent, - reason, - } -} - -function isMute(behavior: ModerationBehavior): ModerationBehavior { - behavior.isMute = true - return behavior -} - -function warnImages(reason: string) { - return { - behavior: ModerationBehaviorCode.WarnImages, - reason, - } -} diff --git a/src/lib/labeling/types.ts b/src/lib/labeling/types.ts index 1ee058024..84d59be7f 100644 --- a/src/lib/labeling/types.ts +++ b/src/lib/labeling/types.ts @@ -1,4 +1,4 @@ -import {ComAtprotoLabelDefs, AppBskyGraphDefs} from '@atproto/api' +import {ComAtprotoLabelDefs} from '@atproto/api' import {LabelPreferencesModel} from 'state/models/ui/preferences' export type Label = ComAtprotoLabelDefs.Label @@ -16,54 +16,3 @@ export interface LabelValGroup { warning: string values: string[] } - -export interface PostLabelInfo { - postLabels: Label[] - accountLabels: Label[] - profileLabels: Label[] - isMuted: boolean - mutedByList?: AppBskyGraphDefs.ListViewBasic - isBlocking: boolean - isBlockedBy: boolean -} - -export interface ProfileLabelInfo { - accountLabels: Label[] - profileLabels: Label[] - isMuted: boolean - isBlocking: boolean - isBlockedBy: boolean -} - -export enum ModerationBehaviorCode { - Show, - Hide, - Warn, - WarnContent, - WarnImages, -} - -export interface ModerationBehavior { - behavior: ModerationBehaviorCode - isMute?: boolean - noOverride?: boolean - reason?: string -} - -export interface AvatarModeration { - warn: boolean - blur: boolean -} - -export interface PostModeration { - avatar: AvatarModeration - list: ModerationBehavior - thread: ModerationBehavior - view: ModerationBehavior -} - -export interface ProfileModeration { - avatar: AvatarModeration - list: ModerationBehavior - view: ModerationBehavior -} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 4eb5e29d2..7159bcb51 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -29,6 +29,7 @@ export type CommonNavigatorParams = { CopyrightPolicy: undefined AppPasswords: undefined SavedFeeds: undefined + PreferencesHomeFeed: undefined } export type BottomTabNavigatorParams = CommonNavigatorParams & { diff --git a/src/routes.ts b/src/routes.ts index 54faba22d..45a8fa572 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -23,6 +23,7 @@ export const router = new Router({ Debug: '/sys/debug', Log: '/sys/log', AppPasswords: '/settings/app-passwords', + PreferencesHomeFeed: '/settings/home-feed', SavedFeeds: '/settings/saved-feeds', Support: '/support', PrivacyPolicy: '/support/privacy', diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 15145a203..c88249c8f 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -277,7 +277,9 @@ export class PostsFeedModel { } const res = await this._getFeed({limit: 1}) if (res.data.feed[0]) { - const slices = this.tuner.tune(res.data.feed, this.feedTuners) + const slices = this.tuner.tune(res.data.feed, this.feedTuners, { + dryRun: true, + }) if (slices[0]) { const sliceModel = new PostsFeedSliceModel(this.rootStore, slices[0]) if (sliceModel.moderation.content.filter) { @@ -374,6 +376,15 @@ export class PostsFeedModel { const toAppend: PostsFeedSliceModel[] = [] for (const slice of slices) { const sliceModel = new PostsFeedSliceModel(this.rootStore, slice) + const dupTest = (item: PostsFeedSliceModel) => + item._reactKey === sliceModel._reactKey + // sanity check + // if a duplicate _reactKey passes through, the UI breaks hard + if (!replace) { + if (this.slices.find(dupTest) || toAppend.find(dupTest)) { + continue + } + } toAppend.push(sliceModel) } runInAction(() => { diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index e9ffe28c2..3b03cdca1 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -4,21 +4,9 @@ import AwaitLock from 'await-lock' import isEqual from 'lodash.isequal' import {isObj, hasProp} from 'lib/type-guards' import {RootStoreModel} from '../root-store' -import { - ComAtprotoLabelDefs, - AppBskyActorDefs, - ModerationOpts, -} from '@atproto/api' -import {LabelValGroup} from 'lib/labeling/types' -import {getLabelValueGroup} from 'lib/labeling/helpers' -import { - UNKNOWN_LABEL_GROUP, - ILLEGAL_LABEL_GROUP, - ALWAYS_FILTER_LABEL_GROUP, - ALWAYS_WARN_LABEL_GROUP, -} from 'lib/labeling/const' +import {ModerationOpts} from '@atproto/api' import {DEFAULT_FEEDS} from 'lib/constants' -import {isIOS, deviceLocales} from 'platform/detection' +import {deviceLocales} from 'platform/detection' import {LANGUAGES} from '../../../locale/languages' // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf @@ -32,7 +20,7 @@ const LABEL_GROUPS = [ 'spam', 'impersonation', ] -const VISIBILITY_VALUES = ['show', 'warn', 'hide'] +const VISIBILITY_VALUES = ['ignore', 'warn', 'hide'] const DEFAULT_LANG_CODES = (deviceLocales || []) .concat(['en', 'ja', 'pt', 'de']) .slice(0, 6) @@ -52,7 +40,7 @@ export class LabelPreferencesModel { } export class PreferencesModel { - adultContentEnabled = !isIOS + adultContentEnabled = false contentLanguages: string[] = deviceLocales || [] postLanguage: string = deviceLocales[0] || 'en' postLanguageHistory: string[] = DEFAULT_LANG_CODES @@ -189,43 +177,32 @@ export class PreferencesModel { await this.lock.acquireAsync() try { // fetch preferences - let hasSavedFeedsPref = false - const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) + const prefs = await this.rootStore.agent.getPreferences() + runInAction(() => { - for (const pref of res.data.preferences) { + this.adultContentEnabled = prefs.adultContentEnabled + for (const label in prefs.contentLabels) { if ( - AppBskyActorDefs.isAdultContentPref(pref) && - AppBskyActorDefs.validateAdultContentPref(pref).success - ) { - this.adultContentEnabled = pref.enabled - } else if ( - AppBskyActorDefs.isContentLabelPref(pref) && - AppBskyActorDefs.validateAdultContentPref(pref).success + LABEL_GROUPS.includes(label) && + VISIBILITY_VALUES.includes(prefs.contentLabels[label]) ) { - if ( - LABEL_GROUPS.includes(pref.label) && - VISIBILITY_VALUES.includes(pref.visibility) - ) { - this.contentLabels[pref.label as keyof LabelPreferencesModel] = - pref.visibility as LabelPreference - } - } else if ( - AppBskyActorDefs.isSavedFeedsPref(pref) && - AppBskyActorDefs.validateSavedFeedsPref(pref).success - ) { - if (!isEqual(this.savedFeeds, pref.saved)) { - this.savedFeeds = pref.saved - } - if (!isEqual(this.pinnedFeeds, pref.pinned)) { - this.pinnedFeeds = pref.pinned - } - hasSavedFeedsPref = true + this.contentLabels[label as keyof LabelPreferencesModel] = + prefs.contentLabels[label] } } + if (prefs.feeds.saved && !isEqual(this.savedFeeds, prefs.feeds.saved)) { + this.savedFeeds = prefs.feeds.saved + } + if ( + prefs.feeds.pinned && + !isEqual(this.pinnedFeeds, prefs.feeds.pinned) + ) { + this.pinnedFeeds = prefs.feeds.pinned + } }) // set defaults on missing items - if (!hasSavedFeedsPref) { + if (typeof prefs.feeds.saved === 'undefined') { const {saved, pinned} = await DEFAULT_FEEDS( this.rootStore.agent.service.toString(), (handle: string) => @@ -237,14 +214,7 @@ export class PreferencesModel { this.savedFeeds = saved this.pinnedFeeds = pinned }) - res.data.preferences.push({ - $type: 'app.bsky.actor.defs#savedFeedsPref', - saved, - pinned, - }) - await this.rootStore.agent.app.bsky.actor.putPreferences({ - preferences: res.data.preferences, - }) + await this.rootStore.agent.setSavedFeeds(saved, pinned) } } finally { this.lock.release() @@ -254,35 +224,6 @@ export class PreferencesModel { } /** - * This function updates the preferences of a user and allows for a callback function to be executed - * before the update. - * @param cb - cb is a callback function that takes in a single parameter of type - * AppBskyActorDefs.Preferences and returns either a boolean or void. This callback function is used to - * update the preferences of the user. The function is called with the current preferences as an - * argument and if the callback returns false, the preferences are not updated. - * @returns void - */ - async update( - cb: ( - prefs: AppBskyActorDefs.Preferences, - ) => AppBskyActorDefs.Preferences | false, - ) { - await this.lock.acquireAsync() - try { - const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) - const newPrefs = cb(res.data.preferences) - if (newPrefs === false) { - return - } - await this.rootStore.agent.app.bsky.actor.putPreferences({ - preferences: newPrefs, - }) - } finally { - this.lock.release() - } - } - - /** * This function resets the preferences to an empty array of no preferences. */ async reset() { @@ -381,84 +322,12 @@ export class PreferencesModel { value: LabelPreference, ) { this.contentLabels[key] = value - - await this.update((prefs: AppBskyActorDefs.Preferences) => { - const existing = prefs.find( - pref => - AppBskyActorDefs.isContentLabelPref(pref) && - AppBskyActorDefs.validateAdultContentPref(pref).success && - pref.label === key, - ) - if (existing) { - existing.visibility = value - } else { - prefs.push({ - $type: 'app.bsky.actor.defs#contentLabelPref', - label: key, - visibility: value, - }) - } - return prefs - }) + await this.rootStore.agent.setContentLabelPref(key, value) } async setAdultContentEnabled(v: boolean) { this.adultContentEnabled = v - await this.update((prefs: AppBskyActorDefs.Preferences) => { - const existing = prefs.find( - pref => - AppBskyActorDefs.isAdultContentPref(pref) && - AppBskyActorDefs.validateAdultContentPref(pref).success, - ) - if (existing) { - existing.enabled = v - } else { - prefs.push({ - $type: 'app.bsky.actor.defs#adultContentPref', - enabled: v, - }) - } - return prefs - }) - } - - getLabelPreference(labels: ComAtprotoLabelDefs.Label[] | undefined): { - pref: LabelPreference - desc: LabelValGroup - } { - let res: {pref: LabelPreference; desc: LabelValGroup} = { - pref: 'show', - desc: UNKNOWN_LABEL_GROUP, - } - if (!labels?.length) { - return res - } - for (const label of labels) { - const group = getLabelValueGroup(label.val) - if (group.id === 'illegal') { - return {pref: 'hide', desc: ILLEGAL_LABEL_GROUP} - } else if (group.id === 'always-filter') { - return {pref: 'hide', desc: ALWAYS_FILTER_LABEL_GROUP} - } else if (group.id === 'always-warn') { - res.pref = 'warn' - res.desc = ALWAYS_WARN_LABEL_GROUP - continue - } else if (group.id === 'unknown') { - continue - } - let pref = this.contentLabels[group.id] - if (pref === 'hide') { - res.pref = 'hide' - res.desc = group - } else if (pref === 'warn' && res.pref === 'show') { - res.pref = 'warn' - res.desc = group - } - } - if (res.desc.isAdultImagery && !this.adultContentEnabled) { - res.pref = 'hide' - } - return res + await this.rootStore.agent.setAdultContentEnabled(v) } get moderationOpts(): ModerationOpts { @@ -499,31 +368,20 @@ export class PreferencesModel { } } - async setSavedFeeds(saved: string[], pinned: string[]) { + async _optimisticUpdateSavedFeeds( + saved: string[], + pinned: string[], + cb: () => Promise<{saved: string[]; pinned: string[]}>, + ) { const oldSaved = this.savedFeeds const oldPinned = this.pinnedFeeds this.savedFeeds = saved this.pinnedFeeds = pinned try { - await this.update((prefs: AppBskyActorDefs.Preferences) => { - let feedsPref = prefs.find( - pref => - AppBskyActorDefs.isSavedFeedsPref(pref) && - AppBskyActorDefs.validateSavedFeedsPref(pref).success, - ) - if (feedsPref) { - feedsPref.saved = saved - feedsPref.pinned = pinned - } else { - feedsPref = { - $type: 'app.bsky.actor.defs#savedFeedsPref', - saved, - pinned, - } - } - return prefs - .filter(pref => !AppBskyActorDefs.isSavedFeedsPref(pref)) - .concat([feedsPref]) + const res = await cb() + runInAction(() => { + this.savedFeeds = res.saved + this.pinnedFeeds = res.pinned }) } catch (e) { runInAction(() => { @@ -534,25 +392,41 @@ export class PreferencesModel { } } + async setSavedFeeds(saved: string[], pinned: string[]) { + return this._optimisticUpdateSavedFeeds(saved, pinned, () => + this.rootStore.agent.setSavedFeeds(saved, pinned), + ) + } + async addSavedFeed(v: string) { - return this.setSavedFeeds([...this.savedFeeds, v], this.pinnedFeeds) + return this._optimisticUpdateSavedFeeds( + [...this.savedFeeds, v], + this.pinnedFeeds, + () => this.rootStore.agent.addSavedFeed(v), + ) } async removeSavedFeed(v: string) { - return this.setSavedFeeds( + return this._optimisticUpdateSavedFeeds( this.savedFeeds.filter(uri => uri !== v), this.pinnedFeeds.filter(uri => uri !== v), + () => this.rootStore.agent.removeSavedFeed(v), ) } async addPinnedFeed(v: string) { - return this.setSavedFeeds(this.savedFeeds, [...this.pinnedFeeds, v]) + return this._optimisticUpdateSavedFeeds( + this.savedFeeds, + [...this.pinnedFeeds, v], + () => this.rootStore.agent.addPinnedFeed(v), + ) } async removePinnedFeed(v: string) { - return this.setSavedFeeds( + return this._optimisticUpdateSavedFeeds( this.savedFeeds, this.pinnedFeeds.filter(uri => uri !== v), + () => this.rootStore.agent.removePinnedFeed(v), ) } diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index a64047f9f..33fdd5710 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -136,10 +136,6 @@ export interface PostLanguagesSettingsModal { name: 'post-languages-settings' } -export interface PreferencesHomeFeed { - name: 'preferences-home-feed' -} - export type Modal = // Account | AddAppPasswordModal @@ -152,7 +148,6 @@ export type Modal = | ContentFilteringSettingsModal | ContentLanguagesSettingsModal | PostLanguagesSettingsModal - | PreferencesHomeFeed // Moderation | ModerationDetailsModal diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index dfe1e26a1..395263af8 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,12 +1,11 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {RichText} from '@atproto/api' +import {RichText, AppBskyRichtextFacet} from '@atproto/api' import EventEmitter from 'eventemitter3' import {useEditor, EditorContent, JSONContent} from '@tiptap/react' import {Document} from '@tiptap/extension-document' import History from '@tiptap/extension-history' import Hardbreak from '@tiptap/extension-hard-break' -import {Link} from '@tiptap/extension-link' import {Mention} from '@tiptap/extension-mention' import {Paragraph} from '@tiptap/extension-paragraph' import {Placeholder} from '@tiptap/extension-placeholder' @@ -17,6 +16,7 @@ import {createSuggestion} from './web/Autocomplete' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {isUriImage, blobToDataUri} from 'lib/media/util' import {Emoji} from './web/EmojiPicker.web' +import {LinkDecorator} from './web/LinkDecorator' export interface TextInputRef { focus: () => void @@ -74,11 +74,7 @@ export const TextInput = React.forwardRef( { extensions: [ Document, - Link.configure({ - protocols: ['http', 'https'], - autolink: true, - linkOnPaste: false, - }), + LinkDecorator, Mention.configure({ HTMLAttributes: { class: 'mention', @@ -128,9 +124,20 @@ export const TextInput = React.forwardRef( newRt.detectFacetsWithoutResolution() setRichText(newRt) - const newSuggestedLinks = new Set(editorJsonToLinks(json)) - if (!isEqual(newSuggestedLinks, suggestedLinks)) { - onSuggestedLinksChanged(newSuggestedLinks) + const set: Set<string> = new Set() + + if (newRt.facets) { + for (const facet of newRt.facets) { + for (const feature of facet.features) { + if (AppBskyRichtextFacet.isLink(feature)) { + set.add(feature.uri) + } + } + } + } + + if (!isEqual(set, suggestedLinks)) { + onSuggestedLinksChanged(set) } }, }, @@ -237,22 +244,6 @@ function textToEditorJson(text: string): JSONContent { } } -function editorJsonToLinks(json: JSONContent): string[] { - let links: string[] = [] - if (json.content?.length) { - for (const node of json.content) { - links = links.concat(editorJsonToLinks(node)) - } - } - - const link = json.marks?.find(m => m.type === 'link') - if (link?.attrs?.href) { - links.push(link.attrs.href) - } - - return links -} - const styles = StyleSheet.create({ container: { flex: 1, diff --git a/src/view/com/composer/text-input/web/LinkDecorator.ts b/src/view/com/composer/text-input/web/LinkDecorator.ts new file mode 100644 index 000000000..531e8d5a0 --- /dev/null +++ b/src/view/com/composer/text-input/web/LinkDecorator.ts @@ -0,0 +1,106 @@ +/** + * TipTap is a stateful rich-text editor, which is extremely useful + * when you _want_ it to be stateful formatting such as bold and italics. + * + * However we also use "stateless" behaviors, specifically for URLs + * where the text itself drives the formatting. + * + * This plugin uses a regex to detect URIs and then applies + * link decorations (a <span> with the "autolink") class. That avoids + * adding any stateful formatting to TipTap's document model. + * + * We then run the URI detection again when constructing the + * RichText object from TipTap's output and merge their features into + * the facet-set. + */ + +import {Mark} from '@tiptap/core' +import {Plugin, PluginKey} from '@tiptap/pm/state' +import {findChildren} from '@tiptap/core' +import {Node as ProsemirrorNode} from '@tiptap/pm/model' +import {Decoration, DecorationSet} from '@tiptap/pm/view' +import {isValidDomain} from 'lib/strings/url-helpers' + +export const LinkDecorator = Mark.create({ + name: 'link-decorator', + priority: 1000, + keepOnSplit: false, + inclusive() { + return true + }, + addProseMirrorPlugins() { + return [linkDecorator()] + }, +}) + +function getDecorations(doc: ProsemirrorNode) { + const decorations: Decoration[] = [] + + findChildren(doc, node => node.type.name === 'paragraph').forEach( + paragraphNode => { + const textContent = paragraphNode.node.textContent + + // links + iterateUris(textContent, (from, to) => { + decorations.push( + Decoration.inline(paragraphNode.pos + from, paragraphNode.pos + to, { + class: 'autolink', + }), + ) + }) + }, + ) + + return DecorationSet.create(doc, decorations) +} + +function linkDecorator() { + const linkDecoratorPlugin: Plugin = new Plugin({ + key: new PluginKey('link-decorator'), + + state: { + init: (_, {doc}) => getDecorations(doc), + apply: (transaction, decorationSet) => { + if (transaction.docChanged) { + return getDecorations(transaction.doc) + } + return decorationSet.map(transaction.mapping, transaction.doc) + }, + }, + + props: { + decorations(state) { + return linkDecoratorPlugin.getState(state) + }, + }, + }) + return linkDecoratorPlugin +} + +function iterateUris(str: string, cb: (from: number, to: number) => void) { + let match + const re = + /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim + while ((match = re.exec(str))) { + let uri = match[2] + if (!uri.startsWith('http')) { + const domain = match.groups?.domain + if (!domain || !isValidDomain(domain)) { + continue + } + uri = `https://${uri}` + } + let from = str.indexOf(match[2], match.index) + let to = from + match[2].length + 1 + // strip ending puncuation + if (/[.,;!?]$/.test(uri)) { + uri = uri.slice(0, -1) + to-- + } + if (/[)]$/.test(uri) && !uri.includes('(')) { + uri = uri.slice(0, -1) + to-- + } + cb(from, to) + } +} diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx index 5215c9cb4..f39351feb 100644 --- a/src/view/com/modals/ContentFilteringSettings.tsx +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -48,15 +48,17 @@ export const Component = observer(({}: {}) => { <ScrollView style={styles.scrollContainer}> <View style={s.mb10}> {isIOS ? ( - <Text type="md" style={pal.textLight}> - Adult content can only be enabled via the Web at{' '} - <TextLink - style={pal.link} - href="https://bsky.app" - text="bsky.app" - /> - . - </Text> + store.preferences.adultContentEnabled ? null : ( + <Text type="md" style={pal.textLight}> + Adult content can only be enabled via the Web at{' '} + <TextLink + style={pal.link} + href="https://bsky.app" + text="bsky.app" + /> + . + </Text> + ) ) : ( <ToggleButton type="default-light" @@ -188,7 +190,7 @@ function SelectGroup({current, onChange, group}: SelectGroupProps) { /> <SelectableBtn current={current} - value="show" + value="ignore" label="Show" right onChange={onChange} diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index dd45262be..4a5a7c504 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -28,7 +28,6 @@ import * as AddAppPassword from './AddAppPasswords' import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' -import * as PreferencesHomeFeed from './PreferencesHomeFeed' import * as ModerationDetailsModal from './ModerationDetails' const DEFAULT_SNAPPOINTS = ['90%'] @@ -130,9 +129,6 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'post-languages-settings') { snapPoints = PostLanguagesSettingsModal.snapPoints element = <PostLanguagesSettingsModal.Component /> - } else if (activeModal?.name === 'preferences-home-feed') { - snapPoints = PreferencesHomeFeed.snapPoints - element = <PreferencesHomeFeed.Component /> } else if (activeModal?.name === 'moderation-details') { snapPoints = ModerationDetailsModal.snapPoints element = <ModerationDetailsModal.Component {...activeModal} /> diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 3aeddeb6b..5cfdd6bb3 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -27,7 +27,6 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as ModerationDetailsModal from './ModerationDetails' -import * as PreferencesHomeFeed from './PreferencesHomeFeed' export const ModalsContainer = observer(function ModalsContainer() { const store = useStores() @@ -105,8 +104,6 @@ function Modal({modal}: {modal: ModalIface}) { element = <AltTextImageModal.Component {...modal} /> } else if (modal.name === 'edit-image') { element = <EditImageModal.Component {...modal} /> - } else if (modal.name === 'preferences-home-feed') { - element = <PreferencesHomeFeed.Component /> } else if (modal.name === 'moderation-details') { element = <ModerationDetailsModal.Component {...modal} /> } else { diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 088be6a90..8b556cea3 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -367,6 +367,7 @@ export const PostThreadItem = observer(function PostThreadItem({ pal.border, pal.view, item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder, + styles.cursor, ]} moderation={item.moderation.content}> <PostSandboxWarning /> @@ -616,4 +617,8 @@ const styles = StyleSheet.create({ marginLeft: 'auto', marginRight: 'auto', }, + cursor: { + // @ts-ignore web only + cursor: 'pointer', + }, }) diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 94dfe6e8b..661b3a899 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -304,6 +304,7 @@ const styles = StyleSheet.create({ paddingBottom: 5, paddingLeft: 10, borderTopWidth: 1, + cursor: 'pointer', }, layout: { flexDirection: 'row', diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index e1212f32c..c46411f0f 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -343,6 +343,7 @@ const styles = StyleSheet.create({ borderTopWidth: 1, paddingLeft: 10, paddingRight: 15, + cursor: 'pointer', }, outerSmallTop: { borderTopWidth: 0, diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index ead85d0b5..321b6ab63 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -259,15 +259,21 @@ function onPressInner( e?: Event, ) { let shouldHandle = false + const isLeftClick = + // @ts-ignore Web only -prf + Platform.OS === 'web' && (e.button == null || e.button === 0) + // @ts-ignore Web only -prf + const isMiddleClick = Platform.OS === 'web' && e.button === 1 + const isMetaKey = + // @ts-ignore Web only -prf + Platform.OS === 'web' && (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) + const newTab = isMetaKey || isMiddleClick if (Platform.OS !== 'web' || !e) { shouldHandle = e ? !e.defaultPrevented : true } else if ( !e.defaultPrevented && // onPress prevented default - // @ts-ignore Web only -prf - !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) && // ignore clicks with modifier keys - // @ts-ignore Web only -prf - (e.button == null || e.button === 0) && // ignore everything but left clicks + (isLeftClick || isMiddleClick) && // ignore everything but left and middle clicks // @ts-ignore Web only -prf [undefined, null, '', 'self'].includes(e.currentTarget?.target) // let browser handle "target=_blank" etc. ) { @@ -277,7 +283,7 @@ function onPressInner( if (shouldHandle) { href = convertBskyAppUrlIfNeeded(href) - if (href.startsWith('http') || href.startsWith('mailto')) { + if (newTab || href.startsWith('http') || href.startsWith('mailto')) { Linking.openURL(href) } else { store.shell.closeModal() // close any active modals diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx index cd3299284..8d2a30506 100644 --- a/src/view/com/util/ViewSelector.tsx +++ b/src/view/com/util/ViewSelector.tsx @@ -168,6 +168,7 @@ export function Selector({ backgroundColor: pal.colors.background, }}> <ScrollView + testID="selector" horizontal showsHorizontalScrollIndicator={false} style={{position: 'absolute'}}> diff --git a/src/view/com/modals/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx index 15f7625b5..b04f274f7 100644 --- a/src/view/com/modals/PreferencesHomeFeed.tsx +++ b/src/view/screens/PreferencesHomeFeed.tsx @@ -1,16 +1,16 @@ import React, {useState} from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' import {Slider} from '@miblanchard/react-native-slider' -import {Text} from '../util/text/Text' +import {Text} from '../com/util/text/Text' import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb, isDesktopWeb} from 'platform/detection' import {ToggleButton} from 'view/com/util/forms/ToggleButton' -import {ScrollView} from 'view/com/modals/util' - -export const snapPoints = ['90%'] +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {ViewHeader} from 'view/com/util/ViewHeader' +import {CenteredView} from 'view/com/util/Views' function RepliesThresholdInput({enabled}: {enabled: boolean}) { const store = useStores() @@ -43,18 +43,25 @@ function RepliesThresholdInput({enabled}: {enabled: boolean}) { ) } -export const Component = observer(function Component() { +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'PreferencesHomeFeed' +> +export const PreferencesHomeFeed = observer(({navigation}: Props) => { const pal = usePalette('default') const store = useStores() return ( - <View - testID="preferencesHomeFeedModal" - style={[pal.view, styles.container]}> + <CenteredView + testID="preferencesHomeFeedScreen" + style={[ + pal.view, + pal.border, + styles.container, + isDesktopWeb && styles.desktopContainer, + ]}> + <ViewHeader title="Home Feed Preferences" showOnDesktop /> <View style={styles.titleSection}> - <Text type="title-lg" style={[pal.text, styles.title]}> - Home Feed Preferences - </Text> <Text type="xl" style={[pal.textLight, styles.description]}> Fine-tune the content you see on your home screen. </Text> @@ -119,27 +126,33 @@ export const Component = observer(function Component() { <TouchableOpacity testID="confirmBtn" onPress={() => { - store.shell.closeModal() + navigation.canGoBack() + ? navigation.goBack() + : navigation.navigate('Settings') }} - style={[styles.btn]} + style={[styles.btn, isDesktopWeb && styles.btnDesktop]} accessibilityRole="button" accessibilityLabel="Confirm" accessibilityHint=""> <Text style={[s.white, s.bold, s.f18]}>Done</Text> </TouchableOpacity> </View> - </View> + </CenteredView> ) }) const styles = StyleSheet.create({ container: { flex: 1, - paddingBottom: isDesktopWeb ? 0 : 60, + paddingBottom: isDesktopWeb ? 40 : 90, + }, + desktopContainer: { + borderLeftWidth: 1, + borderRightWidth: 1, }, titleSection: { - padding: 20, paddingBottom: 30, + paddingTop: isDesktopWeb ? 20 : 0, }, title: { textAlign: 'center', @@ -165,9 +178,12 @@ const styles = StyleSheet.create({ padding: 14, backgroundColor: colors.blue3, }, + btnDesktop: { + marginHorizontal: 'auto', + paddingHorizontal: 80, + }, btnContainer: { paddingTop: 20, - paddingHorizontal: 20, borderTopWidth: isDesktopWeb ? 0 : 1, }, dimmed: { diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 651fac21f..3c50fdde0 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -74,7 +74,7 @@ export const ProfileListScreen = withAuthRequired( store.shell.openModal({ name: 'confirm', title: 'Delete List', - message: 'Are you sure', + message: 'Are you sure?', async onPressConfirm() { await list.delete() if (navigation.canGoBack()) { diff --git a/src/view/screens/Search.web.tsx b/src/view/screens/Search.web.tsx index 85e8c212e..3218b4579 100644 --- a/src/view/screens/Search.web.tsx +++ b/src/view/screens/Search.web.tsx @@ -1,4 +1,5 @@ import React from 'react' +import {View, StyleSheet} from 'react-native' import {SearchUIModel} from 'state/models/ui/search' import {FoafsModel} from 'state/models/discovery/foafs' import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' @@ -47,13 +48,28 @@ export const SearchScreen = withAuthRequired( const {isDesktop} = useWebMediaQueries() if (searchUIModel) { - return <SearchResults model={searchUIModel} /> + return ( + <View style={styles.scrollContainer}> + <SearchResults model={searchUIModel} /> + </View> + ) } if (!isDesktop) { - return <Mobile.SearchScreen navigation={navigation} route={route} /> + return ( + <View style={styles.scrollContainer}> + <Mobile.SearchScreen navigation={navigation} route={route} /> + </View> + ) } return <Suggestions foafs={foafs} suggestedActors={suggestedActors} /> }), ) + +const styles = StyleSheet.create({ + scrollContainer: { + height: '100%', + overflowY: 'auto', + }, +}) diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 4a2c1c16a..481d77086 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -175,10 +175,8 @@ export const SettingsScreen = withAuthRequired( }, []) const openPreferencesModal = React.useCallback(() => { - store.shell.openModal({ - name: 'preferences-home-feed', - }) - }, [store]) + navigation.navigate('PreferencesHomeFeed') + }, [navigation]) const onPressAppPasswords = React.useCallback(() => { navigation.navigate('AppPasswords') @@ -391,7 +389,7 @@ export const SettingsScreen = withAuthRequired( Advanced </Text> <TouchableOpacity - testID="preferencesHomeFeedModalButton" + testID="preferencesHomeFeedButton" style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} onPress={openPreferencesModal} accessibilityRole="button" diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index b37befba6..eec55ee46 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -14,6 +14,7 @@ import { import {Text} from 'view/com/util/text/Text' import {UserAvatar} from 'view/com/util/UserAvatar' import {Link} from 'view/com/util/Link' +import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {usePalette} from 'lib/hooks/usePalette' import {useStores} from 'state/index' import {s, colors} from 'lib/styles' @@ -40,10 +41,14 @@ import {makeProfileLink} from 'lib/routes/links' const ProfileCard = observer(() => { const store = useStores() - return ( + return store.me.handle ? ( <Link href={makeProfileLink(store.me)} style={styles.profileCard} asAnchor> <UserAvatar avatar={store.me.avatar} size={64} /> </Link> + ) : ( + <View style={styles.profileCard}> + <LoadingPlaceholder width={64} height={64} style={{borderRadius: 64}} /> + </View> ) }) |