diff options
Diffstat (limited to 'src/lib')
37 files changed, 1465 insertions, 661 deletions
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 440dfa5ee..5fb7fe50e 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -104,18 +104,18 @@ export async function post(agent: BskyAgent, opts: PostOpts) { // add image embed if present if (opts.images?.length) { - logger.info(`Uploading images`, { + logger.debug(`Uploading images`, { count: opts.images.length, }) const images: AppBskyEmbedImages.Image[] = [] for (const image of opts.images) { opts.onStateChange?.(`Uploading image #${images.length + 1}...`) - logger.info(`Compressing image`) + logger.debug(`Compressing image`) await image.compress() const path = image.compressed?.path ?? image.path const {width, height} = image.compressed || image - logger.info(`Uploading image`) + logger.debug(`Uploading image`) const res = await uploadBlob(agent, path, 'image/jpeg') images.push({ image: res.data.blob, diff --git a/src/lib/app-info.ts b/src/lib/app-info.ts index 3f026d3fe..3071e031b 100644 --- a/src/lib/app-info.ts +++ b/src/lib/app-info.ts @@ -1,5 +1,9 @@ import VersionNumber from 'react-native-version-number' -import * as Updates from 'expo-updates' -export const updateChannel = Updates.channel -export const appVersion = `${VersionNumber.appVersion} (${VersionNumber.buildVersion})` +export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development' +export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight' + +const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production' +export const appVersion = `${VersionNumber.appVersion} (${ + VersionNumber.buildVersion +}, ${IS_DEV ? 'development' : UPDATES_CHANNEL})` diff --git a/src/lib/constants.ts b/src/lib/constants.ts index aec8338d0..f5a72669a 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -3,9 +3,8 @@ import {Insets, Platform} from 'react-native' export const LOCAL_DEV_SERVICE = Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' export const STAGING_SERVICE = 'https://staging.bsky.dev' -export const PROD_SERVICE = 'https://bsky.social' -export const DEFAULT_SERVICE = PROD_SERVICE - +export const BSKY_SERVICE = 'https://bsky.social' +export const DEFAULT_SERVICE = BSKY_SERVICE const HELP_DESK_LANG = 'en-us' export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}` @@ -36,92 +35,16 @@ export const MAX_GRAPHEME_LENGTH = 300 // but increasing limit per user feedback export const MAX_ALT_TEXT = 1000 -export function IS_LOCAL_DEV(url: string) { - return url.includes('localhost') -} - -export function IS_STAGING(url: string) { - return url.startsWith('https://staging.bsky.dev') -} - -export function IS_PROD(url: string) { - // NOTE - // until open federation, "production" is defined as the main server - // this definition will not work once federation is enabled! - // -prf - return ( - url.startsWith('https://bsky.social') || - url.startsWith('https://api.bsky.app') || - /bsky\.network\/?$/.test(url) - ) +export function IS_TEST_USER(handle?: string) { + return handle && handle?.endsWith('.test') } -export const PROD_TEAM_HANDLES = [ - 'jay.bsky.social', - 'pfrazee.com', - 'divy.zone', - 'dholms.xyz', - 'why.bsky.world', - 'iamrosewang.bsky.social', -] -export const STAGING_TEAM_HANDLES = [ - 'arcalinea.staging.bsky.dev', - 'paul.staging.bsky.dev', - 'paul2.staging.bsky.dev', -] -export const DEV_TEAM_HANDLES = ['alice.test', 'bob.test', 'carla.test'] - -export function TEAM_HANDLES(serviceUrl: string) { - if (serviceUrl.includes('localhost')) { - return DEV_TEAM_HANDLES - } else if (serviceUrl.includes('staging')) { - return STAGING_TEAM_HANDLES - } else { - return PROD_TEAM_HANDLES - } +export function IS_PROD_SERVICE(url?: string) { + return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE } -export const STAGING_DEFAULT_FEED = (rkey: string) => - `at://did:plc:wqzurwm3kmaig6e6hnc2gqwo/app.bsky.feed.generator/${rkey}` export const PROD_DEFAULT_FEED = (rkey: string) => `at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}` -export async function DEFAULT_FEEDS( - serviceUrl: string, - resolveHandle: (name: string) => Promise<string>, -) { - // 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') - return { - pinned: [ - `at://${aliceDid}/app.bsky.feed.generator/alice-favs`, - `at://${aliceDid}/app.bsky.feed.generator/alice-favs2`, - ], - saved: [ - `at://${aliceDid}/app.bsky.feed.generator/alice-favs`, - `at://${aliceDid}/app.bsky.feed.generator/alice-favs2`, - ], - } - } else if (IS_STAGING(serviceUrl)) { - // staging - return { - pinned: [STAGING_DEFAULT_FEED('whats-hot')], - saved: [ - STAGING_DEFAULT_FEED('bsky-team'), - STAGING_DEFAULT_FEED('with-friends'), - STAGING_DEFAULT_FEED('whats-hot'), - STAGING_DEFAULT_FEED('hot-classic'), - ], - } - } else { - // production - return { - pinned: [PROD_DEFAULT_FEED('whats-hot')], - saved: [PROD_DEFAULT_FEED('whats-hot')], - } - } -} export const POST_IMG_MAX = { width: 2000, @@ -135,13 +58,11 @@ export const STAGING_LINK_META_PROXY = export const PROD_LINK_META_PROXY = 'https://cardyb.bsky.app/v1/extract?url=' export function LINK_META_PROXY(serviceUrl: string) { - if (IS_LOCAL_DEV(serviceUrl)) { - return STAGING_LINK_META_PROXY - } else if (IS_STAGING(serviceUrl)) { - return STAGING_LINK_META_PROXY - } else { + if (IS_PROD_SERVICE(serviceUrl)) { return PROD_LINK_META_PROXY } + + return STAGING_LINK_META_PROXY } export const STATUS_PAGE_URL = 'https://status.bsky.app/' @@ -158,3 +79,9 @@ export const HITSLOP_20 = createHitslop(20) export const HITSLOP_30 = createHitslop(30) export const BACK_HITSLOP = HITSLOP_30 export const MAX_POST_LINES = 25 + +export const BSKY_FEED_OWNER_DIDS = [ + 'did:plc:z72i7hdynmk6r22z27h6tvur', + 'did:plc:vpkhqolt662uhesyj6nxm7ys', + 'did:plc:q6gjnaw2blty4crticxkmujt', +] diff --git a/src/lib/country-codes.ts b/src/lib/country-codes.ts deleted file mode 100644 index 9c9da84cf..000000000 --- a/src/lib/country-codes.ts +++ /dev/null @@ -1,256 +0,0 @@ -import {CountryCode} from 'libphonenumber-js' - -// ISO 3166-1 alpha-2 codes - -export interface CountryCodeMap { - code2: CountryCode - name: string -} - -export const COUNTRY_CODES: CountryCodeMap[] = [ - {code2: 'AF', name: 'Afghanistan (+93)'}, - {code2: 'AX', name: 'Åland Islands (+358)'}, - {code2: 'AL', name: 'Albania (+355)'}, - {code2: 'DZ', name: 'Algeria (+213)'}, - {code2: 'AS', name: 'American Samoa (+1)'}, - {code2: 'AD', name: 'Andorra (+376)'}, - {code2: 'AO', name: 'Angola (+244)'}, - {code2: 'AI', name: 'Anguilla (+1)'}, - {code2: 'AG', name: 'Antigua and Barbuda (+1)'}, - {code2: 'AR', name: 'Argentina (+54)'}, - {code2: 'AM', name: 'Armenia (+374)'}, - {code2: 'AW', name: 'Aruba (+297)'}, - {code2: 'AU', name: 'Australia (+61)'}, - {code2: 'AT', name: 'Austria (+43)'}, - {code2: 'AZ', name: 'Azerbaijan (+994)'}, - {code2: 'BS', name: 'Bahamas (+1)'}, - {code2: 'BH', name: 'Bahrain (+973)'}, - {code2: 'BD', name: 'Bangladesh (+880)'}, - {code2: 'BB', name: 'Barbados (+1)'}, - {code2: 'BY', name: 'Belarus (+375)'}, - {code2: 'BE', name: 'Belgium (+32)'}, - {code2: 'BZ', name: 'Belize (+501)'}, - {code2: 'BJ', name: 'Benin (+229)'}, - {code2: 'BM', name: 'Bermuda (+1)'}, - {code2: 'BT', name: 'Bhutan (+975)'}, - {code2: 'BO', name: 'Bolivia (Plurinational State of) (+591)'}, - {code2: 'BQ', name: 'Bonaire, Sint Eustatius and Saba (+599)'}, - {code2: 'BA', name: 'Bosnia and Herzegovina (+387)'}, - {code2: 'BW', name: 'Botswana (+267)'}, - {code2: 'BR', name: 'Brazil (+55)'}, - {code2: 'IO', name: 'British Indian Ocean Territory (+246)'}, - {code2: 'BN', name: 'Brunei Darussalam (+673)'}, - {code2: 'BG', name: 'Bulgaria (+359)'}, - {code2: 'BF', name: 'Burkina Faso (+226)'}, - {code2: 'BI', name: 'Burundi (+257)'}, - {code2: 'CV', name: 'Cabo Verde (+238)'}, - {code2: 'KH', name: 'Cambodia (+855)'}, - {code2: 'CM', name: 'Cameroon (+237)'}, - {code2: 'CA', name: 'Canada (+1)'}, - {code2: 'KY', name: 'Cayman Islands (+1)'}, - {code2: 'CF', name: 'Central African Republic (+236)'}, - {code2: 'TD', name: 'Chad (+235)'}, - {code2: 'CL', name: 'Chile (+56)'}, - {code2: 'CN', name: 'China (+86)'}, - {code2: 'CX', name: 'Christmas Island (+61)'}, - {code2: 'CC', name: 'Cocos (Keeling) Islands (+61)'}, - {code2: 'CO', name: 'Colombia (+57)'}, - {code2: 'KM', name: 'Comoros (+269)'}, - {code2: 'CG', name: 'Congo (+242)'}, - {code2: 'CD', name: 'Congo, Democratic Republic of the (+243)'}, - {code2: 'CK', name: 'Cook Islands (+682)'}, - {code2: 'CR', name: 'Costa Rica (+506)'}, - {code2: 'CI', name: "Côte d'Ivoire (+225)"}, - {code2: 'HR', name: 'Croatia (+385)'}, - {code2: 'CU', name: 'Cuba (+53)'}, - {code2: 'CW', name: 'Curaçao (+599)'}, - {code2: 'CY', name: 'Cyprus (+357)'}, - {code2: 'CZ', name: 'Czechia (+420)'}, - {code2: 'DK', name: 'Denmark (+45)'}, - {code2: 'DJ', name: 'Djibouti (+253)'}, - {code2: 'DM', name: 'Dominica (+1)'}, - {code2: 'DO', name: 'Dominican Republic (+1)'}, - {code2: 'EC', name: 'Ecuador (+593)'}, - {code2: 'EG', name: 'Egypt (+20)'}, - {code2: 'SV', name: 'El Salvador (+503)'}, - {code2: 'GQ', name: 'Equatorial Guinea (+240)'}, - {code2: 'ER', name: 'Eritrea (+291)'}, - {code2: 'EE', name: 'Estonia (+372)'}, - {code2: 'SZ', name: 'Eswatini (+268)'}, - {code2: 'ET', name: 'Ethiopia (+251)'}, - {code2: 'FK', name: 'Falkland Islands (Malvinas) (+500)'}, - {code2: 'FO', name: 'Faroe Islands (+298)'}, - {code2: 'FJ', name: 'Fiji (+679)'}, - {code2: 'FI', name: 'Finland (+358)'}, - {code2: 'FR', name: 'France (+33)'}, - {code2: 'GF', name: 'French Guiana (+594)'}, - {code2: 'PF', name: 'French Polynesia (+689)'}, - {code2: 'GA', name: 'Gabon (+241)'}, - {code2: 'GM', name: 'Gambia (+220)'}, - {code2: 'GE', name: 'Georgia (+995)'}, - {code2: 'DE', name: 'Germany (+49)'}, - {code2: 'GH', name: 'Ghana (+233)'}, - {code2: 'GI', name: 'Gibraltar (+350)'}, - {code2: 'GR', name: 'Greece (+30)'}, - {code2: 'GL', name: 'Greenland (+299)'}, - {code2: 'GD', name: 'Grenada (+1)'}, - {code2: 'GP', name: 'Guadeloupe (+590)'}, - {code2: 'GU', name: 'Guam (+1)'}, - {code2: 'GT', name: 'Guatemala (+502)'}, - {code2: 'GG', name: 'Guernsey (+44)'}, - {code2: 'GN', name: 'Guinea (+224)'}, - {code2: 'GW', name: 'Guinea-Bissau (+245)'}, - {code2: 'GY', name: 'Guyana (+592)'}, - {code2: 'HT', name: 'Haiti (+509)'}, - {code2: 'VA', name: 'Holy See (+39)'}, - {code2: 'HN', name: 'Honduras (+504)'}, - {code2: 'HK', name: 'Hong Kong (+852)'}, - {code2: 'HU', name: 'Hungary (+36)'}, - {code2: 'IS', name: 'Iceland (+354)'}, - {code2: 'IN', name: 'India (+91)'}, - {code2: 'ID', name: 'Indonesia (+62)'}, - {code2: 'IR', name: 'Iran (Islamic Republic of) (+98)'}, - {code2: 'IQ', name: 'Iraq (+964)'}, - {code2: 'IE', name: 'Ireland (+353)'}, - {code2: 'IM', name: 'Isle of Man (+44)'}, - {code2: 'IL', name: 'Israel (+972)'}, - {code2: 'IT', name: 'Italy (+39)'}, - {code2: 'JM', name: 'Jamaica (+1)'}, - {code2: 'JP', name: 'Japan (+81)'}, - {code2: 'JE', name: 'Jersey (+44)'}, - {code2: 'JO', name: 'Jordan (+962)'}, - {code2: 'KZ', name: 'Kazakhstan (+7)'}, - {code2: 'KE', name: 'Kenya (+254)'}, - {code2: 'KI', name: 'Kiribati (+686)'}, - {code2: 'KP', name: "Korea (Democratic People's Republic of) (+850)"}, - {code2: 'KR', name: 'Korea, Republic of (+82)'}, - {code2: 'KW', name: 'Kuwait (+965)'}, - {code2: 'KG', name: 'Kyrgyzstan (+996)'}, - {code2: 'LA', name: "Lao People's Democratic Republic (+856)"}, - {code2: 'LV', name: 'Latvia (+371)'}, - {code2: 'LB', name: 'Lebanon (+961)'}, - {code2: 'LS', name: 'Lesotho (+266)'}, - {code2: 'LR', name: 'Liberia (+231)'}, - {code2: 'LY', name: 'Libya (+218)'}, - {code2: 'LI', name: 'Liechtenstein (+423)'}, - {code2: 'LT', name: 'Lithuania (+370)'}, - {code2: 'LU', name: 'Luxembourg (+352)'}, - {code2: 'MO', name: 'Macao (+853)'}, - {code2: 'MG', name: 'Madagascar (+261)'}, - {code2: 'MW', name: 'Malawi (+265)'}, - {code2: 'MY', name: 'Malaysia (+60)'}, - {code2: 'MV', name: 'Maldives (+960)'}, - {code2: 'ML', name: 'Mali (+223)'}, - {code2: 'MT', name: 'Malta (+356)'}, - {code2: 'MH', name: 'Marshall Islands (+692)'}, - {code2: 'MQ', name: 'Martinique (+596)'}, - {code2: 'MR', name: 'Mauritania (+222)'}, - {code2: 'MU', name: 'Mauritius (+230)'}, - {code2: 'YT', name: 'Mayotte (+262)'}, - {code2: 'MX', name: 'Mexico (+52)'}, - {code2: 'FM', name: 'Micronesia (Federated States of) (+691)'}, - {code2: 'MD', name: 'Moldova, Republic of (+373)'}, - {code2: 'MC', name: 'Monaco (+377)'}, - {code2: 'MN', name: 'Mongolia (+976)'}, - {code2: 'ME', name: 'Montenegro (+382)'}, - {code2: 'MS', name: 'Montserrat (+1)'}, - {code2: 'MA', name: 'Morocco (+212)'}, - {code2: 'MZ', name: 'Mozambique (+258)'}, - {code2: 'MM', name: 'Myanmar (+95)'}, - {code2: 'NA', name: 'Namibia (+264)'}, - {code2: 'NR', name: 'Nauru (+674)'}, - {code2: 'NP', name: 'Nepal (+977)'}, - {code2: 'NL', name: 'Netherlands, Kingdom of the (+31)'}, - {code2: 'NC', name: 'New Caledonia (+687)'}, - {code2: 'NZ', name: 'New Zealand (+64)'}, - {code2: 'NI', name: 'Nicaragua (+505)'}, - {code2: 'NE', name: 'Niger (+227)'}, - {code2: 'NG', name: 'Nigeria (+234)'}, - {code2: 'NU', name: 'Niue (+683)'}, - {code2: 'NF', name: 'Norfolk Island (+672)'}, - {code2: 'MK', name: 'North Macedonia (+389)'}, - {code2: 'MP', name: 'Northern Mariana Islands (+1)'}, - {code2: 'NO', name: 'Norway (+47)'}, - {code2: 'OM', name: 'Oman (+968)'}, - {code2: 'PK', name: 'Pakistan (+92)'}, - {code2: 'PW', name: 'Palau (+680)'}, - {code2: 'PS', name: 'Palestine, State of (+970)'}, - {code2: 'PA', name: 'Panama (+507)'}, - {code2: 'PG', name: 'Papua New Guinea (+675)'}, - {code2: 'PY', name: 'Paraguay (+595)'}, - {code2: 'PE', name: 'Peru (+51)'}, - {code2: 'PH', name: 'Philippines (+63)'}, - {code2: 'PL', name: 'Poland (+48)'}, - {code2: 'PT', name: 'Portugal (+351)'}, - {code2: 'PR', name: 'Puerto Rico (+1)'}, - {code2: 'QA', name: 'Qatar (+974)'}, - {code2: 'RE', name: 'Réunion (+262)'}, - {code2: 'RO', name: 'Romania (+40)'}, - {code2: 'RU', name: 'Russian Federation (+7)'}, - {code2: 'RW', name: 'Rwanda (+250)'}, - {code2: 'BL', name: 'Saint Barthélemy (+590)'}, - {code2: 'SH', name: 'Saint Helena, Ascension and Tristan da Cunha (+290)'}, - {code2: 'KN', name: 'Saint Kitts and Nevis (+1)'}, - {code2: 'LC', name: 'Saint Lucia (+1)'}, - {code2: 'MF', name: 'Saint Martin (French part) (+590)'}, - {code2: 'PM', name: 'Saint Pierre and Miquelon (+508)'}, - {code2: 'VC', name: 'Saint Vincent and the Grenadines (+1)'}, - {code2: 'WS', name: 'Samoa (+685)'}, - {code2: 'SM', name: 'San Marino (+378)'}, - {code2: 'ST', name: 'Sao Tome and Principe (+239)'}, - {code2: 'SA', name: 'Saudi Arabia (+966)'}, - {code2: 'SN', name: 'Senegal (+221)'}, - {code2: 'RS', name: 'Serbia (+381)'}, - {code2: 'SC', name: 'Seychelles (+248)'}, - {code2: 'SL', name: 'Sierra Leone (+232)'}, - {code2: 'SG', name: 'Singapore (+65)'}, - {code2: 'SX', name: 'Sint Maarten (Dutch part) (+1)'}, - {code2: 'SK', name: 'Slovakia (+421)'}, - {code2: 'SI', name: 'Slovenia (+386)'}, - {code2: 'SB', name: 'Solomon Islands (+677)'}, - {code2: 'SO', name: 'Somalia (+252)'}, - {code2: 'ZA', name: 'South Africa (+27)'}, - {code2: 'SS', name: 'South Sudan (+211)'}, - {code2: 'ES', name: 'Spain (+34)'}, - {code2: 'LK', name: 'Sri Lanka (+94)'}, - {code2: 'SD', name: 'Sudan (+249)'}, - {code2: 'SR', name: 'Suriname (+597)'}, - {code2: 'SJ', name: 'Svalbard and Jan Mayen (+47)'}, - {code2: 'SE', name: 'Sweden (+46)'}, - {code2: 'CH', name: 'Switzerland (+41)'}, - {code2: 'SY', name: 'Syrian Arab Republic (+963)'}, - {code2: 'TW', name: 'Taiwan (+886)'}, - {code2: 'TJ', name: 'Tajikistan (+992)'}, - {code2: 'TZ', name: 'Tanzania, United Republic of (+255)'}, - {code2: 'TH', name: 'Thailand (+66)'}, - {code2: 'TL', name: 'Timor-Leste (+670)'}, - {code2: 'TG', name: 'Togo (+228)'}, - {code2: 'TK', name: 'Tokelau (+690)'}, - {code2: 'TO', name: 'Tonga (+676)'}, - {code2: 'TT', name: 'Trinidad and Tobago (+1)'}, - {code2: 'TN', name: 'Tunisia (+216)'}, - {code2: 'TR', name: 'Türkiye (+90)'}, - {code2: 'TM', name: 'Turkmenistan (+993)'}, - {code2: 'TC', name: 'Turks and Caicos Islands (+1)'}, - {code2: 'TV', name: 'Tuvalu (+688)'}, - {code2: 'UG', name: 'Uganda (+256)'}, - {code2: 'UA', name: 'Ukraine (+380)'}, - {code2: 'AE', name: 'United Arab Emirates (+971)'}, - { - code2: 'GB', - name: 'United Kingdom of Great Britain and Northern Ireland (+44)', - }, - {code2: 'US', name: 'United States of America (+1)'}, - {code2: 'UY', name: 'Uruguay (+598)'}, - {code2: 'UZ', name: 'Uzbekistan (+998)'}, - {code2: 'VU', name: 'Vanuatu (+678)'}, - {code2: 'VE', name: 'Venezuela (Bolivarian Republic of) (+58)'}, - {code2: 'VN', name: 'Viet Nam (+84)'}, - {code2: 'VG', name: 'Virgin Islands (British) (+1)'}, - {code2: 'VI', name: 'Virgin Islands (U.S.) (+1)'}, - {code2: 'WF', name: 'Wallis and Futuna (+681)'}, - {code2: 'EH', name: 'Western Sahara (+212)'}, - {code2: 'YE', name: 'Yemen (+967)'}, - {code2: 'ZM', name: 'Zambia (+260)'}, - {code2: 'ZW', name: 'Zimbabwe (+263)'}, -] diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts index 74b5674d5..eb1685a0a 100644 --- a/src/lib/hooks/useAccountSwitcher.ts +++ b/src/lib/hooks/useAccountSwitcher.ts @@ -6,6 +6,7 @@ import {useSessionApi, SessionAccount} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import {useCloseAllActiveElements} from '#/state/util' import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import {LogEvents} from '../statsig/statsig' export function useAccountSwitcher() { const {track} = useAnalytics() @@ -14,7 +15,10 @@ export function useAccountSwitcher() { const {requestSwitchToAccount} = useLoggedOutViewControls() const onPressSwitchAccount = useCallback( - async (account: SessionAccount) => { + async ( + account: SessionAccount, + logContext: LogEvents['account:loggedIn']['logContext'], + ) => { track('Settings:SwitchAccountButtonClicked') try { @@ -28,7 +32,7 @@ export function useAccountSwitcher() { // So we change the URL ourselves. The navigator will pick it up on remount. history.pushState(null, '', '/') } - await selectAccount(account) + await selectAccount(account, logContext) setTimeout(() => { Toast.show(`Signed in as @${account.handle}`) }, 100) diff --git a/src/lib/hooks/useDedupe.ts b/src/lib/hooks/useDedupe.ts new file mode 100644 index 000000000..d9432cb2c --- /dev/null +++ b/src/lib/hooks/useDedupe.ts @@ -0,0 +1,17 @@ +import React from 'react' + +export const useDedupe = () => { + const canDo = React.useRef(true) + + return React.useRef((cb: () => unknown) => { + if (canDo.current) { + canDo.current = false + setTimeout(() => { + canDo.current = true + }, 250) + cb() + return true + } + return false + }).current +} diff --git a/src/lib/hooks/useInitialNumToRender.ts b/src/lib/hooks/useInitialNumToRender.ts new file mode 100644 index 000000000..942f0404a --- /dev/null +++ b/src/lib/hooks/useInitialNumToRender.ts @@ -0,0 +1,11 @@ +import React from 'react' +import {Dimensions} from 'react-native' + +const MIN_POST_HEIGHT = 100 + +export function useInitialNumToRender(minItemHeight: number = MIN_POST_HEIGHT) { + return React.useMemo(() => { + const screenHeight = Dimensions.get('window').height + return Math.ceil(screenHeight / minItemHeight) + 1 + }, [minItemHeight]) +} diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts new file mode 100644 index 000000000..8741530b5 --- /dev/null +++ b/src/lib/hooks/useIntentHandler.ts @@ -0,0 +1,93 @@ +import React from 'react' +import * as Linking from 'expo-linking' +import {isNative} from 'platform/detection' +import {useComposerControls} from 'state/shell' +import {useSession} from 'state/session' +import {useCloseAllActiveElements} from 'state/util' + +type IntentType = 'compose' + +const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/ + +export function useIntentHandler() { + const incomingUrl = Linking.useURL() + const composeIntent = useComposeIntent() + + React.useEffect(() => { + const handleIncomingURL = (url: string) => { + // We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three + // slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care + // of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first + // path parameter is in pathname rather than in hostname. + if (url.startsWith('bluesky://') && !url.startsWith('bluesky:///')) { + url = url.replace('bluesky://', 'bluesky:///') + } + + const urlp = new URL(url) + const [_, intent, intentType] = urlp.pathname.split('/') + + // On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the + // intent check. On web, we have to check the first part of the path since we have an actual hostname + const isIntent = intent === 'intent' + const params = urlp.searchParams + + if (!isIntent) return + + switch (intentType as IntentType) { + case 'compose': { + composeIntent({ + text: params.get('text'), + imageUrisStr: params.get('imageUris'), + }) + } + } + } + + if (incomingUrl) handleIncomingURL(incomingUrl) + }, [incomingUrl, composeIntent]) +} + +function useComposeIntent() { + const closeAllActiveElements = useCloseAllActiveElements() + const {openComposer} = useComposerControls() + const {hasSession} = useSession() + + return React.useCallback( + ({ + text, + imageUrisStr, + }: { + text: string | null + imageUrisStr: string | null // unused for right now, will be used later with intents + }) => { + if (!hasSession) return + + closeAllActiveElements() + + const imageUris = imageUrisStr + ?.split(',') + .filter(part => { + // For some security, we're going to filter out any image uri that is external. We don't want someone to + // be able to provide some link like "bluesky://intent/compose?imageUris=https://IHaveYourIpNow.com/image.jpeg + // and we load that image + if (part.includes('https://') || part.includes('http://')) { + return false + } + // We also should just filter out cases that don't have all the info we need + return VALID_IMAGE_REGEX.test(part) + }) + .map(part => { + const [uri, width, height] = part.split('|') + return {uri, width: Number(width), height: Number(height)} + }) + + setTimeout(() => { + openComposer({ + text: text ?? undefined, + imageUris: isNative ? imageUris : undefined, + }) + }, 500) + }, + [hasSession, closeAllActiveElements, openComposer], + ) +} diff --git a/src/lib/hooks/useNavigationDeduped.ts b/src/lib/hooks/useNavigationDeduped.ts new file mode 100644 index 000000000..d913f7f3d --- /dev/null +++ b/src/lib/hooks/useNavigationDeduped.ts @@ -0,0 +1,80 @@ +import React from 'react' +import {useNavigation} from '@react-navigation/core' +import {AllNavigatorParams, NavigationProp} from 'lib/routes/types' +import type {NavigationAction} from '@react-navigation/routers' +import {NavigationState} from '@react-navigation/native' +import {useDedupe} from 'lib/hooks/useDedupe' + +export type DebouncedNavigationProp = Pick< + NavigationProp, + | 'popToTop' + | 'push' + | 'navigate' + | 'canGoBack' + | 'replace' + | 'dispatch' + | 'goBack' + | 'getState' +> + +export function useNavigationDeduped() { + const navigation = useNavigation<NavigationProp>() + const dedupe = useDedupe() + + return React.useMemo( + (): DebouncedNavigationProp => ({ + // Types from @react-navigation/routers/lib/typescript/src/StackRouter.ts + push: <RouteName extends keyof AllNavigatorParams>( + ...args: undefined extends AllNavigatorParams[RouteName] + ? + | [screen: RouteName] + | [screen: RouteName, params: AllNavigatorParams[RouteName]] + : [screen: RouteName, params: AllNavigatorParams[RouteName]] + ) => { + dedupe(() => navigation.push(...args)) + }, + // Types from @react-navigation/core/src/types.tsx + navigate: <RouteName extends keyof AllNavigatorParams>( + ...args: RouteName extends unknown + ? undefined extends AllNavigatorParams[RouteName] + ? + | [screen: RouteName] + | [screen: RouteName, params: AllNavigatorParams[RouteName]] + : [screen: RouteName, params: AllNavigatorParams[RouteName]] + : never + ) => { + dedupe(() => navigation.navigate(...args)) + }, + // Types from @react-navigation/routers/lib/typescript/src/StackRouter.ts + replace: <RouteName extends keyof AllNavigatorParams>( + ...args: undefined extends AllNavigatorParams[RouteName] + ? + | [screen: RouteName] + | [screen: RouteName, params: AllNavigatorParams[RouteName]] + : [screen: RouteName, params: AllNavigatorParams[RouteName]] + ) => { + dedupe(() => navigation.replace(...args)) + }, + dispatch: ( + action: + | NavigationAction + | ((state: NavigationState) => NavigationAction), + ) => { + dedupe(() => navigation.dispatch(action)) + }, + popToTop: () => { + dedupe(() => navigation.popToTop()) + }, + goBack: () => { + dedupe(() => navigation.goBack()) + }, + canGoBack: () => { + return navigation.canGoBack() + }, + getState: () => { + return navigation.getState() + }, + }), + [dedupe, navigation], + ) +} diff --git a/src/lib/hooks/useOTAUpdate.ts b/src/lib/hooks/useOTAUpdate.ts index 53eab300e..d35179256 100644 --- a/src/lib/hooks/useOTAUpdate.ts +++ b/src/lib/hooks/useOTAUpdate.ts @@ -2,25 +2,9 @@ import * as Updates from 'expo-updates' import {useCallback, useEffect} from 'react' import {AppState} from 'react-native' import {logger} from '#/logger' -import {useModalControls} from '#/state/modals' -import {t} from '@lingui/macro' export function useOTAUpdate() { - const {openModal} = useModalControls() - // HELPER FUNCTIONS - const showUpdatePopup = useCallback(() => { - openModal({ - name: 'confirm', - title: t`Update Available`, - message: t`A new version of the app is available. Please update to continue using the app.`, - onPressConfirm: async () => { - Updates.reloadAsync().catch(err => { - throw err - }) - }, - }) - }, [openModal]) const checkForUpdate = useCallback(async () => { logger.debug('useOTAUpdate: Checking for update...') try { @@ -32,32 +16,26 @@ export function useOTAUpdate() { } // Otherwise fetch the update in the background, so even if the user rejects switching to latest version it will be done automatically on next relaunch. await Updates.fetchUpdateAsync() - // show a popup modal - showUpdatePopup() } catch (e) { logger.error('useOTAUpdate: Error while checking for update', { message: e, }) } - }, [showUpdatePopup]) - const updateEventListener = useCallback( - (event: Updates.UpdateEvent) => { - logger.debug('useOTAUpdate: Listening for update...') - if (event.type === Updates.UpdateEventType.ERROR) { - logger.error('useOTAUpdate: Error while listening for update', { - message: event.message, - }) - } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) { - // Handle no update available - // do nothing - } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) { - // Handle update available - // open modal, ask for user confirmation, and reload the app - showUpdatePopup() - } - }, - [showUpdatePopup], - ) + }, []) + const updateEventListener = useCallback((event: Updates.UpdateEvent) => { + logger.debug('useOTAUpdate: Listening for update...') + if (event.type === Updates.UpdateEventType.ERROR) { + logger.error('useOTAUpdate: Error while listening for update', { + message: event.message, + }) + } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) { + // Handle no update available + // do nothing + } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) { + // Handle update available + // open modal, ask for user confirmation, and reload the app + } + }, []) useEffect(() => { // ADD EVENT LISTENERS diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts new file mode 100644 index 000000000..181f0b2c6 --- /dev/null +++ b/src/lib/hooks/useOTAUpdates.ts @@ -0,0 +1,142 @@ +import React from 'react' +import {Alert, AppState, AppStateStatus} from 'react-native' +import app from 'react-native-version-number' +import { + checkForUpdateAsync, + fetchUpdateAsync, + isEnabled, + reloadAsync, + setExtraParamAsync, + useUpdates, +} from 'expo-updates' + +import {logger} from '#/logger' +import {IS_TESTFLIGHT} from 'lib/app-info' +import {isIOS} from 'platform/detection' + +const MINIMUM_MINIMIZE_TIME = 15 * 60e3 + +async function setExtraParams() { + await setExtraParamAsync( + isIOS ? 'ios-build-number' : 'android-build-number', + // Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is. + // This just ensures it gets passed as a string + `${app.buildVersion}`, + ) + await setExtraParamAsync( + 'channel', + IS_TESTFLIGHT ? 'testflight' : 'production', + ) +} + +export function useOTAUpdates() { + const appState = React.useRef<AppStateStatus>('active') + const lastMinimize = React.useRef(0) + const ranInitialCheck = React.useRef(false) + const timeout = React.useRef<NodeJS.Timeout>() + const {isUpdatePending} = useUpdates() + + const setCheckTimeout = React.useCallback(() => { + timeout.current = setTimeout(async () => { + try { + await setExtraParams() + + logger.debug('Checking for update...') + const res = await checkForUpdateAsync() + + if (res.isAvailable) { + logger.debug('Attempting to fetch update...') + await fetchUpdateAsync() + } else { + logger.debug('No update available.') + } + } catch (e) { + logger.warn('OTA Update Error', {error: `${e}`}) + } + }, 10e3) + }, []) + + const onIsTestFlight = React.useCallback(() => { + setTimeout(async () => { + try { + await setExtraParams() + + const res = await checkForUpdateAsync() + if (res.isAvailable) { + await fetchUpdateAsync() + + Alert.alert( + 'Update Available', + 'A new version of the app is available. Relaunch now?', + [ + { + text: 'No', + style: 'cancel', + }, + { + text: 'Relaunch', + style: 'default', + onPress: async () => { + await reloadAsync() + }, + }, + ], + ) + } + } catch (e: any) { + // No need to handle + } + }, 3e3) + }, []) + + React.useEffect(() => { + // For Testflight users, we can prompt the user to update immediately whenever there's an available update. This + // is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update + // immediately. + if (IS_TESTFLIGHT) { + onIsTestFlight() + return + } else if (!isEnabled || __DEV__ || ranInitialCheck.current) { + // Development client shouldn't check for updates at all, so we skip that here. + return + } + + setCheckTimeout() + ranInitialCheck.current = true + }, [onIsTestFlight, setCheckTimeout]) + + // After the app has been minimized for 30 minutes, we want to either A. install an update if one has become available + // or B check for an update again. + React.useEffect(() => { + if (!isEnabled) return + + const subscription = AppState.addEventListener( + 'change', + async nextAppState => { + if ( + appState.current.match(/inactive|background/) && + nextAppState === 'active' + ) { + // If it's been 15 minutes since the last "minimize", we should feel comfortable updating the client since + // chances are that there isn't anything important going on in the current session. + if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) { + if (isUpdatePending) { + await reloadAsync() + } else { + setCheckTimeout() + } + } + } else { + lastMinimize.current = Date.now() + } + + appState.current = nextAppState + }, + ) + + return () => { + clearTimeout(timeout.current) + subscription.remove() + } + }, [isUpdatePending, setCheckTimeout]) +} diff --git a/src/lib/hooks/useWebBodyScrollLock.ts b/src/lib/hooks/useWebBodyScrollLock.ts index 585f193f1..0dcf911fe 100644 --- a/src/lib/hooks/useWebBodyScrollLock.ts +++ b/src/lib/hooks/useWebBodyScrollLock.ts @@ -6,6 +6,7 @@ let refCount = 0 function incrementRefCount() { if (refCount === 0) { document.body.style.overflow = 'hidden' + document.documentElement.style.scrollbarGutter = 'auto' } refCount++ } @@ -14,6 +15,7 @@ function decrementRefCount() { refCount-- if (refCount === 0) { document.body.style.overflow = '' + document.documentElement.style.scrollbarGutter = '' } } diff --git a/src/lib/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts index c7c8d4130..fa951432e 100644 --- a/src/lib/link-meta/link-meta.ts +++ b/src/lib/link-meta/link-meta.ts @@ -26,7 +26,7 @@ export interface LinkMeta { export async function getLinkMeta( agent: BskyAgent, url: string, - timeout = 5e3, + timeout = 15e3, ): Promise<LinkMeta> { if (isBskyAppUrl(url)) { return extractBskyMeta(agent, url) diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx index d7b608041..31702ab22 100644 --- a/src/lib/media/picker.e2e.tsx +++ b/src/lib/media/picker.e2e.tsx @@ -3,7 +3,6 @@ import RNFS from 'react-native-fs' import {CropperOptions} from './types' import {compressIfNeeded} from './manip' -let _imageCounter = 0 async function getFile() { let files = await RNFS.readDir( RNFS.LibraryDirectoryPath.split('/') @@ -12,7 +11,7 @@ async function getFile() { .join('/'), ) files = files.filter(file => file.path.endsWith('.JPG')) - const file = files[_imageCounter++ % files.length] + const file = files[0] return await compressIfNeeded({ path: file.path, mime: 'image/jpeg', diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts index 8bade34e2..96e82e4c7 100644 --- a/src/lib/media/picker.shared.ts +++ b/src/lib/media/picker.shared.ts @@ -18,11 +18,18 @@ export async function openPicker(opts?: ImagePickerOptions) { Toast.show('You may only select up to 4 images') } - return (response.assets ?? []).slice(0, 4).map(image => ({ - mime: 'image/jpeg', - height: image.height, - width: image.width, - path: image.uri, - size: getDataUriSize(image.uri), - })) + return (response.assets ?? []) + .slice(0, 4) + .filter(asset => { + if (asset.mimeType?.startsWith('image/')) return true + Toast.show('Only image files are supported') + return false + }) + .map(image => ({ + mime: 'image/jpeg', + height: image.height, + width: image.width, + path: image.uri, + size: getDataUriSize(image.uri), + })) } diff --git a/src/lib/moderatePost_wrapped.ts b/src/lib/moderatePost_wrapped.ts index 2195b2304..0ce01368a 100644 --- a/src/lib/moderatePost_wrapped.ts +++ b/src/lib/moderatePost_wrapped.ts @@ -1,58 +1,30 @@ -import { - AppBskyEmbedRecord, - AppBskyEmbedRecordWithMedia, - moderatePost, -} from '@atproto/api' +import {moderatePost, BSKY_LABELER_DID} from '@atproto/api' type ModeratePost = typeof moderatePost -type Options = Parameters<ModeratePost>[1] & { - hiddenPosts?: string[] -} +type Options = Parameters<ModeratePost>[1] export function moderatePost_wrapped( subject: Parameters<ModeratePost>[0], opts: Options, ) { - const {hiddenPosts = [], ...options} = opts - const moderations = moderatePost(subject, options) + // HACK + // temporarily translate 'gore' into 'graphic-media' during the transition period + // can remove this in a few months + // -prf + translateOldLabels(subject) - if (hiddenPosts.includes(subject.uri)) { - moderations.content.filter = true - moderations.content.blur = true - if (!moderations.content.cause) { - moderations.content.cause = { - // @ts-ignore Temporary extension to the moderation system -prf - type: 'post-hidden', - source: {type: 'user'}, - priority: 1, - } - } - } + return moderatePost(subject, opts) +} - if (subject.embed) { - let embedHidden = false - if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) { - embedHidden = hiddenPosts.includes(subject.embed.record.uri) - } - if ( - AppBskyEmbedRecordWithMedia.isView(subject.embed) && - AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) - ) { - embedHidden = hiddenPosts.includes(subject.embed.record.record.uri) - } - if (embedHidden) { - moderations.embed.filter = true - moderations.embed.blur = true - if (!moderations.embed.cause) { - moderations.embed.cause = { - // @ts-ignore Temporary extension to the moderation system -prf - type: 'post-hidden', - source: {type: 'user'}, - priority: 1, - } +function translateOldLabels(subject: Parameters<ModeratePost>[0]) { + if (subject.labels) { + for (const label of subject.labels) { + if ( + label.val === 'gore' && + (!label.src || label.src === BSKY_LABELER_DID) + ) { + label.val = 'graphic-media' } } } - - return moderations } diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index bf19c208a..4105c2c2d 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -1,142 +1,81 @@ -import {ModerationCause, ProfileModeration, PostModeration} from '@atproto/api' +import { + ModerationCause, + ModerationUI, + InterpretedLabelValueDefinition, + LABELS, + AppBskyLabelerDefs, + BskyAgent, + ModerationOpts, +} from '@atproto/api' -export interface ModerationCauseDescription { - name: string - description: string -} +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' -export function describeModerationCause( - cause: ModerationCause | undefined, - context: 'account' | 'content', -): ModerationCauseDescription { - if (!cause) { - return { - name: 'Content Warning', - description: - 'Moderator has chosen to set a general warning on the content.', - } - } - if (cause.type === 'blocking') { - if (cause.source.type === 'list') { - return { - name: `User Blocked by "${cause.source.list.name}"`, - description: - 'You have blocked this user. You cannot view their content.', - } - } else { - return { - name: 'User Blocked', - description: - 'You have blocked this user. You cannot view their content.', - } - } - } - if (cause.type === 'blocked-by') { - return { - name: 'User Blocking You', - description: 'This user has blocked you. You cannot view their content.', - } - } - if (cause.type === 'block-other') { - return { - name: 'Content Not Available', - description: - 'This content is not available because one of the users involved has blocked the other.', - } - } - if (cause.type === 'muted') { - if (cause.source.type === 'list') { - return { - name: - context === 'account' - ? `Muted by "${cause.source.list.name}"` - : `Post by muted user ("${cause.source.list.name}")`, - description: 'You have muted this user', - } - } else { - return { - name: context === 'account' ? 'Muted User' : 'Post by muted user', - description: 'You have muted this user', - } - } - } - // @ts-ignore Temporary extension to the moderation system -prf - if (cause.type === 'post-hidden') { - return { - name: 'Post Hidden by You', - description: 'You have hidden this post', - } +export function getModerationCauseKey(cause: ModerationCause): string { + const source = + cause.source.type === 'labeler' + ? cause.source.did + : cause.source.type === 'list' + ? cause.source.list.uri + : 'user' + if (cause.type === 'label') { + return `label:${cause.label.val}:${source}` } - return cause.labelDef.strings[context].en + return `${cause.type}:${source}` } -export function getProfileModerationCauses( - moderation: ProfileModeration, -): ModerationCause[] { - /* - Gather everything on profile and account that blurs or alerts - */ - return [ - moderation.decisions.profile.cause, - ...moderation.decisions.profile.additionalCauses, - moderation.decisions.account.cause, - ...moderation.decisions.account.additionalCauses, - ].filter(cause => { - if (!cause) { - return false - } - if (cause?.type === 'label') { - if ( - cause.labelDef.onwarn === 'blur' || - cause.labelDef.onwarn === 'alert' - ) { - return true - } else { - return false - } - } - return true - }) as ModerationCause[] +export function isJustAMute(modui: ModerationUI): boolean { + return modui.filters.length === 1 && modui.filters[0].type === 'muted' } -export function isPostMediaBlurred( - decisions: PostModeration['decisions'], -): boolean { - return decisions.post.blurMedia +export function getLabelingServiceTitle({ + displayName, + handle, +}: { + displayName?: string + handle: string +}) { + return displayName + ? sanitizeDisplayName(displayName) + : sanitizeHandle(handle, '@') } -export function isQuoteBlurred( - decisions: PostModeration['decisions'], -): boolean { - return ( - decisions.quote?.blur || - decisions.quote?.blurMedia || - decisions.quote?.filter || - decisions.quotedAccount?.blur || - decisions.quotedAccount?.filter || - false - ) +export function lookupLabelValueDefinition( + labelValue: string, + customDefs: InterpretedLabelValueDefinition[] | undefined, +): InterpretedLabelValueDefinition | undefined { + let def + if (!labelValue.startsWith('!') && customDefs) { + def = customDefs.find(d => d.identifier === labelValue) + } + if (!def) { + def = LABELS[labelValue as keyof typeof LABELS] + } + return def } -export function isCauseALabelOnUri( - cause: ModerationCause | undefined, - uri: string, +export function isAppLabeler( + labeler: + | string + | AppBskyLabelerDefs.LabelerView + | AppBskyLabelerDefs.LabelerViewDetailed, ): boolean { - if (cause?.type !== 'label') { - return false + if (typeof labeler === 'string') { + return BskyAgent.appLabelers.includes(labeler) } - return cause.label.uri === uri + return BskyAgent.appLabelers.includes(labeler.creator.did) } -export function getModerationCauseKey(cause: ModerationCause): string { - const source = - cause.source.type === 'labeler' - ? cause.source.labeler.did - : cause.source.type === 'list' - ? cause.source.list.uri - : 'user' - if (cause.type === 'label') { - return `label:${cause.label.val}:${source}` +export function isLabelerSubscribed( + labeler: + | string + | AppBskyLabelerDefs.LabelerView + | AppBskyLabelerDefs.LabelerViewDetailed, + modOpts: ModerationOpts, +) { + labeler = typeof labeler === 'string' ? labeler : labeler.creator.did + if (isAppLabeler(labeler)) { + return true } - return `${cause.type}:${source}` + return modOpts.prefs.labelers.find(l => l.did === labeler) } diff --git a/src/lib/moderation/useGlobalLabelStrings.ts b/src/lib/moderation/useGlobalLabelStrings.ts new file mode 100644 index 000000000..1c5a48231 --- /dev/null +++ b/src/lib/moderation/useGlobalLabelStrings.ts @@ -0,0 +1,52 @@ +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMemo} from 'react' + +export type GlobalLabelStrings = Record< + string, + { + name: string + description: string + } +> + +export function useGlobalLabelStrings(): GlobalLabelStrings { + const {_} = useLingui() + return useMemo( + () => ({ + '!hide': { + name: _(msg`Content Blocked`), + description: _(msg`This content has been hidden by the moderators.`), + }, + '!warn': { + name: _(msg`Content Warning`), + description: _( + msg`This content has received a general warning from moderators.`, + ), + }, + '!no-unauthenticated': { + name: _(msg`Sign-in Required`), + description: _( + msg`This user has requested that their content only be shown to signed-in users.`, + ), + }, + porn: { + name: _(msg`Pornography`), + description: _(msg`Explicit sexual images.`), + }, + sexual: { + name: _(msg`Sexually Suggestive`), + description: _(msg`Does not include nudity.`), + }, + nudity: { + name: _(msg`Non-sexual Nudity`), + description: _(msg`E.g. artistic nudes.`), + }, + 'graphic-media': { + name: _(msg`Graphic Media`), + description: _(msg`Explicit or potentially disturbing media.`), + }, + }), + [_], + ) +} diff --git a/src/lib/moderation/useLabelBehaviorDescription.ts b/src/lib/moderation/useLabelBehaviorDescription.ts new file mode 100644 index 000000000..0250c1bc8 --- /dev/null +++ b/src/lib/moderation/useLabelBehaviorDescription.ts @@ -0,0 +1,70 @@ +import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' + +export function useLabelBehaviorDescription( + labelValueDef: InterpretedLabelValueDefinition, + pref: LabelPreference, +) { + const {_} = useLingui() + if (pref === 'ignore') { + return _(msg`Off`) + } + if (labelValueDef.blurs === 'content' || labelValueDef.blurs === 'media') { + if (pref === 'hide') { + return _(msg`Hide`) + } + return _(msg`Warn`) + } else if (labelValueDef.severity === 'alert') { + if (pref === 'hide') { + return _(msg`Hide`) + } + return _(msg`Warn`) + } else if (labelValueDef.severity === 'inform') { + if (pref === 'hide') { + return _(msg`Hide`) + } + return _(msg`Show badge`) + } else { + if (pref === 'hide') { + return _(msg`Hide`) + } + return _(msg`Disabled`) + } +} + +export function useLabelLongBehaviorDescription( + labelValueDef: InterpretedLabelValueDefinition, + pref: LabelPreference, +) { + const {_} = useLingui() + if (pref === 'ignore') { + return _(msg`Disabled`) + } + if (labelValueDef.blurs === 'content') { + if (pref === 'hide') { + return _(msg`Warn content and filter from feeds`) + } + return _(msg`Warn content`) + } else if (labelValueDef.blurs === 'media') { + if (pref === 'hide') { + return _(msg`Blur images and filter from feeds`) + } + return _(msg`Blur images`) + } else if (labelValueDef.severity === 'alert') { + if (pref === 'hide') { + return _(msg`Show warning and filter from feeds`) + } + return _(msg`Show warning`) + } else if (labelValueDef.severity === 'inform') { + if (pref === 'hide') { + return _(msg`Show badge and filter from feeds`) + } + return _(msg`Show badge`) + } else { + if (pref === 'hide') { + return _(msg`Filter from feeds`) + } + return _(msg`Disabled`) + } +} diff --git a/src/lib/moderation/useLabelInfo.ts b/src/lib/moderation/useLabelInfo.ts new file mode 100644 index 000000000..b1cffe1e7 --- /dev/null +++ b/src/lib/moderation/useLabelInfo.ts @@ -0,0 +1,100 @@ +import { + ComAtprotoLabelDefs, + AppBskyLabelerDefs, + LABELS, + interpretLabelValueDefinition, + InterpretedLabelValueDefinition, +} from '@atproto/api' +import {useLingui} from '@lingui/react' +import * as bcp47Match from 'bcp-47-match' + +import { + GlobalLabelStrings, + useGlobalLabelStrings, +} from '#/lib/moderation/useGlobalLabelStrings' +import {useLabelDefinitions} from '#/state/preferences' + +export interface LabelInfo { + label: ComAtprotoLabelDefs.Label + def: InterpretedLabelValueDefinition + strings: ComAtprotoLabelDefs.LabelValueDefinitionStrings + labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined +} + +export function useLabelInfo(label: ComAtprotoLabelDefs.Label): LabelInfo { + const {i18n} = useLingui() + const {labelDefs, labelers} = useLabelDefinitions() + const globalLabelStrings = useGlobalLabelStrings() + const def = getDefinition(labelDefs, label) + return { + label, + def, + strings: getLabelStrings(i18n.locale, globalLabelStrings, def), + labeler: labelers.find(labeler => label.src === labeler.creator.did), + } +} + +export function getDefinition( + labelDefs: Record<string, InterpretedLabelValueDefinition[]>, + label: ComAtprotoLabelDefs.Label, +): InterpretedLabelValueDefinition { + // check local definitions + const customDef = + !label.val.startsWith('!') && + labelDefs[label.src]?.find( + def => def.identifier === label.val && def.definedBy === label.src, + ) + if (customDef) { + return customDef + } + + // check global definitions + const globalDef = LABELS[label.val as keyof typeof LABELS] + if (globalDef) { + return globalDef + } + + // fallback to a noop definition + return interpretLabelValueDefinition( + { + identifier: label.val, + severity: 'none', + blurs: 'none', + defaultSetting: 'ignore', + locales: [], + }, + label.src, + ) +} + +export function getLabelStrings( + locale: string, + globalLabelStrings: GlobalLabelStrings, + def: InterpretedLabelValueDefinition, +): ComAtprotoLabelDefs.LabelValueDefinitionStrings { + if (!def.definedBy) { + // global definition, look up strings + if (def.identifier in globalLabelStrings) { + return globalLabelStrings[ + def.identifier + ] as ComAtprotoLabelDefs.LabelValueDefinitionStrings + } + } else { + // try to find locale match in the definition's strings + const localeMatch = def.locales.find( + strings => bcp47Match.basicFilter(locale, strings.lang).length > 0, + ) + if (localeMatch) { + return localeMatch + } + // fall back to the zero item if no match + if (def.locales[0]) { + return def.locales[0] + } + } + return { + lang: locale, + name: def.identifier, + description: `Labeled "${def.identifier}"`, + } +} diff --git a/src/lib/moderation/useModerationCauseDescription.ts b/src/lib/moderation/useModerationCauseDescription.ts new file mode 100644 index 000000000..57b50d777 --- /dev/null +++ b/src/lib/moderation/useModerationCauseDescription.ts @@ -0,0 +1,150 @@ +import React from 'react' +import { + BSKY_LABELER_DID, + ModerationCause, + ModerationCauseSource, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {getDefinition, getLabelStrings} from './useLabelInfo' +import {useLabelDefinitions} from '#/state/preferences' +import {useGlobalLabelStrings} from './useGlobalLabelStrings' + +import {Props as SVGIconProps} from '#/components/icons/common' +import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' +import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' + +export interface ModerationCauseDescription { + icon: React.ComponentType<SVGIconProps> + name: string + description: string + source?: string + sourceType?: ModerationCauseSource['type'] +} + +export function useModerationCauseDescription( + cause: ModerationCause | undefined, +): ModerationCauseDescription { + const {_, i18n} = useLingui() + const {labelDefs, labelers} = useLabelDefinitions() + const globalLabelStrings = useGlobalLabelStrings() + + return React.useMemo(() => { + if (!cause) { + return { + icon: Warning, + name: _(msg`Content Warning`), + description: _( + msg`Moderator has chosen to set a general warning on the content.`, + ), + } + } + if (cause.type === 'blocking') { + if (cause.source.type === 'list') { + return { + icon: CircleBanSign, + name: _(msg`User Blocked by "${cause.source.list.name}"`), + description: _( + msg`You have blocked this user. You cannot view their content.`, + ), + } + } else { + return { + icon: CircleBanSign, + name: _(msg`User Blocked`), + description: _( + msg`You have blocked this user. You cannot view their content.`, + ), + } + } + } + if (cause.type === 'blocked-by') { + return { + icon: CircleBanSign, + name: _(msg`User Blocking You`), + description: _( + msg`This user has blocked you. You cannot view their content.`, + ), + } + } + if (cause.type === 'block-other') { + return { + icon: CircleBanSign, + name: _(msg`Content Not Available`), + description: _( + msg`This content is not available because one of the users involved has blocked the other.`, + ), + } + } + if (cause.type === 'muted') { + if (cause.source.type === 'list') { + return { + icon: EyeSlash, + name: _(msg`Muted by "${cause.source.list.name}"`), + description: _(msg`You have muted this user`), + } + } else { + return { + icon: EyeSlash, + name: _(msg`Account Muted`), + description: _(msg`You have muted this account.`), + } + } + } + if (cause.type === 'mute-word') { + return { + icon: EyeSlash, + name: _(msg`Post Hidden by Muted Word`), + description: _( + msg`You've chosen to hide a word or tag within this post.`, + ), + } + } + if (cause.type === 'hidden') { + return { + icon: EyeSlash, + name: _(msg`Post Hidden by You`), + description: _(msg`You have hidden this post`), + } + } + if (cause.type === 'label') { + const def = cause.labelDef || getDefinition(labelDefs, cause.label) + const strings = getLabelStrings(i18n.locale, globalLabelStrings, def) + const labeler = labelers.find(l => l.creator.did === cause.label.src) + let source = + labeler?.creator.displayName || + (labeler?.creator.handle ? '@' + labeler?.creator.handle : undefined) + if (!source) { + if (cause.label.src === BSKY_LABELER_DID) { + source = 'Bluesky Moderation Service' + } else { + source = cause.label.src + } + } + if (def.identifier === 'porn' || def.identifier === 'sexual') { + strings.name = 'Adult Content' + } + + return { + icon: + def.identifier === '!no-unauthenticated' + ? EyeSlash + : def.severity === 'alert' + ? Warning + : CircleInfo, + name: strings.name, + description: strings.description, + source, + sourceType: cause.source.type, + } + } + // should never happen + return { + icon: CircleInfo, + name: '', + description: ``, + } + }, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale]) +} diff --git a/src/lib/moderation/useReportOptions.ts b/src/lib/moderation/useReportOptions.ts new file mode 100644 index 000000000..e00170594 --- /dev/null +++ b/src/lib/moderation/useReportOptions.ts @@ -0,0 +1,94 @@ +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMemo} from 'react' +import {ComAtprotoModerationDefs} from '@atproto/api' + +export interface ReportOption { + reason: string + title: string + description: string +} + +interface ReportOptions { + account: ReportOption[] + post: ReportOption[] + list: ReportOption[] + feedgen: ReportOption[] + other: ReportOption[] +} + +export function useReportOptions(): ReportOptions { + const {_} = useLingui() + return useMemo(() => { + const other = { + reason: ComAtprotoModerationDefs.REASONOTHER, + title: _(msg`Other`), + description: _(msg`An issue not included in these options`), + } + const common = [ + { + reason: ComAtprotoModerationDefs.REASONRUDE, + title: _(msg`Anti-Social Behavior`), + description: _(msg`Harassment, trolling, or intolerance`), + }, + { + reason: ComAtprotoModerationDefs.REASONVIOLATION, + title: _(msg`Illegal and Urgent`), + description: _(msg`Glaring violations of law or terms of service`), + }, + other, + ] + return { + account: [ + { + reason: ComAtprotoModerationDefs.REASONMISLEADING, + title: _(msg`Misleading Account`), + description: _( + msg`Impersonation or false claims about identity or affiliation`, + ), + }, + { + reason: ComAtprotoModerationDefs.REASONSPAM, + title: _(msg`Frequently Posts Unwanted Content`), + description: _(msg`Spam; excessive mentions or replies`), + }, + { + reason: ComAtprotoModerationDefs.REASONVIOLATION, + title: _(msg`Name or Description Violates Community Standards`), + description: _(msg`Terms used violate community standards`), + }, + other, + ], + post: [ + { + reason: ComAtprotoModerationDefs.REASONSPAM, + title: _(msg`Spam`), + description: _(msg`Excessive mentions or replies`), + }, + { + reason: ComAtprotoModerationDefs.REASONSEXUAL, + title: _(msg`Unwanted Sexual Content`), + description: _(msg`Nudity or pornography not labeled as such`), + }, + ...common, + ], + list: [ + { + reason: ComAtprotoModerationDefs.REASONVIOLATION, + title: _(msg`Name or Description Violates Community Standards`), + description: _(msg`Terms used violate community standards`), + }, + ...common, + ], + feedgen: [ + { + reason: ComAtprotoModerationDefs.REASONVIOLATION, + title: _(msg`Name or Description Violates Community Standards`), + description: _(msg`Terms used violate community standards`), + }, + ...common, + ], + other: common, + } + }, [_]) +} diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts index 62d0bfc4b..0f628f428 100644 --- a/src/lib/notifications/notifications.ts +++ b/src/lib/notifications/notifications.ts @@ -1,12 +1,15 @@ +import {useEffect} from 'react' import * as Notifications from 'expo-notifications' import {QueryClient} from '@tanstack/react-query' -import {resetToTab} from '../../Navigation' -import {devicePlatform, isIOS} from 'platform/detection' -import {track} from 'lib/analytics/analytics' + import {logger} from '#/logger' import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed' import {truncateAndInvalidate} from '#/state/queries/util' -import {SessionAccount, getAgent} from '#/state/session' +import {getAgent, SessionAccount} from '#/state/session' +import {track} from 'lib/analytics/analytics' +import {devicePlatform, isIOS} from 'platform/detection' +import {resetToTab} from '../../Navigation' +import {logEvent} from '../statsig/statsig' const SERVICE_DID = (serviceUrl?: string) => serviceUrl?.includes('staging') @@ -79,52 +82,63 @@ export function registerTokenChangeHandler( } } -export function init(queryClient: QueryClient) { - // handle notifications that are received, both in the foreground or background - // NOTE: currently just here for debug logging - Notifications.addNotificationReceivedListener(event => { - logger.debug( - 'Notifications: received', - {event}, - logger.DebugContext.notifications, - ) - if (event.request.trigger.type === 'push') { - // handle payload-based deeplinks - let payload - if (isIOS) { - payload = event.request.trigger.payload - } else { - // TODO: handle android payload deeplink +export function useNotificationsListener(queryClient: QueryClient) { + useEffect(() => { + // handle notifications that are received, both in the foreground or background + // NOTE: currently just here for debug logging + const sub1 = Notifications.addNotificationReceivedListener(event => { + logger.debug( + 'Notifications: received', + {event}, + logger.DebugContext.notifications, + ) + if (event.request.trigger.type === 'push') { + // handle payload-based deeplinks + let payload + if (isIOS) { + payload = event.request.trigger.payload + } else { + // TODO: handle android payload deeplink + } + if (payload) { + logger.debug( + 'Notifications: received payload', + payload, + logger.DebugContext.notifications, + ) + // TODO: deeplink notif here + } } - if (payload) { + }) + + // handle notifications that are tapped on + const sub2 = Notifications.addNotificationResponseReceivedListener( + response => { logger.debug( - 'Notifications: received payload', - payload, + 'Notifications: response received', + { + actionIdentifier: response.actionIdentifier, + }, logger.DebugContext.notifications, ) - // TODO: deeplink notif here - } - } - }) - - // handle notifications that are tapped on - Notifications.addNotificationResponseReceivedListener(response => { - logger.debug( - 'Notifications: response received', - { - actionIdentifier: response.actionIdentifier, + if ( + response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER + ) { + logger.debug( + 'User pressed a notification, opening notifications tab', + {}, + logger.DebugContext.notifications, + ) + track('Notificatons:OpenApp') + logEvent('notifications:openApp', {}) + truncateAndInvalidate(queryClient, RQKEY_NOTIFS()) + resetToTab('NotificationsTab') // open notifications tab + } }, - logger.DebugContext.notifications, ) - if (response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER) { - logger.debug( - 'User pressed a notification, opening notifications tab', - {}, - logger.DebugContext.notifications, - ) - track('Notificatons:OpenApp') - truncateAndInvalidate(queryClient, RQKEY_NOTIFS()) - resetToTab('NotificationsTab') // open notifications tab + return () => { + sub1.remove() + sub2.remove() } - }) + }, [queryClient]) } diff --git a/src/lib/react-query.ts b/src/lib/react-query.tsx index 7fe3fe7a4..08b61ee20 100644 --- a/src/lib/react-query.ts +++ b/src/lib/react-query.tsx @@ -1,7 +1,18 @@ +import React from 'react' import {AppState, AppStateStatus} from 'react-native' -import {QueryClient, focusManager} from '@tanstack/react-query' +import AsyncStorage from '@react-native-async-storage/async-storage' +import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' +import {focusManager, QueryClient} from '@tanstack/react-query' +import { + PersistQueryClientProvider, + PersistQueryClientProviderProps, +} from '@tanstack/react-query-persist-client' + import {isNative} from '#/platform/detection' +// any query keys in this array will be persisted to AsyncStorage +const STORED_CACHE_QUERY_KEYS = ['labelers-detailed-info'] + focusManager.setEventListener(onFocus => { if (isNative) { const subscription = AppState.addEventListener( @@ -28,7 +39,7 @@ focusManager.setEventListener(onFocus => { } }) -export const queryClient = new QueryClient({ +const queryClient = new QueryClient({ defaultOptions: { queries: { // NOTE @@ -48,3 +59,31 @@ export const queryClient = new QueryClient({ }, }, }) + +const asyncStoragePersister = createAsyncStoragePersister({ + storage: AsyncStorage, + key: 'queryCache', +}) + +const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehydrateOptions'] = + { + shouldDehydrateMutation: (_: any) => false, + shouldDehydrateQuery: query => { + return STORED_CACHE_QUERY_KEYS.includes(String(query.queryKey[0])) + }, + } + +const persistOptions = { + persister: asyncStoragePersister, + dehydrateOptions, +} + +export function QueryProvider({children}: {children: React.ReactNode}) { + return ( + <PersistQueryClientProvider + client={queryClient} + persistOptions={persistOptions}> + {children} + </PersistQueryClientProvider> + ) +} diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts index 538f30cd3..9dfdab909 100644 --- a/src/lib/routes/links.ts +++ b/src/lib/routes/links.ts @@ -25,3 +25,13 @@ export function makeCustomFeedLink( export function makeListLink(did: string, rkey: string, ...segments: string[]) { return [`/profile`, did, 'lists', rkey, ...segments].join('/') } + +export function makeTagLink(did: string) { + return `/search?q=${encodeURIComponent(did)}` +} + +export function makeSearchLink(props: {query: string; from?: 'me' | string}) { + return `/search?q=${encodeURIComponent( + props.query + (props.from ? ` from:${props.from}` : ''), + )}` +} diff --git a/src/lib/routes/router.ts b/src/lib/routes/router.ts index 00defaeda..8c8be3739 100644 --- a/src/lib/routes/router.ts +++ b/src/lib/routes/router.ts @@ -2,9 +2,15 @@ import {RouteParams, Route} from './types' export class Router { routes: [string, Route][] = [] - constructor(description: Record<string, string>) { + constructor(description: Record<string, string | string[]>) { for (const [screen, pattern] of Object.entries(description)) { - this.routes.push([screen, createRoute(pattern)]) + if (typeof pattern === 'string') { + this.routes.push([screen, createRoute(pattern)]) + } else { + pattern.forEach(subPattern => { + this.routes.push([screen, createRoute(subPattern)]) + }) + } } } diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 90ae75830..95af2f237 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -21,7 +21,9 @@ export type CommonNavigatorParams = { PostRepostedBy: {name: string; rkey: string} ProfileFeed: {name: string; rkey: string} ProfileFeedLikedBy: {name: string; rkey: string} + ProfileLabelerLikedBy: {name: string} Debug: undefined + DebugMod: undefined Log: undefined Support: undefined PrivacyPolicy: undefined @@ -30,9 +32,11 @@ export type CommonNavigatorParams = { CopyrightPolicy: undefined AppPasswords: undefined SavedFeeds: undefined - PreferencesHomeFeed: undefined + PreferencesFollowingFeed: undefined PreferencesThreads: undefined PreferencesExternalEmbeds: undefined + Search: {q?: string} + Hashtag: {tag: string; author?: string} } export type BottomTabNavigatorParams = CommonNavigatorParams & { @@ -68,6 +72,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & { Search: {q?: string} Feeds: undefined Notifications: undefined + Hashtag: {tag: string; author?: string} } export type AllNavigatorParams = CommonNavigatorParams & { @@ -80,6 +85,7 @@ export type AllNavigatorParams = CommonNavigatorParams & { NotificationsTab: undefined Notifications: undefined MyProfileTab: undefined + Hashtag: {tag: string; author?: string} } // NOTE diff --git a/src/lib/sharing.ts b/src/lib/sharing.ts index b294d7464..9f402f873 100644 --- a/src/lib/sharing.ts +++ b/src/lib/sharing.ts @@ -12,9 +12,9 @@ import {Share} from 'react-native' */ export async function shareUrl(url: string) { if (isAndroid) { - Share.share({message: url}) + await Share.share({message: url}) } else if (isIOS) { - Share.share({url}) + await Share.share({url}) } else { // React Native Share is not supported by web. Web Share API // has increasing but not full support, so default to clipboard diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts new file mode 100644 index 000000000..2de15b64e --- /dev/null +++ b/src/lib/statsig/events.ts @@ -0,0 +1,97 @@ +export type LogEvents = { + // App events + init: { + initMs: number + } + 'account:loggedIn': { + logContext: 'LoginForm' | 'SwitchAccount' | 'ChooseAccountForm' | 'Settings' + withPassword: boolean + } + 'account:loggedOut': { + logContext: 'SwitchAccount' | 'Settings' | 'Deactivated' + } + 'notifications:openApp': {} + 'state:background': { + secondsActive: number + } + 'state:foreground': {} + 'router:navigate': {} + + // Screen events + 'splash:signInPressed': {} + 'splash:createAccountPressed': {} + 'signup:nextPressed': { + activeStep: number + } + 'onboarding:interests:nextPressed': { + selectedInterests: string[] + selectedInterestsLength: number + } + 'onboarding:suggestedAccounts:nextPressed': { + selectedAccountsLength: number + skipped: boolean + } + 'onboarding:followingFeed:nextPressed': {} + 'onboarding:algoFeeds:nextPressed': { + selectedPrimaryFeeds: string[] + selectedPrimaryFeedsLength: number + selectedSecondaryFeeds: string[] + selectedSecondaryFeedsLength: number + } + 'onboarding:topicalFeeds:nextPressed': { + selectedFeeds: string[] + selectedFeedsLength: number + } + 'onboarding:moderation:nextPressed': {} + 'onboarding:finished:nextPressed': {} + 'feed:endReached': { + feedType: string + itemCount: number + } + 'feed:refresh': { + feedType: string + reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest' + } + + // Data events + 'account:create:begin': {} + 'account:create:success': {} + 'post:create': { + imageCount: number + isReply: boolean + hasLink: boolean + hasQuote: boolean + langs: string + logContext: 'Composer' + } + 'post:like': { + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' + } + 'post:repost': { + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' + } + 'post:unlike': { + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' + } + 'post:unrepost': { + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' + } + 'profile:follow': { + logContext: + | 'RecommendedFollowsItem' + | 'PostThreadItem' + | 'ProfileCard' + | 'ProfileHeader' + | 'ProfileHeaderSuggestedFollows' + | 'ProfileMenu' + } + 'profile:unfollow': { + logContext: + | 'RecommendedFollowsItem' + | 'PostThreadItem' + | 'ProfileCard' + | 'ProfileHeader' + | 'ProfileHeaderSuggestedFollows' + | 'ProfileMenu' + } +} diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx new file mode 100644 index 000000000..68c63de61 --- /dev/null +++ b/src/lib/statsig/statsig.tsx @@ -0,0 +1,136 @@ +import React from 'react' +import {Platform} from 'react-native' +import {AppState, AppStateStatus} from 'react-native' +import {sha256} from 'js-sha256' +import { + Statsig, + StatsigProvider, + useGate as useStatsigGate, +} from 'statsig-react-native-expo' + +import {logger} from '#/logger' +import {useSession} from '../../state/session' +import {LogEvents} from './events' + +export type {LogEvents} + +const statsigOptions = { + environment: { + tier: process.env.NODE_ENV === 'development' ? 'development' : 'production', + }, + // Don't block on waiting for network. The fetched config will kick in on next load. + // This ensures the UI is always consistent and doesn't update mid-session. + // Note this makes cold load (no local storage) and private mode return `false` for all gates. + initTimeoutMs: 1, +} + +type FlatJSONRecord = Record< + string, + | string + | number + | boolean + | null + | undefined + // Technically not scalar but Statsig will stringify it which works for us: + | string[] +> + +let getCurrentRouteName: () => string | null | undefined = () => null + +export function attachRouteToLogEvents( + getRouteName: () => string | null | undefined, +) { + getCurrentRouteName = getRouteName +} + +export function logEvent<E extends keyof LogEvents>( + eventName: E & string, + rawMetadata: LogEvents[E] & FlatJSONRecord, +) { + try { + const fullMetadata = { + ...rawMetadata, + } as Record<string, string> // Statsig typings are unnecessarily strict here. + fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)' + if (Statsig.initializeCalled()) { + Statsig.logEvent(eventName, null, fullMetadata) + } + } catch (e) { + // A log should never interrupt the calling code, whatever happens. + logger.error('Failed to log an event', {message: e}) + } +} + +export function useGate(gateName: string) { + const {isLoading, value} = useStatsigGate(gateName) + if (isLoading) { + // This should not happen because of waitForInitialization={true}. + console.error('Did not expected isLoading to ever be true.') + } + return value +} + +function toStatsigUser(did: string | undefined) { + let userID: string | undefined + if (did) { + userID = sha256(did) + } + return { + userID, + platform: Platform.OS, + } +} + +let lastState: AppStateStatus = AppState.currentState +let lastActive = lastState === 'active' ? performance.now() : null +AppState.addEventListener('change', (state: AppStateStatus) => { + if (state === lastState) { + return + } + lastState = state + if (state === 'active') { + lastActive = performance.now() + logEvent('state:foreground', {}) + } else { + let secondsActive = 0 + if (lastActive != null) { + secondsActive = Math.round((performance.now() - lastActive) / 1e3) + } + lastActive = null + logEvent('state:background', { + secondsActive, + }) + } +}) + +export function Provider({children}: {children: React.ReactNode}) { + const {currentAccount} = useSession() + const currentStatsigUser = React.useMemo( + () => toStatsigUser(currentAccount?.did), + [currentAccount?.did], + ) + + React.useEffect(() => { + function refresh() { + // Intentionally refetching the config using the JS SDK rather than React SDK + // so that the new config is stored in cache but isn't used during this session. + // It will kick in for the next reload. + Statsig.updateUser(currentStatsigUser) + } + const id = setInterval(refresh, 3 * 60e3 /* 3 min */) + return () => clearInterval(id) + }, [currentStatsigUser]) + + return ( + <StatsigProvider + sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV" + mountKey={currentStatsigUser.userID} + user={currentStatsigUser} + // This isn't really blocking due to short initTimeoutMs above. + // However, it ensures `isLoading` is always `false`. + waitForInitialization={true} + options={statsigOptions}> + {children} + </StatsigProvider> + ) +} diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts index 75383dd4f..e0f23fa2c 100644 --- a/src/lib/strings/display-names.ts +++ b/src/lib/strings/display-names.ts @@ -1,5 +1,4 @@ import {ModerationUI} from '@atproto/api' -import {describeModerationCause} from '../moderation' // \u2705 = ✅ // \u2713 = ✓ @@ -14,7 +13,7 @@ export function sanitizeDisplayName( moderation?: ModerationUI, ): string { if (moderation?.blur) { - return `⚠${describeModerationCause(moderation.cause, 'account').name}` + return '' } if (typeof str === 'string') { return str.replace(CHECK_MARKS_RE, '').replace(CONTROL_CHARS_RE, '').trim() diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts index 21a575b91..ee7328478 100644 --- a/src/lib/strings/embed-player.ts +++ b/src/lib/strings/embed-player.ts @@ -2,6 +2,15 @@ import {Dimensions} from 'react-native' import {isWeb} from 'platform/detection' const {height: SCREEN_HEIGHT} = Dimensions.get('window') +const IFRAME_HOST = isWeb + ? // @ts-ignore only for web + window.location.host === 'localhost:8100' + ? 'http://localhost:8100' + : 'https://bsky.app' + : __DEV__ && !process.env.JEST_WORKER_ID + ? 'http://localhost:8100' + : 'https://bsky.app' + export const embedPlayerSources = [ 'youtube', 'youtubeShorts', @@ -74,7 +83,7 @@ export function parseEmbedPlayerFromUrl( return { type: 'youtube_video', source: 'youtube', - playerUri: `https://bsky.app/iframe/youtube.html?videoId=${videoId}&start=${seek}`, + playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`, } } } @@ -93,7 +102,7 @@ export function parseEmbedPlayerFromUrl( type: page === 'shorts' ? 'youtube_short' : 'youtube_video', source: page === 'shorts' ? 'youtubeShorts' : 'youtube', hideDetails: page === 'shorts' ? true : undefined, - playerUri: `https://bsky.app/iframe/youtube.html?videoId=${videoId}&start=${seek}`, + playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`, } } } @@ -343,45 +352,45 @@ export function parseEmbedPlayerFromUrl( } } -export function getPlayerHeight({ +export function getPlayerAspect({ type, - width, hasThumb, + width, }: { type: EmbedPlayerParams['type'] - width: number hasThumb: boolean -}) { - if (!hasThumb) return (width / 16) * 9 + width: number +}): {aspectRatio?: number; height?: number} { + if (!hasThumb) return {aspectRatio: 16 / 9} switch (type) { case 'youtube_video': case 'twitch_video': case 'vimeo_video': - return (width / 16) * 9 + return {aspectRatio: 16 / 9} case 'youtube_short': if (SCREEN_HEIGHT < 600) { - return ((width / 9) * 16) / 1.75 + return {aspectRatio: (9 / 16) * 1.75} } else { - return ((width / 9) * 16) / 1.5 + return {aspectRatio: (9 / 16) * 1.5} } case 'spotify_album': case 'apple_music_album': case 'apple_music_playlist': case 'spotify_playlist': case 'soundcloud_set': - return 380 + return {height: 380} case 'spotify_song': if (width <= 300) { - return 155 + return {height: 155} } - return 232 + return {height: 232} case 'soundcloud_track': - return 165 + return {height: 165} case 'apple_music_song': - return 150 + return {height: 150} default: - return width + return {aspectRatio: 16 / 9} } } diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts index 6ce462435..bc07b32ec 100644 --- a/src/lib/strings/handles.ts +++ b/src/lib/strings/handles.ts @@ -1,3 +1,8 @@ +// Regex from the go implementation +// https://github.com/bluesky-social/indigo/blob/main/atproto/syntax/handle.go#L10 +const VALIDATE_REGEX = + /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/ + export function makeValidHandle(str: string): string { if (str.length > 20) { str = str.slice(0, 20) @@ -19,3 +24,29 @@ export function isInvalidHandle(handle: string): boolean { export function sanitizeHandle(handle: string, prefix = ''): string { return isInvalidHandle(handle) ? '⚠Invalid Handle' : `${prefix}${handle}` } + +export interface IsValidHandle { + handleChars: boolean + hyphenStartOrEnd: boolean + frontLength: boolean + totalLength: boolean + overall: boolean +} + +// More checks from https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/handle/index.ts#L72 +export function validateHandle(str: string, userDomain: string): IsValidHandle { + const fullHandle = createFullHandle(str, userDomain) + + const results = { + handleChars: + !str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')), + hyphenStartOrEnd: !str.startsWith('-') && !str.endsWith('-'), + frontLength: str.length >= 3, + totalLength: fullHandle.length <= 253, + } + + return { + ...results, + overall: !Object.values(results).includes(false), + } +} diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts index e2abe9019..de4562d2c 100644 --- a/src/lib/strings/helpers.ts +++ b/src/lib/strings/helpers.ts @@ -8,10 +8,27 @@ export function pluralize(n: number, base: string, plural?: string): string { return base + 's' } -export function enforceLen(str: string, len: number, ellipsis = false): string { +export function enforceLen( + str: string, + len: number, + ellipsis = false, + mode: 'end' | 'middle' = 'end', +): string { str = str || '' if (str.length > len) { - return str.slice(0, len) + (ellipsis ? '...' : '') + if (ellipsis) { + if (mode === 'end') { + return str.slice(0, len) + '…' + } else if (mode === 'middle') { + const half = Math.floor(len / 2) + return str.slice(0, half) + '…' + str.slice(-half) + } else { + // fallback + return str.slice(0, len) + } + } else { + return str.slice(0, len) + } } return str } diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts index 05a60e94b..3e162af1a 100644 --- a/src/lib/strings/time.ts +++ b/src/lib/strings/time.ts @@ -23,7 +23,7 @@ export function ago(date: number | string | Date): string { } else if (diffSeconds < DAY) { return `${Math.floor(diffSeconds / HOUR)}h` } else if (diffSeconds < MONTH) { - return `${Math.floor(diffSeconds / DAY)}d` + return `${Math.round(diffSeconds / DAY)}d` } else if (diffSeconds < YEAR) { return `${Math.floor(diffSeconds / MONTH)}mo` } else { diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 8a71718c8..70a2b7069 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -1,8 +1,27 @@ import {AtUri} from '@atproto/api' -import {PROD_SERVICE} from 'lib/constants' +import {BSKY_SERVICE} from 'lib/constants' import TLDs from 'tlds' import psl from 'psl' +export const BSKY_APP_HOST = 'https://bsky.app' +const BSKY_TRUSTED_HOSTS = [ + 'bsky.app', + 'bsky.social', + 'blueskyweb.xyz', + 'blueskyweb.zendesk.com', + ...(__DEV__ ? ['localhost:19006', 'localhost:8100'] : []), +] + +/* + * This will allow any BSKY_TRUSTED_HOSTS value by itself or with a subdomain. + * It will also allow relative paths like /profile as well as #. + */ +const TRUSTED_REGEX = new RegExp( + `^(http(s)?://(([\\w-]+\\.)?${BSKY_TRUSTED_HOSTS.join( + '|([\\w-]+\\.)?', + )})|/|#)`, +) + export function isValidDomain(str: string): boolean { return !!TLDs.find(tld => { let i = str.lastIndexOf(tld) @@ -28,7 +47,7 @@ export function makeRecordUri( export function toNiceDomain(url: string): string { try { const urlp = new URL(url) - if (`https://${urlp.host}` === PROD_SERVICE) { + if (`https://${urlp.host}` === BSKY_SERVICE) { return 'Bluesky Social' } return urlp.host ? urlp.host : url @@ -67,8 +86,25 @@ export function isBskyAppUrl(url: string): boolean { return url.startsWith('https://bsky.app/') } +export function isRelativeUrl(url: string): boolean { + return /^\/[^/]/.test(url) +} + +export function isBskyRSSUrl(url: string): boolean { + return ( + (url.startsWith('https://bsky.app/') || isRelativeUrl(url)) && + /\/rss\/?$/.test(url) + ) +} + export function isExternalUrl(url: string): boolean { - return !isBskyAppUrl(url) && url.startsWith('http') + const external = !isBskyAppUrl(url) && url.startsWith('http') + const rss = isBskyRSSUrl(url) + return external || rss +} + +export function isTrustedUrl(url: string): boolean { + return TRUSTED_REGEX.test(url) } export function isBskyPostUrl(url: string): boolean { @@ -148,6 +184,11 @@ export function feedUriToHref(url: string): string { export function linkRequiresWarning(uri: string, label: string) { const labelDomain = labelToDomain(label) + // We should trust any relative URL or a # since we know it links to internal content + if (isRelativeUrl(uri) || uri === '#') { + return false + } + let urip try { urip = new URL(uri) @@ -156,21 +197,11 @@ export function linkRequiresWarning(uri: string, label: string) { } const host = urip.hostname.toLowerCase() - - if (host === 'bsky.app') { - // if this is a link to internal content, - // warn if it represents itself as a URL to another app - if ( - labelDomain && - labelDomain !== 'bsky.app' && - isPossiblyAUrl(labelDomain) - ) { - return true - } - return false + if (isTrustedUrl(uri)) { + // if this is a link to internal content, warn if it represents itself as a URL to another app + return !!labelDomain && labelDomain !== host && isPossiblyAUrl(labelDomain) } else { - // if this is a link to external content, - // warn if the label doesnt match the target + // if this is a link to external content, warn if the label doesnt match the target if (!labelDomain) { return true } @@ -220,3 +251,8 @@ export function splitApexDomain(hostname: string): [string, string] { hostnamep.domain, ] } + +export function createBskyAppAbsoluteUrl(path: string): string { + const sanitizedPath = path.replace(BSKY_APP_HOST, '').replace(/^\/+/, '') + return `${BSKY_APP_HOST.replace(/\/$/, '')}/${sanitizedPath}` +} diff --git a/src/lib/themes.ts b/src/lib/themes.ts index 9a3880b92..6fada40a7 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -9,7 +9,7 @@ export const defaultTheme: Theme = { palette: { default: { background: lightPalette.white, - backgroundLight: lightPalette.contrast_50, + backgroundLight: lightPalette.contrast_25, text: lightPalette.black, textLight: lightPalette.contrast_700, textInverted: lightPalette.white, @@ -306,7 +306,7 @@ export const darkTheme: Theme = { // non-standard textVeryLight: darkPalette.contrast_400, - replyLine: darkPalette.contrast_100, + replyLine: darkPalette.contrast_200, replyLineDot: darkPalette.contrast_200, unreadNotifBg: darkPalette.primary_975, unreadNotifBorder: darkPalette.primary_900, @@ -344,6 +344,25 @@ export const dimTheme: Theme = { default: { ...darkTheme.palette.default, background: dimPalette.black, + backgroundLight: dimPalette.contrast_50, + text: dimPalette.white, + textLight: dimPalette.contrast_700, + textInverted: dimPalette.black, + link: dimPalette.primary_500, + border: dimPalette.contrast_100, + borderDark: dimPalette.contrast_200, + icon: dimPalette.contrast_500, + + // non-standard + textVeryLight: dimPalette.contrast_400, + replyLine: dimPalette.contrast_200, + replyLineDot: dimPalette.contrast_200, + unreadNotifBg: dimPalette.primary_975, + unreadNotifBorder: dimPalette.primary_900, + postCtrl: dimPalette.contrast_500, + brandText: dimPalette.primary_500, + emptyStateIcon: dimPalette.contrast_300, + borderLinkHover: dimPalette.contrast_300, }, }, } |