diff --git a/README.md b/README.md index c32d726e41..08e7aba28f 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,33 @@ # Bluesky Social App -Welcome friends! This is the codebase for the Bluesky Social app. It serves as a resource to engineers building on the [AT Protocol](https://atproto.com). +Welcome friends! This is the codebase for the Bluesky Social app. + +Get the app itself: - **Web: [bsky.app](https://bsky.app)** - **iOS: [App Store](https://apps.apple.com/us/app/bluesky-social/id6444370199)** - **Android: [Play Store](https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&hl=en_US&gl=US)** -Links: +## Development Resources -- [Build instructions](./docs/build.md) -- [ATProto repo](https://github.com/bluesky-social/atproto) -- [ATProto docs](https://atproto.com) +This is a [React Native](https://reactnative.dev/) application, written in the TypeScript programming language. It builds on the `atproto` TypeScript packages (like [`@atproto/api`](https://www.npmjs.com/package/@atproto/api)), code for which is also on open source, but in [a different git repository](https://github.com/bluesky-social/atproto). -## Rules & guidelines +There is a small about of Go language source code (in `./bskyweb/`), for a web service that returns the React Native Web application. ---- +The [Build Instructions](./docs/builds.md) are a good place to get started with the app itself. -â„šī¸ While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review. +The Authenticated Transfer Protocol ("AT Protocol" or "atproto") is a decentralized social media protocol. You don't *need* to understand AT Protocol to work with this application, but it can help. Learn more at: ---- +- [Overview and Guides](https://atproto.com/guides/overview) +- [Github Discussions](https://github.com/bluesky-social/atproto/discussions) 👈 Great place to ask questions +- [Protocol Specifications](https://atproto.com/specs/atp) +- [Blogpost on self-authenticating data structures](https://blueskyweb.xyz/blog/3-6-2022-a-self-authenticating-social-protocol) + +The Bluesky Social application encompases a set of schemas and APIs built in the overall AT Protocol framework. The namespace for these "Lexicons" is `app.bsky.*`. + +## Contributions + +> While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review. **Rules:** diff --git a/__tests__/lib/strings/url-helpers.test.ts b/__tests__/lib/strings/url-helpers.test.ts new file mode 100644 index 0000000000..3055a9ef6d --- /dev/null +++ b/__tests__/lib/strings/url-helpers.test.ts @@ -0,0 +1,98 @@ +import { + linkRequiresWarning, + isPossiblyAUrl, + splitApexDomain, +} from '../../../src/lib/strings/url-helpers' + +describe('linkRequiresWarning', () => { + type Case = [string, string, boolean] + const cases: Case[] = [ + ['http://example.com', 'http://example.com', false], + ['http://example.com', 'example.com', false], + ['http://example.com', 'example.com/page', false], + ['http://example.com', '', true], + ['http://example.com', 'other.com', true], + ['http://example.com', 'http://other.com', true], + ['http://example.com', 'some label', true], + ['http://example.com', 'example.com more', true], + ['http://example.com', 'http://example.co', true], + ['http://example.co', 'http://example.com', true], + ['http://example.com', 'example.co', true], + ['http://example.co', 'example.com', true], + ['http://site.pages.dev', 'http://site.page', true], + ['http://site.page', 'http://site.pages.dev', true], + ['http://site.pages.dev', 'site.page', true], + ['http://site.page', 'site.pages.dev', true], + ['http://site.pages.dev', 'http://site.pages', true], + ['http://site.pages', 'http://site.pages.dev', true], + ['http://site.pages.dev', 'site.pages', true], + ['http://site.pages', 'site.pages.dev', true], + + // bad uri inputs, default to true + ['', '', true], + ['example.com', 'example.com', true], + ] + + it.each(cases)( + 'given input uri %p and text %p, returns %p', + (uri, text, expected) => { + const output = linkRequiresWarning(uri, text) + expect(output).toEqual(expected) + }, + ) +}) + +describe('isPossiblyAUrl', () => { + type Case = [string, boolean] + const cases: Case[] = [ + ['', false], + ['text', false], + ['some text', false], + ['some text', false], + ['some domain.com', false], + ['domain.com', true], + [' domain.com', true], + ['domain.com ', true], + [' domain.com ', true], + ['http://domain.com', true], + [' http://domain.com', true], + ['http://domain.com ', true], + [' http://domain.com ', true], + ['https://domain.com', true], + [' https://domain.com', true], + ['https://domain.com ', true], + [' https://domain.com ', true], + ['http://domain.com/foo', true], + ['http://domain.com stuff', true], + ] + + it.each(cases)('given input uri %p, returns %p', (str, expected) => { + const output = isPossiblyAUrl(str) + expect(output).toEqual(expected) + }) +}) + +describe('splitApexDomain', () => { + type Case = [string, string, string] + const cases: Case[] = [ + ['', '', ''], + ['example.com', '', 'example.com'], + ['foo.example.com', 'foo.', 'example.com'], + ['foo.bar.example.com', 'foo.bar.', 'example.com'], + ['example.co.uk', '', 'example.co.uk'], + ['foo.example.co.uk', 'foo.', 'example.co.uk'], + ['example.nonsense', '', 'example.nonsense'], + ['foo.example.nonsense', '', 'foo.example.nonsense'], + ['foo.bar.example.nonsense', '', 'foo.bar.example.nonsense'], + ['example.com.example.com', 'example.com.', 'example.com'], + ] + + it.each(cases)( + 'given input uri %p, returns %p,%p', + (str, expected1, expected2) => { + const output = splitApexDomain(str) + expect(output[0]).toEqual(expected1) + expect(output[1]).toEqual(expected2) + }, + ) +}) diff --git a/app.config.js b/app.config.js index 51e95b1a16..a1477a8aea 100644 --- a/app.config.js +++ b/app.config.js @@ -6,7 +6,7 @@ module.exports = function () { slug: 'bluesky', scheme: 'bluesky', owner: 'blueskysocial', - version: '1.51.0', + version: '1.52.0', runtimeVersion: { policy: 'appVersion', }, @@ -19,7 +19,7 @@ module.exports = function () { backgroundColor: '#ffffff', }, ios: { - buildNumber: '5', + buildNumber: '1', supportsTablet: false, bundleIdentifier: 'xyz.blueskyweb.app', config: { @@ -43,7 +43,7 @@ module.exports = function () { backgroundColor: '#ffffff', }, android: { - versionCode: 39, + versionCode: 40, adaptiveIcon: { foregroundImage: './assets/adaptive-icon.png', backgroundColor: '#ffffff', diff --git a/docs/build.md b/docs/build.md index ac167a3622..30906b0839 100644 --- a/docs/build.md +++ b/docs/build.md @@ -8,11 +8,13 @@ - brew tap wix/brew - brew install applesimutils - After initial setup: + - Copy `google-services.json.example` to `google-services.json` or provide your own `google-services.json`. (A real firebase project is NOT required) - `npx expo prebuild` -> you will also need to run this anytime `app.json` or native `package.json` deps change - Start the dev servers - `git clone git@github.com:bluesky-social/atproto.git` - `cd atproto` - `pnpm i` + - `pnpm build` - `cd packages/dev-env && pnpm start` - Run the dev app - iOS: `yarn ios` @@ -119,6 +121,7 @@ upload-sourcemaps \ dist/bundles/main.jsbundle dist/bundles/ios-.map` ### OTA updates + To create OTA updates, run `eas update` along with the `--branch` flag to indicate which branch you want to push the update to, and the `--message` flag to indicate a message for yourself and your team that shows up on https://expo.dev. ALl the channels (which make up the options for the `--branch` flag) are given in `eas.json`. [See more here](https://docs.expo.dev/eas-update/getting-started/) The clients which can receive an OTA update is governed by the `runtimeVersion` property in `app.json`. Right now, it is set so that only apps with the same `appVersion` (same as `version` property in `app.json`) can receive the update and install it. However, we can manually set `"runtimeVersion": "1.34.0"` or anything along those lines as well. This is useful if very little native code changes from update-to-update. If we are manually setting `runtimeVersion`, we should increment the version each time native code is changed. [See more here](https://docs.expo.dev/eas-update/runtime-versions/) diff --git a/google-services.json.example b/google-services.json.example new file mode 100644 index 0000000000..698ef12b28 --- /dev/null +++ b/google-services.json.example @@ -0,0 +1,41 @@ +{ + "project_info": { + "project_id": "blueskyweb-example", + "project_number": "100000000000", + "firebase_url": "https://blueskyweb-example.firebaseio.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:123456789000:android:f1bf012572b04063", + "android_client_info": { + "package_name": "xyz.blueskyweb.app" + } + }, + "oauth_client": [ + { + "client_id": "123456789000.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "123456789000" + } + ], + "services": { + "analytics_service": { + "status": 1 + }, + "appinvite_service": { + "status": 1, + "other_platform_oauth_client": [] + }, + "ads_service": { + "status": 2 + } + } + } + ], + "configuration_version": "1" +} diff --git a/package.json b/package.json index 9b1ed9000c..84c5d7c6d3 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:apk": "eas build -p android --profile dev-android-apk" }, "dependencies": { - "@atproto/api": "^0.6.19", + "@atproto/api": "^0.6.20", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@emoji-mart/react": "^1.1.1", @@ -35,7 +35,7 @@ "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-native-fontawesome": "^0.3.0", - "@gorhom/bottom-sheet": "^4.4.7", + "@gorhom/bottom-sheet": "^4.5.1", "@mattermost/react-native-paste-input": "^0.6.4", "@miblanchard/react-native-slider": "^2.3.1", "@react-native-async-storage/async-storage": "1.18.2", @@ -45,7 +45,7 @@ "@react-native-community/datetimepicker": "7.2.0", "@react-native-menu/menu": "^0.8.0", "@react-native-picker/picker": "2.4.10", - "@react-navigation/bottom-tabs": "^6.5.7", + "@react-navigation/bottom-tabs": "^6.5.9", "@react-navigation/drawer": "^6.6.2", "@react-navigation/native": "^6.1.6", "@react-navigation/native-stack": "^6.9.12", @@ -116,11 +116,12 @@ "normalize-url": "^8.0.0", "patch-package": "^6.5.1", "postinstall-postinstall": "^2.1.0", + "psl": "^1.9.0", "react": "18.2.0", "react-avatar-editor": "^13.0.0", "react-circular-progressbar": "^2.1.0", "react-dom": "^18.2.0", - "react-native": "0.72.4", + "react-native": "0.72.5", "react-native-appstate-hook": "^1.0.6", "react-native-draggable-flatlist": "^4.0.1", "react-native-drawer-layout": "^3.2.0", @@ -176,6 +177,7 @@ "@types/lodash.samplesize": "^4.2.7", "@types/lodash.set": "^4.3.7", "@types/lodash.shuffle": "^4.2.7", + "@types/psl": "^1.1.1", "@types/react-avatar-editor": "^13.0.0", "@types/react-responsive": "^8.0.5", "@types/react-test-renderer": "^17.0.1", diff --git a/patches/react-native+0.72.4.patch b/patches/react-native+0.72.5.patch similarity index 100% rename from patches/react-native+0.72.4.patch rename to patches/react-native+0.72.5.patch diff --git a/src/Navigation.tsx b/src/Navigation.tsx index a247c72dd8..97612c9ecf 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -246,6 +246,7 @@ function TabsNavigator() { ), [], ) + return ( Promise, ) { + // TODO: remove this when the test suite no longer relies on it if (IS_LOCAL_DEV(serviceUrl)) { // local dev const aliceDid = await resolveHandle('alice.test') @@ -106,16 +107,8 @@ export async function DEFAULT_FEEDS( } else { // production return { - pinned: [ - PROD_DEFAULT_FEED('whats-hot'), - PROD_DEFAULT_FEED('with-friends'), - ], - saved: [ - PROD_DEFAULT_FEED('bsky-team'), - PROD_DEFAULT_FEED('with-friends'), - PROD_DEFAULT_FEED('whats-hot'), - PROD_DEFAULT_FEED('hot-classic'), - ], + pinned: [PROD_DEFAULT_FEED('whats-hot')], + saved: [PROD_DEFAULT_FEED('whats-hot')], } } } diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts new file mode 100644 index 0000000000..85bd5d0d45 --- /dev/null +++ b/src/lib/hooks/useAccountSwitcher.ts @@ -0,0 +1,41 @@ +import {useCallback, useState} from 'react' +import {useStores} from 'state/index' +import {useAnalytics} from 'lib/analytics/analytics' +import {StackActions, useNavigation} from '@react-navigation/native' +import {NavigationProp} from 'lib/routes/types' +import {AccountData} from 'state/models/session' +import {reset as resetNavigation} from '../../Navigation' +import * as Toast from 'view/com/util/Toast' + +export function useAccountSwitcher(): [ + boolean, + (v: boolean) => void, + (acct: AccountData) => Promise, +] { + const {track} = useAnalytics() + + const store = useStores() + const [isSwitching, setIsSwitching] = useState(false) + const navigation = useNavigation() + + const onPressSwitchAccount = useCallback( + async (acct: AccountData) => { + track('Settings:SwitchAccountButtonClicked') + setIsSwitching(true) + const success = await store.session.resumeSession(acct) + store.shell.closeAllActiveElements() + if (success) { + resetNavigation() + Toast.show(`Signed in as ${acct.displayName || acct.handle}`) + } else { + Toast.show('Sorry! We need you to enter your password.') + navigation.navigate('HomeTab') + navigation.dispatch(StackActions.popToTop()) + store.session.clear() + } + }, + [track, setIsSwitching, navigation, store], + ) + + return [isSwitching, setIsSwitching, onPressSwitchAccount] +} diff --git a/src/lib/hooks/useFollowDid.ts b/src/lib/hooks/useFollowProfile.ts similarity index 57% rename from src/lib/hooks/useFollowDid.ts rename to src/lib/hooks/useFollowProfile.ts index 223adb0475..6220daba86 100644 --- a/src/lib/hooks/useFollowDid.ts +++ b/src/lib/hooks/useFollowProfile.ts @@ -1,11 +1,11 @@ import React from 'react' - +import {AppBskyActorDefs} from '@atproto/api' import {useStores} from 'state/index' import {FollowState} from 'state/models/cache/my-follows' -export function useFollowDid({did}: {did: string}) { +export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) { const store = useStores() - const state = store.me.follows.getFollowState(did) + const state = store.me.follows.getFollowState(profile.did) return { state, @@ -13,8 +13,10 @@ export function useFollowDid({did}: {did: string}) { toggle: React.useCallback(async () => { if (state === FollowState.Following) { try { - await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) - store.me.follows.removeFollow(did) + await store.agent.deleteFollow( + store.me.follows.getFollowUri(profile.did), + ) + store.me.follows.removeFollow(profile.did) return { state: FollowState.NotFollowing, following: false, @@ -25,8 +27,14 @@ export function useFollowDid({did}: {did: string}) { } } else if (state === FollowState.NotFollowing) { try { - const res = await store.agent.follow(did) - store.me.follows.addFollow(did, res.uri) + const res = await store.agent.follow(profile.did) + store.me.follows.addFollow(profile.did, { + followRecordUri: res.uri, + did: profile.did, + handle: profile.handle, + displayName: profile.displayName, + avatar: profile.avatar, + }) return { state: FollowState.Following, following: true, @@ -41,6 +49,6 @@ export function useFollowDid({did}: {did: string}) { state: FollowState.Unknown, following: false, } - }, [store, did, state]), + }, [store, profile, state]), } } diff --git a/src/lib/hooks/useOnMainScroll.ts b/src/lib/hooks/useOnMainScroll.ts index 507a28ceef..250ef3a364 100644 --- a/src/lib/hooks/useOnMainScroll.ts +++ b/src/lib/hooks/useOnMainScroll.ts @@ -2,12 +2,18 @@ import {useState, useCallback, useRef} from 'react' import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' import {RootStoreModel} from 'state/index' import {s} from 'lib/styles' -import {isDesktopWeb} from 'platform/detection' +import {useWebMediaQueries} from './useWebMediaQueries' -const DY_LIMIT_UP = isDesktopWeb ? 30 : 10 -const DY_LIMIT_DOWN = isDesktopWeb ? 150 : 10 const Y_LIMIT = 10 +const useDeviceLimits = () => { + const {isDesktop} = useWebMediaQueries() + return { + dyLimitUp: isDesktop ? 30 : 10, + dyLimitDown: isDesktop ? 150 : 10, + } +} + export type OnScrollCb = ( event: NativeSyntheticEvent, ) => void @@ -18,6 +24,8 @@ export function useOnMainScroll( ): [OnScrollCb, boolean, ResetCb] { let lastY = useRef(0) let [isScrolledDown, setIsScrolledDown] = useState(false) + const {dyLimitUp, dyLimitDown} = useDeviceLimits() + return [ useCallback( (event: NativeSyntheticEvent) => { @@ -25,15 +33,11 @@ export function useOnMainScroll( const dy = y - (lastY.current || 0) lastY.current = y - if ( - !store.shell.minimalShellMode && - dy > DY_LIMIT_DOWN && - y > Y_LIMIT - ) { + if (!store.shell.minimalShellMode && dy > dyLimitDown && y > Y_LIMIT) { store.shell.setMinimalShellMode(true) } else if ( store.shell.minimalShellMode && - (dy < DY_LIMIT_UP * -1 || y <= Y_LIMIT) + (dy < dyLimitUp * -1 || y <= Y_LIMIT) ) { store.shell.setMinimalShellMode(false) } @@ -50,7 +54,7 @@ export function useOnMainScroll( setIsScrolledDown(false) } }, - [store, isScrolledDown], + [store.shell, dyLimitDown, dyLimitUp, isScrolledDown], ), isScrolledDown, useCallback(() => { diff --git a/src/lib/routes/back-handler.ts b/src/lib/routes/back-handler.ts index c4067c53e9..aae2f2c24e 100644 --- a/src/lib/routes/back-handler.ts +++ b/src/lib/routes/back-handler.ts @@ -1,8 +1,19 @@ +import {isAndroid} from 'platform/detection' import {BackHandler} from 'react-native' import {RootStoreModel} from 'state/index' export function init(store: RootStoreModel) { - BackHandler.addEventListener('hardwareBackPress', () => { - return store.shell.closeAnyActiveElement() - }) + // only register back handler on android, otherwise it throws an error + if (isAndroid) { + const backHandler = BackHandler.addEventListener( + 'hardwareBackPress', + () => { + return store.shell.closeAnyActiveElement() + }, + ) + return () => { + backHandler.remove() + } + } + return () => {} } diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts index 183d53e317..ef93a366f9 100644 --- a/src/lib/strings/helpers.ts +++ b/src/lib/strings/helpers.ts @@ -15,3 +15,20 @@ export function enforceLen(str: string, len: number, ellipsis = false): string { } return str } + +// https://stackoverflow.com/a/52171480 +export function toHashCode(str: string, seed = 0): number { + let h1 = 0xdeadbeef ^ seed, + h2 = 0x41c6ce57 ^ seed + for (let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i) + h1 = Math.imul(h1 ^ ch, 2654435761) + h2 = Math.imul(h2 ^ ch, 1597334677) + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909) + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909) + + return 4294967296 * (2097151 & h2) + (h1 >>> 0) +} diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 671dc97815..3c27d86397 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -1,6 +1,7 @@ import {AtUri} from '@atproto/api' import {PROD_SERVICE} from 'state/index' import TLDs from 'tlds' +import psl from 'psl' export function isValidDomain(str: string): boolean { return !!TLDs.find(tld => { @@ -166,3 +167,53 @@ export function getYoutubeVideoId(link: string): string | undefined { } return videoId } + +export function linkRequiresWarning(uri: string, label: string) { + const labelDomain = labelToDomain(label) + if (!labelDomain) { + return true + } + try { + const urip = new URL(uri) + return labelDomain !== urip.hostname + } catch { + return true + } +} + +function labelToDomain(label: string): string | undefined { + // any spaces just immediately consider the label a non-url + if (/\s/.test(label)) { + return undefined + } + try { + return new URL(label).hostname + } catch {} + try { + return new URL('https://' + label).hostname + } catch {} + return undefined +} + +export function isPossiblyAUrl(str: string): boolean { + str = str.trim() + if (str.startsWith('http://')) { + return true + } + if (str.startsWith('https://')) { + return true + } + const [firstWord] = str.split(/[\s\/]/) + return isValidDomain(firstWord) +} + +export function splitApexDomain(hostname: string): [string, string] { + const hostnamep = psl.parse(hostname) + if (hostnamep.error || !hostnamep.listed || !hostnamep.domain) { + return ['', hostname] + } + return [ + hostnamep.subdomain ? `${hostnamep.subdomain}.` : '', + hostnamep.domain, + ] +} diff --git a/src/lib/themes.ts b/src/lib/themes.ts index 95aee0842f..b778d5b30d 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -264,8 +264,8 @@ export const defaultTheme: Theme = { fontWeight: '400', }, 'post-text-lg': { - fontSize: 22, - letterSpacing: 0.4, + fontSize: 20, + letterSpacing: 0.2, fontWeight: '400', }, 'button-lg': { diff --git a/src/platform/detection.ts b/src/platform/detection.ts index d414b008ce..f4f7be240d 100644 --- a/src/platform/detection.ts +++ b/src/platform/detection.ts @@ -12,7 +12,6 @@ export const isMobileWeb = isWeb && // @ts-ignore we know window exists -prf global.window.matchMedia(isMobileWebMediaQuery)?.matches -export const isDesktopWeb = isWeb && !isMobileWeb export const deviceLocales = dedupArray( getLocales?.().map?.(locale => locale.languageCode), diff --git a/src/state/models/cache/my-follows.ts b/src/state/models/cache/my-follows.ts index 10f88c4a96..14dc9895db 100644 --- a/src/state/models/cache/my-follows.ts +++ b/src/state/models/cache/my-follows.ts @@ -1,7 +1,14 @@ import {makeAutoObservable} from 'mobx' -import {AppBskyActorDefs} from '@atproto/api' +import { + AppBskyActorDefs, + AppBskyGraphGetFollows as GetFollows, + moderateProfile, +} from '@atproto/api' import {RootStoreModel} from '../root-store' +const MAX_SYNC_PAGES = 10 +const SYNC_TTL = 60e3 * 10 // 10 minutes + type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView export enum FollowState { @@ -10,6 +17,14 @@ export enum FollowState { Unknown, } +export interface FollowInfo { + did: string + followRecordUri: string | undefined + handle: string + displayName: string | undefined + avatar: string | undefined +} + /** * This model is used to maintain a synced local cache of the user's * follows. It should be periodically refreshed and updated any time @@ -17,9 +32,8 @@ export enum FollowState { */ export class MyFollowsCache { // data - followDidToRecordMap: Record = {} + byDid: Record = {} lastSync = 0 - myDid?: string constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -35,16 +49,45 @@ export class MyFollowsCache { // = clear() { - this.followDidToRecordMap = {} - this.lastSync = 0 - this.myDid = undefined + this.byDid = {} + } + + /** + * Syncs a subset of the user's follows + * for performance reasons, caps out at 1000 follows + */ + async syncIfNeeded() { + if (this.lastSync > Date.now() - SYNC_TTL) { + return + } + + let cursor + for (let i = 0; i < MAX_SYNC_PAGES; i++) { + const res: GetFollows.Response = await this.rootStore.agent.getFollows({ + actor: this.rootStore.me.did, + cursor, + limit: 100, + }) + res.data.follows = res.data.follows.filter( + profile => + !moderateProfile(profile, this.rootStore.preferences.moderationOpts) + .account.filter, + ) + this.hydrateMany(res.data.follows) + if (!res.data.cursor) { + break + } + cursor = res.data.cursor + } + + this.lastSync = Date.now() } getFollowState(did: string): FollowState { - if (typeof this.followDidToRecordMap[did] === 'undefined') { + if (typeof this.byDid[did] === 'undefined') { return FollowState.Unknown } - if (typeof this.followDidToRecordMap[did] === 'string') { + if (typeof this.byDid[did].followRecordUri === 'string') { return FollowState.Following } return FollowState.NotFollowing @@ -53,49 +96,41 @@ export class MyFollowsCache { async fetchFollowState(did: string): Promise { // TODO: can we get a more efficient method for this? getProfile fetches more data than we need -prf const res = await this.rootStore.agent.getProfile({actor: did}) - if (res.data.viewer?.following) { - this.addFollow(did, res.data.viewer.following) - } else { - this.removeFollow(did) - } + this.hydrate(did, res.data) return this.getFollowState(did) } getFollowUri(did: string): string { - const v = this.followDidToRecordMap[did] + const v = this.byDid[did] if (typeof v === 'string') { return v } throw new Error('Not a followed user') } - addFollow(did: string, recordUri: string) { - this.followDidToRecordMap[did] = recordUri + addFollow(did: string, info: FollowInfo) { + this.byDid[did] = info } removeFollow(did: string) { - this.followDidToRecordMap[did] = false - } - - /** - * Use this to incrementally update the cache as views provide information - */ - hydrate(did: string, recordUri: string | undefined) { - if (recordUri) { - this.followDidToRecordMap[did] = recordUri - } else { - this.followDidToRecordMap[did] = false + if (this.byDid[did]) { + this.byDid[did].followRecordUri = undefined } } - /** - * Use this to incrementally update the cache as views provide information - */ - hydrateProfiles(profiles: Profile[]) { + hydrate(did: string, profile: Profile) { + this.byDid[did] = { + did, + followRecordUri: profile.viewer?.following, + handle: profile.handle, + displayName: profile.displayName, + avatar: profile.avatar, + } + } + + hydrateMany(profiles: Profile[]) { for (const profile of profiles) { - if (profile.viewer) { - this.hydrate(profile.did, profile.viewer.following) - } + this.hydrate(profile.did, profile) } } } diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index 981fb1f1da..a862c27d32 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import { AppBskyFeedGetPostThread as GetPostThread, AppBskyFeedDefs, + AppBskyFeedPost, PostModeration, } from '@atproto/api' import {AtUri} from '@atproto/api' @@ -76,6 +77,13 @@ export class PostThreadModel { return this.rootStore.mutedThreads.uris.has(this.rootUri) } + get isCachedPostAReply() { + if (AppBskyFeedPost.isRecord(this.thread?.post.record)) { + return !!this.thread?.post.record.reply + } + return false + } + // public api // = diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index 26fa6008c8..906f84c281 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -137,7 +137,7 @@ export class ProfileModel { runInAction(() => { this.followersCount++ this.viewer.following = res.uri - this.rootStore.me.follows.addFollow(this.did, res.uri) + this.rootStore.me.follows.hydrate(this.did, this) }) track('Profile:Follow', { username: this.handle, @@ -290,8 +290,8 @@ export class ProfileModel { this.labels = res.data.labels if (res.data.viewer) { Object.assign(this.viewer, res.data.viewer) - this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following) } + this.rootStore.me.follows.hydrate(this.did, res.data) } async _createRichText() { diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts index 580145f65d..4a647dcfe3 100644 --- a/src/state/models/discovery/foafs.ts +++ b/src/state/models/discovery/foafs.ts @@ -1,8 +1,4 @@ -import { - AppBskyActorDefs, - AppBskyGraphGetFollows as GetFollows, - moderateProfile, -} from '@atproto/api' +import {AppBskyActorDefs} from '@atproto/api' import {makeAutoObservable, runInAction} from 'mobx' import sampleSize from 'lodash.samplesize' import {bundleAsync} from 'lib/async/bundle' @@ -43,35 +39,13 @@ export class FoafsModel { try { this.isLoading = true - // fetch & hydrate up to 1000 follows - { - let cursor - for (let i = 0; i < 10; i++) { - const res: GetFollows.Response = - await this.rootStore.agent.getFollows({ - actor: this.rootStore.me.did, - cursor, - limit: 100, - }) - res.data.follows = res.data.follows.filter( - profile => - !moderateProfile( - profile, - this.rootStore.preferences.moderationOpts, - ).account.filter, - ) - this.rootStore.me.follows.hydrateProfiles(res.data.follows) - if (!res.data.cursor) { - break - } - cursor = res.data.cursor - } - } + // fetch some of the user's follows + await this.rootStore.me.follows.syncIfNeeded() // grab 10 of the users followed by the user runInAction(() => { this.sources = sampleSize( - Object.keys(this.rootStore.me.follows.followDidToRecordMap), + Object.keys(this.rootStore.me.follows.byDid), 10, ) }) @@ -100,7 +74,7 @@ export class FoafsModel { for (let i = 0; i < results.length; i++) { const res = results[i] if (res.status === 'fulfilled') { - this.rootStore.me.follows.hydrateProfiles(res.value.data.follows) + this.rootStore.me.follows.hydrateMany(res.value.data.follows) } const profile = profiles.data.profiles[i] const source = this.sources[i] diff --git a/src/state/models/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts index 8ad321ed95..3638e7f0d2 100644 --- a/src/state/models/discovery/onboarding.ts +++ b/src/state/models/discovery/onboarding.ts @@ -81,6 +81,7 @@ export class OnboardingModel { } finish() { + this.rootStore.me.mainFeed.refresh() // load the selected content this.step = 'Home' track('Onboarding:Complete') } diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts index afa5e74e3e..d270267eee 100644 --- a/src/state/models/discovery/suggested-actors.ts +++ b/src/state/models/discovery/suggested-actors.ts @@ -76,7 +76,7 @@ export class SuggestedActorsModel { !moderateProfile(actor, this.rootStore.preferences.moderationOpts) .account.filter, ) - this.rootStore.me.follows.hydrateProfiles(actors) + this.rootStore.me.follows.hydrateMany(actors) runInAction(() => { if (replace) { @@ -118,7 +118,7 @@ export class SuggestedActorsModel { actor: actor, }) const {suggestions: moreSuggestions} = res.data - this.rootStore.me.follows.hydrateProfiles(moreSuggestions) + this.rootStore.me.follows.hydrateMany(moreSuggestions) // dedupe const toInsert = moreSuggestions.filter( s => !this.suggestions.find(s2 => s2.did === s.did), diff --git a/src/state/models/discovery/user-autocomplete.ts b/src/state/models/discovery/user-autocomplete.ts index 461073e457..25ce859d2e 100644 --- a/src/state/models/discovery/user-autocomplete.ts +++ b/src/state/models/discovery/user-autocomplete.ts @@ -4,6 +4,8 @@ import AwaitLock from 'await-lock' import {RootStoreModel} from '../root-store' import {isInvalidHandle} from 'lib/strings/handles' +type ProfileViewBasic = AppBskyActorDefs.ProfileViewBasic + export class UserAutocompleteModel { // state isLoading = false @@ -12,9 +14,8 @@ export class UserAutocompleteModel { lock = new AwaitLock() // data - follows: AppBskyActorDefs.ProfileViewBasic[] = [] - searchRes: AppBskyActorDefs.ProfileViewBasic[] = [] knownHandles: Set = new Set() + _suggestions: ProfileViewBasic[] = [] constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -27,29 +28,35 @@ export class UserAutocompleteModel { ) } - get suggestions() { + get follows(): ProfileViewBasic[] { + return Object.values(this.rootStore.me.follows.byDid).map(item => ({ + did: item.did, + handle: item.handle, + displayName: item.displayName, + avatar: item.avatar, + })) + } + + get suggestions(): ProfileViewBasic[] { if (!this.isActive) { return [] } - if (this.prefix) { - return this.searchRes.map(user => ({ - handle: user.handle, - displayName: user.displayName, - avatar: user.avatar, - })) - } - return this.follows.map(follow => ({ - handle: follow.handle, - displayName: follow.displayName, - avatar: follow.avatar, - })) + return this._suggestions } // public api // = async setup() { - await this._getFollows() + await this.rootStore.me.follows.syncIfNeeded() + runInAction(() => { + for (const did in this.rootStore.me.follows.byDid) { + const info = this.rootStore.me.follows.byDid[did] + if (!isInvalidHandle(info.handle)) { + this.knownHandles.add(info.handle) + } + } + }) } setActive(v: boolean) { @@ -57,7 +64,7 @@ export class UserAutocompleteModel { } async setPrefix(prefix: string) { - const origPrefix = prefix.trim() + const origPrefix = prefix.trim().toLocaleLowerCase() this.prefix = origPrefix await this.lock.acquireAsync() try { @@ -65,9 +72,27 @@ export class UserAutocompleteModel { if (this.prefix !== origPrefix) { return // another prefix was set before we got our chance } - await this._search() + + // reset to follow results + this._computeSuggestions([]) + + // ask backend + const res = await this.rootStore.agent.searchActorsTypeahead({ + term: this.prefix, + limit: 8, + }) + this._computeSuggestions(res.data.actors) + + // update known handles + runInAction(() => { + for (const u of res.data.actors) { + this.knownHandles.add(u.handle) + } + }) } else { - this.searchRes = [] + runInAction(() => { + this._computeSuggestions([]) + }) } } finally { this.lock.release() @@ -77,28 +102,40 @@ export class UserAutocompleteModel { // internal // = - async _getFollows() { - const res = await this.rootStore.agent.getFollows({ - actor: this.rootStore.me.did || '', - }) - runInAction(() => { - this.follows = res.data.follows.filter(f => !isInvalidHandle(f.handle)) - for (const f of this.follows) { - this.knownHandles.add(f.handle) + _computeSuggestions(searchRes: AppBskyActorDefs.ProfileViewBasic[] = []) { + if (this.prefix) { + const items: ProfileViewBasic[] = [] + for (const item of this.follows) { + if (prefixMatch(this.prefix, item)) { + items.push(item) + } + if (items.length >= 8) { + break + } } - }) - } - - async _search() { - const res = await this.rootStore.agent.searchActorsTypeahead({ - term: this.prefix, - limit: 8, - }) - runInAction(() => { - this.searchRes = res.data.actors - for (const u of this.searchRes) { - this.knownHandles.add(u.handle) + for (const item of searchRes) { + if (!items.find(item2 => item2.handle === item.handle)) { + items.push({ + did: item.did, + handle: item.handle, + displayName: item.displayName, + avatar: item.avatar, + }) + } } - }) + this._suggestions = items + } else { + this._suggestions = this.follows + } } } + +function prefixMatch(prefix: string, info: ProfileViewBasic): boolean { + if (info.handle.includes(prefix)) { + return true + } + if (info.displayName?.toLocaleLowerCase().includes(prefix)) { + return true + } + return false +} diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index bb619147ff..2462689b14 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -116,6 +116,10 @@ export class PostsFeedModel { return this.hasLoaded && !this.hasContent } + get isLoadingMore() { + return this.isLoading && !this.isRefreshing + } + setHasNewLatest(v: boolean) { this.hasNewLatest = v } @@ -307,12 +311,12 @@ export class PostsFeedModel { } async _appendAll(res: FeedAPIResponse, replace = false) { - this.hasMore = !!res.cursor + this.hasMore = !!res.cursor && res.feed.length > 0 if (replace) { this.emptyFetches = 0 } - this.rootStore.me.follows.hydrateProfiles( + this.rootStore.me.follows.hydrateMany( res.feed.map(item => item.post.author), ) for (const item of res.feed) { diff --git a/src/state/models/invited-users.ts b/src/state/models/invited-users.ts index a28e0309a6..cd36670622 100644 --- a/src/state/models/invited-users.ts +++ b/src/state/models/invited-users.ts @@ -61,7 +61,7 @@ export class InvitedUsers { profile => !profile.viewer?.following, ) }) - this.rootStore.me.follows.hydrateProfiles(this.profiles) + this.rootStore.me.follows.hydrateMany(this.profiles) } catch (e) { this.rootStore.log.error( 'Failed to fetch profiles for invited users', diff --git a/src/state/models/lists/likes.ts b/src/state/models/lists/likes.ts index 39882d73af..dd3cf18a33 100644 --- a/src/state/models/lists/likes.ts +++ b/src/state/models/lists/likes.ts @@ -126,7 +126,7 @@ export class LikesModel { _appendAll(res: GetLikes.Response) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor - this.rootStore.me.follows.hydrateProfiles( + this.rootStore.me.follows.hydrateMany( res.data.likes.map(like => like.actor), ) this.likes = this.likes.concat(res.data.likes) diff --git a/src/state/models/lists/reposted-by.ts b/src/state/models/lists/reposted-by.ts index a70375bdc3..5d4fc107d7 100644 --- a/src/state/models/lists/reposted-by.ts +++ b/src/state/models/lists/reposted-by.ts @@ -130,6 +130,6 @@ export class RepostedByModel { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.repostedBy = this.repostedBy.concat(res.data.repostedBy) - this.rootStore.me.follows.hydrateProfiles(res.data.repostedBy) + this.rootStore.me.follows.hydrateMany(res.data.repostedBy) } } diff --git a/src/state/models/lists/user-followers.ts b/src/state/models/lists/user-followers.ts index 2962d62428..1f817c33c0 100644 --- a/src/state/models/lists/user-followers.ts +++ b/src/state/models/lists/user-followers.ts @@ -115,6 +115,6 @@ export class UserFollowersModel { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.followers = this.followers.concat(res.data.followers) - this.rootStore.me.follows.hydrateProfiles(res.data.followers) + this.rootStore.me.follows.hydrateMany(res.data.followers) } } diff --git a/src/state/models/lists/user-follows.ts b/src/state/models/lists/user-follows.ts index 56432a7961..c9630eba82 100644 --- a/src/state/models/lists/user-follows.ts +++ b/src/state/models/lists/user-follows.ts @@ -115,6 +115,6 @@ export class UserFollowsModel { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.follows = this.follows.concat(res.data.follows) - this.rootStore.me.follows.hydrateProfiles(res.data.follows) + this.rootStore.me.follows.hydrateMany(res.data.follows) } } diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 186e61cf6b..8a7a4c851e 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -25,13 +25,13 @@ export class MeModel { savedFeeds: SavedFeedsModel notifications: NotificationsFeedModel follows: MyFollowsCache - invites: ComAtprotoServerDefs.InviteCode[] = [] + invites: ComAtprotoServerDefs.InviteCode[] | null = [] appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = [] lastProfileStateUpdate = Date.now() lastNotifsUpdate = Date.now() get invitesAvailable() { - return this.invites.filter(isInviteAvailable).length + return this.invites?.filter(isInviteAvailable).length || null } constructor(public rootStore: RootStoreModel) { @@ -180,7 +180,9 @@ export class MeModel { } catch (e) { this.rootStore.log.error('Failed to fetch user invite codes', e) } - await this.rootStore.invitedUsers.fetch(this.invites) + if (this.invites) { + await this.rootStore.invitedUsers.fetch(this.invites) + } } } diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 63cbd33a88..1573eecc2c 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -21,6 +21,7 @@ import {PreferencesModel} from './ui/preferences' import {resetToTab} from '../../Navigation' import {ImageSizesCache} from './cache/image-sizes' import {MutedThreads} from './muted-threads' +import {Reminders} from './ui/reminders' import {reset as resetNavigation} from '../../Navigation' import {RecentTagsModel} from './ui/tags-autocomplete' @@ -54,6 +55,7 @@ export class RootStoreModel { linkMetas = new LinkMetasCache(this) imageSizes = new ImageSizesCache() mutedThreads = new MutedThreads() + reminders = new Reminders(this) recentTags = new RecentTagsModel() constructor(agent: BskyAgent) { @@ -79,6 +81,7 @@ export class RootStoreModel { preferences: this.preferences.serialize(), invitedUsers: this.invitedUsers.serialize(), mutedThreads: this.mutedThreads.serialize(), + reminders: this.reminders.serialize(), recentTags: this.recentTags.serialize(), } } @@ -115,6 +118,9 @@ export class RootStoreModel { if (hasProp(v, 'recentTags')) { this.recentTags.hydrate(v.recentTags) } + if (hasProp(v, 'reminders')) { + this.reminders.hydrate(v.reminders) + } } } diff --git a/src/state/models/session.ts b/src/state/models/session.ts index 1bc722c8cc..7cd3c1222f 100644 --- a/src/state/models/session.ts +++ b/src/state/models/session.ts @@ -30,6 +30,7 @@ export const accountData = z.object({ email: z.string().optional(), displayName: z.string().optional(), aviUrl: z.string().optional(), + emailConfirmed: z.boolean().optional(), }) export type AccountData = z.infer @@ -106,6 +107,10 @@ export class SessionModel { return this.accounts.filter(acct => acct.did !== this.data?.did) } + get emailNeedsConfirmation() { + return !this.currentSession?.emailConfirmed + } + get isSandbox() { if (!this.data) { return false @@ -217,6 +222,7 @@ export class SessionModel { ? addedInfo.displayName : existingAccount?.displayName || '', aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '', + emailConfirmed: session?.emailConfirmed, } if (!existingAccount) { this.accounts.push(newAccount) @@ -246,6 +252,8 @@ export class SessionModel { did: acct.did, displayName: acct.displayName, aviUrl: acct.aviUrl, + email: acct.email, + emailConfirmed: acct.emailConfirmed, })) } @@ -297,6 +305,8 @@ export class SessionModel { refreshJwt: account.refreshJwt || '', did: account.did, handle: account.handle, + email: account.email, + emailConfirmed: account.emailConfirmed, }), ) const addedInfo = await this.loadAccountInfo(agent, account.did) @@ -452,4 +462,10 @@ export class SessionModel { await this.rootStore.me.load() } } + + updateLocalAccountData(changes: Partial) { + this.accounts = this.accounts.map(acct => + acct.did === this.data?.did ? {...acct, ...changes} : acct, + ) + } } diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index b3365bd7cd..6ca19b4b74 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -418,6 +418,7 @@ export class PreferencesModel { const oldPinned = this.pinnedFeeds this.savedFeeds = saved this.pinnedFeeds = pinned + await this.lock.acquireAsync() try { const res = await cb() runInAction(() => { @@ -430,6 +431,8 @@ export class PreferencesModel { this.pinnedFeeds = oldPinned }) throw e + } finally { + this.lock.release() } } @@ -441,7 +444,7 @@ export class PreferencesModel { async addSavedFeed(v: string) { return this._optimisticUpdateSavedFeeds( - [...this.savedFeeds, v], + [...this.savedFeeds.filter(uri => uri !== v), v], this.pinnedFeeds, () => this.rootStore.agent.addSavedFeed(v), ) @@ -457,8 +460,8 @@ export class PreferencesModel { async addPinnedFeed(v: string) { return this._optimisticUpdateSavedFeeds( - this.savedFeeds, - [...this.pinnedFeeds, v], + [...this.savedFeeds.filter(uri => uri !== v), v], + [...this.pinnedFeeds.filter(uri => uri !== v), v], () => this.rootStore.agent.addPinnedFeed(v), ) } @@ -473,71 +476,121 @@ export class PreferencesModel { async setBirthDate(birthDate: Date) { this.birthDate = birthDate - await this.rootStore.agent.setPersonalDetails({birthDate}) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setPersonalDetails({birthDate}) + } finally { + this.lock.release() + } } async toggleHomeFeedHideReplies() { this.homeFeed.hideReplies = !this.homeFeed.hideReplies - await this.rootStore.agent.setFeedViewPrefs('home', { - hideReplies: this.homeFeed.hideReplies, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + hideReplies: this.homeFeed.hideReplies, + }) + } finally { + this.lock.release() + } } async toggleHomeFeedHideRepliesByUnfollowed() { this.homeFeed.hideRepliesByUnfollowed = !this.homeFeed.hideRepliesByUnfollowed - await this.rootStore.agent.setFeedViewPrefs('home', { - hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed, + }) + } finally { + this.lock.release() + } } async setHomeFeedHideRepliesByLikeCount(threshold: number) { this.homeFeed.hideRepliesByLikeCount = threshold - await this.rootStore.agent.setFeedViewPrefs('home', { - hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount, + }) + } finally { + this.lock.release() + } } async toggleHomeFeedHideReposts() { this.homeFeed.hideReposts = !this.homeFeed.hideReposts - await this.rootStore.agent.setFeedViewPrefs('home', { - hideReposts: this.homeFeed.hideReposts, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + hideReposts: this.homeFeed.hideReposts, + }) + } finally { + this.lock.release() + } } async toggleHomeFeedHideQuotePosts() { this.homeFeed.hideQuotePosts = !this.homeFeed.hideQuotePosts - await this.rootStore.agent.setFeedViewPrefs('home', { - hideQuotePosts: this.homeFeed.hideQuotePosts, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + hideQuotePosts: this.homeFeed.hideQuotePosts, + }) + } finally { + this.lock.release() + } } async toggleHomeFeedMergeFeedEnabled() { this.homeFeed.lab_mergeFeedEnabled = !this.homeFeed.lab_mergeFeedEnabled - await this.rootStore.agent.setFeedViewPrefs('home', { - lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setFeedViewPrefs('home', { + lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled, + }) + } finally { + this.lock.release() + } } async setThreadSort(v: string) { if (THREAD_SORT_VALUES.includes(v)) { this.thread.sort = v - await this.rootStore.agent.setThreadViewPrefs({sort: v}) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setThreadViewPrefs({sort: v}) + } finally { + this.lock.release() + } } } async togglePrioritizedFollowedUsers() { this.thread.prioritizeFollowedUsers = !this.thread.prioritizeFollowedUsers - await this.rootStore.agent.setThreadViewPrefs({ - prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setThreadViewPrefs({ + prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers, + }) + } finally { + this.lock.release() + } } async toggleThreadTreeViewEnabled() { this.thread.lab_treeViewEnabled = !this.thread.lab_treeViewEnabled - await this.rootStore.agent.setThreadViewPrefs({ - lab_treeViewEnabled: this.thread.lab_treeViewEnabled, - }) + await this.lock.acquireAsync() + try { + await this.rootStore.agent.setThreadViewPrefs({ + lab_treeViewEnabled: this.thread.lab_treeViewEnabled, + }) + } finally { + this.lock.release() + } } toggleRequireAltTextEnabled() { diff --git a/src/state/models/ui/reminders.ts b/src/state/models/ui/reminders.ts new file mode 100644 index 0000000000..60dbf5d880 --- /dev/null +++ b/src/state/models/ui/reminders.ts @@ -0,0 +1,64 @@ +import {makeAutoObservable} from 'mobx' +import {isObj, hasProp} from 'lib/type-guards' +import {RootStoreModel} from '../root-store' +import {toHashCode} from 'lib/strings/helpers' + +const DAY = 60e3 * 24 * 1 // 1 day (ms) + +export class Reminders { + lastEmailConfirm: Date = new Date() + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + {serialize: false, hydrate: false}, + {autoBind: true}, + ) + } + + serialize() { + return { + lastEmailConfirm: this.lastEmailConfirm + ? this.lastEmailConfirm.toISOString() + : undefined, + } + } + + hydrate(v: unknown) { + if ( + isObj(v) && + hasProp(v, 'lastEmailConfirm') && + typeof v.lastEmailConfirm === 'string' + ) { + this.lastEmailConfirm = new Date(v.lastEmailConfirm) + } + } + + get shouldRequestEmailConfirmation() { + const sess = this.rootStore.session.currentSession + if (!sess) { + return false + } + if (sess.emailConfirmed) { + return false + } + if (this.rootStore.onboarding.isActive) { + return false + } + const today = new Date() + // shard the users into 2 day of the week buckets + // (this is to avoid a sudden influx of email updates when + // this feature rolls out) + const code = toHashCode(sess.did) % 7 + if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) { + return false + } + // only ask once a day at most, but because of the bucketing + // this will be more like weekly + return Number(today) - Number(this.lastEmailConfirm) > DAY + } + + setEmailConfirmationRequested() { + this.lastEmailConfirm = new Date() + } +} diff --git a/src/state/models/ui/search.ts b/src/state/models/ui/search.ts index 4ab9db5135..2b2036751d 100644 --- a/src/state/models/ui/search.ts +++ b/src/state/models/ui/search.ts @@ -59,7 +59,7 @@ export class SearchUIModel { } while (profilesSearch.length) } - this.rootStore.me.follows.hydrateProfiles(profiles) + this.rootStore.me.follows.hydrateMany(profiles) runInAction(() => { this.profiles = profiles diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 6475135633..a8937b84ca 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -24,6 +24,7 @@ export interface ConfirmModal { onPressCancel?: () => void | Promise confirmBtnText?: string confirmBtnStyle?: StyleProp + cancelBtnText?: string } export interface EditProfileModal { @@ -140,6 +141,25 @@ export interface BirthDateSettingsModal { name: 'birth-date-settings' } +export interface VerifyEmailModal { + name: 'verify-email' + showReminder?: boolean +} + +export interface ChangeEmailModal { + name: 'change-email' +} + +export interface SwitchAccountModal { + name: 'switch-account' +} + +export interface LinkWarningModal { + name: 'link-warning' + text: string + href: string +} + export type Modal = // Account | AddAppPasswordModal @@ -148,6 +168,9 @@ export type Modal = | EditProfileModal | ProfilePreviewModal | BirthDateSettingsModal + | VerifyEmailModal + | ChangeEmailModal + | SwitchAccountModal // Curation | ContentFilteringSettingsModal @@ -174,6 +197,7 @@ export type Modal = // Generic | ConfirmModal + | LinkWarningModal interface LightboxModel {} @@ -250,6 +274,7 @@ export class ShellUiModel { }) this.setupClock() + this.setupLoginModals() } serialize(): unknown { @@ -375,4 +400,13 @@ export class ShellUiModel { }) }, 60_000) } + + setupLoginModals() { + this.rootStore.onSessionReady(() => { + if (this.rootStore.reminders.shouldRequestEmailConfirmation) { + this.openModal({name: 'verify-email', showReminder: true}) + this.rootStore.reminders.setEmailConfirmationRequested() + } + }) + } } diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx index 3c949bb9a2..cef9618efa 100644 --- a/src/view/com/auth/SplashScreen.web.tsx +++ b/src/view/com/auth/SplashScreen.web.tsx @@ -6,7 +6,8 @@ import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {CenteredView} from '../util/Views' -import {isMobileWeb} from 'platform/detection' +import {isWeb} from 'platform/detection' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' export const SplashScreen = ({ onPressSignin, @@ -16,6 +17,9 @@ export const SplashScreen = ({ onPressCreateAccount: () => void }) => { const pal = usePalette('default') + const {isTabletOrMobile} = useWebMediaQueries() + const styles = useStyles() + const isMobileWeb = isWeb && isTabletOrMobile return ( @@ -55,13 +59,14 @@ export const SplashScreen = ({ -