diff options
Diffstat (limited to 'src/lib')
44 files changed, 2607 insertions, 303 deletions
diff --git a/src/lib/ThemeContext.tsx b/src/lib/ThemeContext.tsx new file mode 100644 index 000000000..bcfc076f4 --- /dev/null +++ b/src/lib/ThemeContext.tsx @@ -0,0 +1,95 @@ +import React, {createContext, useContext, useMemo} from 'react' +import {TextStyle, useColorScheme, ViewStyle} from 'react-native' +import {darkTheme, defaultTheme} from './themes' + +export type ColorScheme = 'light' | 'dark' + +export type PaletteColorName = + | 'default' + | 'primary' + | 'secondary' + | 'inverted' + | 'error' +export type PaletteColor = { + background: string + backgroundLight: string + text: string + textLight: string + textInverted: string + link: string + border: string + borderDark: string + icon: string + [k: string]: string +} +export type Palette = Record<PaletteColorName, PaletteColor> + +export type ShapeName = 'button' | 'bigButton' | 'smallButton' +export type Shapes = Record<ShapeName, ViewStyle> + +export type TypographyVariant = + | 'xl-thin' + | 'xl' + | 'xl-medium' + | 'xl-bold' + | 'xl-heavy' + | 'lg-thin' + | 'lg' + | 'lg-medium' + | 'lg-bold' + | 'lg-heavy' + | 'md-thin' + | 'md' + | 'md-medium' + | 'md-bold' + | 'md-heavy' + | 'sm-thin' + | 'sm' + | 'sm-medium' + | 'sm-bold' + | 'sm-heavy' + | 'xs-thin' + | 'xs' + | 'xs-medium' + | 'xs-bold' + | 'xs-heavy' + | 'title-2xl' + | 'title-xl' + | 'title-lg' + | 'title' + | 'title-sm' + | 'post-text-lg' + | 'post-text' + | 'button' + | 'button-lg' + | 'mono' +export type Typography = Record<TypographyVariant, TextStyle> + +export interface Theme { + colorScheme: ColorScheme + palette: Palette + shapes: Shapes + typography: Typography +} + +export interface ThemeProviderProps { + theme?: ColorScheme +} + +export const ThemeContext = createContext<Theme>(defaultTheme) + +export const useTheme = () => useContext(ThemeContext) + +export const ThemeProvider: React.FC<ThemeProviderProps> = ({ + theme, + children, +}) => { + const colorScheme = useColorScheme() + + const value = useMemo( + () => ((theme || colorScheme) === 'dark' ? darkTheme : defaultTheme), + [colorScheme, theme], + ) + + return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> +} diff --git a/src/lib/analytics.tsx b/src/lib/analytics.tsx new file mode 100644 index 000000000..441cdc454 --- /dev/null +++ b/src/lib/analytics.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import {AppState, AppStateStatus} from 'react-native' +import {createClient, AnalyticsProvider} from '@segment/analytics-react-native' +import {RootStoreModel, AppInfo} from 'state/models/root-store' + +const segmentClient = createClient({ + writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI', + trackAppLifecycleEvents: false, +}) + +export {useAnalytics} from '@segment/analytics-react-native' + +export function init(store: RootStoreModel) { + // NOTE + // this method is a copy of segment's own lifecycle event tracking + // we handle it manually to ensure that it never fires while the app is backgrounded + // -prf + segmentClient.onContextLoaded(() => { + if (AppState.currentState !== 'active') { + store.log.debug('Prevented a metrics ping while the app was backgrounded') + return + } + const context = segmentClient.context.get() + if (typeof context?.app === 'undefined') { + store.log.debug('Aborted metrics ping due to unavailable context') + return + } + + const oldAppInfo = store.appInfo + const newAppInfo = context.app as AppInfo + store.setAppInfo(newAppInfo) + store.log.debug('Recording app info', {new: newAppInfo, old: oldAppInfo}) + + if (typeof oldAppInfo === 'undefined') { + segmentClient.track('Application Installed', { + version: newAppInfo.version, + build: newAppInfo.build, + }) + } else if (newAppInfo.version !== oldAppInfo.version) { + segmentClient.track('Application Updated', { + version: newAppInfo.version, + build: newAppInfo.build, + previous_version: oldAppInfo.version, + previous_build: oldAppInfo.build, + }) + } + segmentClient.track('Application Opened', { + from_background: false, + version: newAppInfo.version, + build: newAppInfo.build, + }) + }) + + let lastState: AppStateStatus = AppState.currentState + AppState.addEventListener('change', (state: AppStateStatus) => { + if (state === 'active' && lastState !== 'active') { + const context = segmentClient.context.get() + segmentClient.track('Application Opened', { + from_background: true, + version: context?.app?.version, + build: context?.app?.build, + }) + } else if (state !== 'active' && lastState === 'active') { + segmentClient.track('Application Backgrounded') + } + lastState = state + }) +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + return ( + <AnalyticsProvider client={segmentClient}>{children}</AnalyticsProvider> + ) +} diff --git a/src/lib/analytics.web.tsx b/src/lib/analytics.web.tsx new file mode 100644 index 000000000..d7b9b2646 --- /dev/null +++ b/src/lib/analytics.web.tsx @@ -0,0 +1,16 @@ +// TODO +import React from 'react' +import {RootStoreModel} from 'state/models/root-store' + +export function useAnalytics() { + return { + screen(_name: string) {}, + track(_name: string, _opts: any) {}, + } +} + +export function init(_store: RootStoreModel) {} + +export function Provider({children}: React.PropsWithChildren<{}>) { + return children +} diff --git a/src/lib/api/api-polyfill.ts b/src/lib/api/api-polyfill.ts new file mode 100644 index 000000000..3b5ba7518 --- /dev/null +++ b/src/lib/api/api-polyfill.ts @@ -0,0 +1,79 @@ +import AtpAgent from '@atproto/api' +import RNFS from 'react-native-fs' + +const TIMEOUT = 10e3 // 10s + +export function doPolyfill() { + AtpAgent.configure({fetch: fetchHandler}) +} + +interface FetchHandlerResponse { + status: number + headers: Record<string, string> + body: ArrayBuffer | undefined +} + +async function fetchHandler( + reqUri: string, + reqMethod: string, + reqHeaders: Record<string, string>, + reqBody: any, +): Promise<FetchHandlerResponse> { + const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type'] + if (reqMimeType && reqMimeType.startsWith('application/json')) { + reqBody = JSON.stringify(reqBody) + } else if ( + typeof reqBody === 'string' && + (reqBody.startsWith('/') || reqBody.startsWith('file:')) + ) { + if (reqBody.endsWith('.jpeg') || reqBody.endsWith('.jpg')) { + // HACK + // React native has a bug that inflates the size of jpegs on upload + // we get around that by renaming the file ext to .bin + // see https://github.com/facebook/react-native/issues/27099 + // -prf + const newPath = reqBody.replace(/\.jpe?g$/, '.bin') + await RNFS.moveFile(reqBody, newPath) + reqBody = newPath + } + // NOTE + // React native treats bodies with {uri: string} as file uploads to pull from cache + // -prf + reqBody = {uri: reqBody} + } + + const controller = new AbortController() + const to = setTimeout(() => controller.abort(), TIMEOUT) + + const res = await fetch(reqUri, { + method: reqMethod, + headers: reqHeaders, + body: reqBody, + signal: controller.signal, + }) + + const resStatus = res.status + const resHeaders: Record<string, string> = {} + res.headers.forEach((value: string, key: string) => { + resHeaders[key] = value + }) + const resMimeType = resHeaders['Content-Type'] || resHeaders['content-type'] + let resBody + if (resMimeType) { + if (resMimeType.startsWith('application/json')) { + resBody = await res.json() + } else if (resMimeType.startsWith('text/')) { + resBody = await res.text() + } else { + throw new Error('TODO: non-textual response body') + } + } + + clearTimeout(to) + + return { + status: resStatus, + headers: resHeaders, + body: resBody, + } +} diff --git a/src/lib/api/api-polyfill.web.ts b/src/lib/api/api-polyfill.web.ts new file mode 100644 index 000000000..1469cf905 --- /dev/null +++ b/src/lib/api/api-polyfill.web.ts @@ -0,0 +1,4 @@ +export function doPolyfill() { + // TODO needed? native fetch may work fine -prf + // AtpApi.xrpc.fetch = fetchHandler +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts new file mode 100644 index 000000000..d800c376c --- /dev/null +++ b/src/lib/api/index.ts @@ -0,0 +1,201 @@ +import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api' +import {AtUri} from '../../third-party/uri' +import {RootStoreModel} from 'state/models/root-store' +import {extractEntities} from 'lib/strings/rich-text-detection' +import {isNetworkError} from 'lib/strings/errors' +import {LinkMeta} from '../link-meta/link-meta' +import {Image} from '../images' +import {RichText} from '../strings/rich-text' + +export interface ExternalEmbedDraft { + uri: string + isLoading: boolean + meta?: LinkMeta + localThumb?: Image +} + +export async function resolveName(store: RootStoreModel, didOrHandle: string) { + if (!didOrHandle) { + throw new Error('Invalid handle: ""') + } + if (didOrHandle.startsWith('did:')) { + return didOrHandle + } + const res = await store.api.com.atproto.handle.resolve({ + handle: didOrHandle, + }) + return res.data.did +} + +export async function post( + store: RootStoreModel, + rawText: string, + replyTo?: string, + extLink?: ExternalEmbedDraft, + images?: string[], + knownHandles?: Set<string>, + onStateChange?: (state: string) => void, +) { + let embed: AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | undefined + let reply + const text = new RichText(rawText, undefined, { + cleanNewlines: true, + }).text.trim() + + onStateChange?.('Processing...') + const entities = extractEntities(text, knownHandles) + if (entities) { + for (const ent of entities) { + if (ent.type === 'mention') { + const prof = await store.profiles.getProfile(ent.value) + ent.value = prof.data.did + } + } + } + + if (images?.length) { + embed = { + $type: 'app.bsky.embed.images', + images: [], + } as AppBskyEmbedImages.Main + let i = 1 + for (const image of images) { + onStateChange?.(`Uploading image #${i++}...`) + const res = await store.api.com.atproto.blob.upload( + image, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts + {encoding: 'image/jpeg'}, + ) + embed.images.push({ + image: { + cid: res.data.cid, + mimeType: 'image/jpeg', + }, + alt: '', // TODO supply alt text + }) + } + } + + if (!embed && extLink) { + let thumb + if (extLink.localThumb) { + onStateChange?.('Uploading link thumbnail...') + let encoding + if (extLink.localThumb.path.endsWith('.png')) { + encoding = 'image/png' + } else if ( + extLink.localThumb.path.endsWith('.jpeg') || + extLink.localThumb.path.endsWith('.jpg') + ) { + encoding = 'image/jpeg' + } else { + store.log.warn( + 'Unexpected image format for thumbnail, skipping', + extLink.localThumb.path, + ) + } + if (encoding) { + const thumbUploadRes = await store.api.com.atproto.blob.upload( + extLink.localThumb.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts + {encoding}, + ) + thumb = { + cid: thumbUploadRes.data.cid, + mimeType: encoding, + } + } + } + embed = { + $type: 'app.bsky.embed.external', + external: { + uri: extLink.uri, + title: extLink.meta?.title || '', + description: extLink.meta?.description || '', + thumb, + }, + } as AppBskyEmbedExternal.Main + } + + if (replyTo) { + const replyToUrip = new AtUri(replyTo) + const parentPost = await store.api.app.bsky.feed.post.get({ + user: replyToUrip.host, + rkey: replyToUrip.rkey, + }) + if (parentPost) { + const parentRef = { + uri: parentPost.uri, + cid: parentPost.cid, + } + reply = { + root: parentPost.value.reply?.root || parentRef, + parent: parentRef, + } + } + } + + try { + onStateChange?.('Posting...') + return await store.api.app.bsky.feed.post.create( + {did: store.me.did || ''}, + { + text, + reply, + embed, + entities, + createdAt: new Date().toISOString(), + }, + ) + } catch (e: any) { + console.error(`Failed to create post: ${e.toString()}`) + if (isNetworkError(e)) { + throw new Error( + 'Post failed to upload. Please check your Internet connection and try again.', + ) + } else { + throw e + } + } +} + +export async function repost(store: RootStoreModel, uri: string, cid: string) { + return await store.api.app.bsky.feed.repost.create( + {did: store.me.did || ''}, + { + subject: {uri, cid}, + createdAt: new Date().toISOString(), + }, + ) +} + +export async function unrepost(store: RootStoreModel, repostUri: string) { + const repostUrip = new AtUri(repostUri) + return await store.api.app.bsky.feed.repost.delete({ + did: repostUrip.hostname, + rkey: repostUrip.rkey, + }) +} + +export async function follow( + store: RootStoreModel, + subjectDid: string, + subjectDeclarationCid: string, +) { + return await store.api.app.bsky.graph.follow.create( + {did: store.me.did || ''}, + { + subject: { + did: subjectDid, + declarationCid: subjectDeclarationCid, + }, + createdAt: new Date().toISOString(), + }, + ) +} + +export async function unfollow(store: RootStoreModel, followUri: string) { + const followUrip = new AtUri(followUri) + return await store.api.app.bsky.graph.follow.delete({ + did: followUrip.hostname, + rkey: followUrip.rkey, + }) +} diff --git a/src/lib/app-info.ts b/src/lib/app-info.ts new file mode 100644 index 000000000..1ced274e7 --- /dev/null +++ b/src/lib/app-info.ts @@ -0,0 +1,4 @@ +import VersionNumber from 'react-native-version-number' + +export const appVersion = VersionNumber.appVersion +export const buildVersion = VersionNumber.buildVersion diff --git a/src/lib/app-info.web.ts b/src/lib/app-info.web.ts new file mode 100644 index 000000000..a2b6858da --- /dev/null +++ b/src/lib/app-info.web.ts @@ -0,0 +1,3 @@ +// TODO +export const appVersion = 'TODO' +export const buildVersion = 'TODO' diff --git a/src/lib/assets.native.ts b/src/lib/assets.native.ts new file mode 100644 index 000000000..d7f4a7287 --- /dev/null +++ b/src/lib/assets.native.ts @@ -0,0 +1,5 @@ +import {ImageRequireSource} from 'react-native' + +export const DEF_AVATAR: ImageRequireSource = require('../../public/img/default-avatar.jpg') +export const TABS_EXPLAINER: ImageRequireSource = require('../../public/img/tabs-explainer.jpg') +export const CLOUD_SPLASH: ImageRequireSource = require('../../public/img/cloud-splash.png') diff --git a/src/lib/assets.ts b/src/lib/assets.ts new file mode 100644 index 000000000..216478762 --- /dev/null +++ b/src/lib/assets.ts @@ -0,0 +1,10 @@ +import {ImageRequireSource} from 'react-native' + +// @ts-ignore we need to pretend -prf +export const DEF_AVATAR: ImageRequireSource = {uri: '/img/default-avatar.jpg'} +// @ts-ignore we need to pretend -prf +export const TABS_EXPLAINER: ImageRequireSource = { + uri: '/img/tabs-explainer.jpg', +} +// @ts-ignore we need to pretend -prf +export const CLOUD_SPLASH: ImageRequireSource = {uri: '/img/cloud-splash.png'} diff --git a/src/lib/async/bundle.ts b/src/lib/async/bundle.ts new file mode 100644 index 000000000..e307cd437 --- /dev/null +++ b/src/lib/async/bundle.ts @@ -0,0 +1,24 @@ +type BundledFn<Args extends readonly unknown[], Res> = ( + ...args: Args +) => Promise<Res> + +/** + * A helper which ensures that multiple calls to an async function + * only produces one in-flight request at a time. + */ +export function bundleAsync<Args extends readonly unknown[], Res>( + fn: BundledFn<Args, Res>, +): BundledFn<Args, Res> { + let promise: Promise<Res> | undefined + return async (...args) => { + if (promise) { + return promise + } + promise = fn(...args) + try { + return await promise + } finally { + promise = undefined + } + } +} diff --git a/src/lib/bg-scheduler.ts b/src/lib/bg-scheduler.ts new file mode 100644 index 000000000..db3f2d7fd --- /dev/null +++ b/src/lib/bg-scheduler.ts @@ -0,0 +1,18 @@ +import BackgroundFetch, { + BackgroundFetchStatus, +} from 'react-native-background-fetch' + +export function configure( + handler: (taskId: string) => Promise<void>, + timeoutHandler: (taskId: string) => void, +): Promise<BackgroundFetchStatus> { + return BackgroundFetch.configure( + {minimumFetchInterval: 15}, + handler, + timeoutHandler, + ) +} + +export function finish(taskId: string) { + return BackgroundFetch.finish(taskId) +} diff --git a/src/lib/bg-scheduler.web.ts b/src/lib/bg-scheduler.web.ts new file mode 100644 index 000000000..91ec9428f --- /dev/null +++ b/src/lib/bg-scheduler.web.ts @@ -0,0 +1,13 @@ +type BackgroundFetchStatus = 0 | 1 | 2 + +export async function configure( + _handler: (taskId: string) => Promise<void>, + _timeoutHandler: (taskId: string) => Promise<void>, +): Promise<BackgroundFetchStatus> { + // TODO + return 0 +} + +export function finish(_taskId: string) { + // TODO +} diff --git a/src/lib/build-flags.ts b/src/lib/build-flags.ts new file mode 100644 index 000000000..155230e5d --- /dev/null +++ b/src/lib/build-flags.ts @@ -0,0 +1,2 @@ +export const LOGIN_INCLUDE_DEV_SERVERS = true +export const TABS_ENABLED = false diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 000000000..2a3043c06 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,65 @@ +export const FEEDBACK_FORM_URL = + 'https://docs.google.com/forms/d/e/1FAIpQLSdavFRXTdB6tRobaFrRR2A1gv3b-IBHwQkBmNZTRpoqmcrPrQ/viewform?usp=sf_link' + +export const MAX_DISPLAY_NAME = 64 +export const MAX_DESCRIPTION = 256 + +export const PROD_SUGGESTED_FOLLOWS = [ + 'john', + 'visakanv', + 'saz', + 'steph', + 'ratzlaff', + 'beth', + 'weisser', + 'katherine', + 'annagat', + 'josh', + 'lurkshark', + 'amir', + 'amyxzh', + 'danielle', + 'jack-frazee', + 'vibes', + 'cat', + 'yuriy', + 'alvinreyes', + 'skoot', + 'patricia', + 'ara4n', + 'case', + 'armand', + 'ivan', + 'nicholas', + 'kelsey', + 'ericlee', + 'emily', + 'jake', + 'jennijuju', + 'ian5v', + 'bnewbold', + 'chris', + 'mtclai', + 'willscott', + 'michael', + 'kwkroeger', + 'broox', + 'iamrosewang', + 'jack-morrison', + 'pwang', + 'martin', + 'jack', + 'dan', + 'why', + 'divy', + 'jay', + 'paul', +].map(handle => `${handle}.bsky.social`) + +export const STAGING_SUGGESTED_FOLLOWS = ['arcalinea', 'paul', 'paul2'].map( + handle => `${handle}.staging.bsky.dev`, +) + +export const DEV_SUGGESTED_FOLLOWS = ['alice', 'bob', 'carla'].map( + handle => `${handle}.test`, +) diff --git a/src/lib/errors.ts b/src/lib/errors.ts deleted file mode 100644 index 216d5927b..000000000 --- a/src/lib/errors.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function isNetworkError(e: unknown) { - const str = String(e) - return str.includes('Abort') || str.includes('Network request failed') -} diff --git a/src/lib/hooks/useAnimatedValue.ts b/src/lib/hooks/useAnimatedValue.ts new file mode 100644 index 000000000..1307ef952 --- /dev/null +++ b/src/lib/hooks/useAnimatedValue.ts @@ -0,0 +1,12 @@ +import * as React from 'react' +import {Animated} from 'react-native' + +export function useAnimatedValue(initialValue: number) { + const lazyRef = React.useRef<Animated.Value>() + + if (lazyRef.current === undefined) { + lazyRef.current = new Animated.Value(initialValue) + } + + return lazyRef.current as Animated.Value +} diff --git a/src/lib/hooks/useOnMainScroll.ts b/src/lib/hooks/useOnMainScroll.ts new file mode 100644 index 000000000..41b35dd4f --- /dev/null +++ b/src/lib/hooks/useOnMainScroll.ts @@ -0,0 +1,25 @@ +import {useState} from 'react' +import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' +import {RootStoreModel} from 'state/index' + +export type OnScrollCb = ( + event: NativeSyntheticEvent<NativeScrollEvent>, +) => void + +export function useOnMainScroll(store: RootStoreModel) { + let [lastY, setLastY] = useState(0) + let isMinimal = store.shell.minimalShellMode + return function onMainScroll(event: NativeSyntheticEvent<NativeScrollEvent>) { + const y = event.nativeEvent.contentOffset.y + const dy = y - (lastY || 0) + setLastY(y) + + if (!isMinimal && y > 10 && dy > 10) { + store.shell.setMinimalShellMode(true) + isMinimal = true + } else if (isMinimal && (y <= 10 || dy < -10)) { + store.shell.setMinimalShellMode(false) + isMinimal = false + } + } +} diff --git a/src/lib/hooks/usePalette.ts b/src/lib/hooks/usePalette.ts new file mode 100644 index 000000000..5b9929c7d --- /dev/null +++ b/src/lib/hooks/usePalette.ts @@ -0,0 +1,48 @@ +import {TextStyle, ViewStyle} from 'react-native' +import {useTheme, PaletteColorName, PaletteColor} from '../ThemeContext' + +export interface UsePaletteValue { + colors: PaletteColor + view: ViewStyle + btn: ViewStyle + border: ViewStyle + borderDark: ViewStyle + text: TextStyle + textLight: TextStyle + textInverted: TextStyle + link: TextStyle + icon: TextStyle +} +export function usePalette(color: PaletteColorName): UsePaletteValue { + const palette = useTheme().palette[color] + return { + colors: palette, + view: { + backgroundColor: palette.background, + }, + btn: { + backgroundColor: palette.backgroundLight, + }, + border: { + borderColor: palette.border, + }, + borderDark: { + borderColor: palette.borderDark, + }, + text: { + color: palette.text, + }, + textLight: { + color: palette.textLight, + }, + textInverted: { + color: palette.textInverted, + }, + link: { + color: palette.link, + }, + icon: { + color: palette.icon, + }, + } +} diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx new file mode 100644 index 000000000..f400c3f72 --- /dev/null +++ b/src/lib/icons.tsx @@ -0,0 +1,529 @@ +import React from 'react' +import {StyleProp, TextStyle, ViewStyle} from 'react-native' +import Svg, {Path, Rect} from 'react-native-svg' + +export function GridIcon({ + style, + solid, +}: { + style?: StyleProp<ViewStyle> + solid?: boolean +}) { + const DIM = 4 + const ARC = 2 + return ( + <Svg width="24" height="24" style={style}> + <Path + d={`M4,1 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`} + strokeWidth={2} + stroke="#000" + fill={solid ? '#000' : undefined} + /> + <Path + d={`M16,1 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`} + strokeWidth={2} + stroke="#000" + fill={solid ? '#000' : undefined} + /> + <Path + d={`M4,13 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`} + strokeWidth={2} + stroke="#000" + fill={solid ? '#000' : undefined} + /> + <Path + d={`M16,13 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`} + strokeWidth={2} + stroke="#000" + fill={solid ? '#000' : undefined} + /> + </Svg> + ) +} +export function GridIconSolid({style}: {style?: StyleProp<ViewStyle>}) { + return <GridIcon style={style} solid /> +} + +export function HomeIcon({ + style, + size, + strokeWidth = 4, +}: { + style?: StyleProp<ViewStyle> + size?: string | number + strokeWidth?: number +}) { + return ( + <Svg + viewBox="0 0 48 48" + width={size || 24} + height={size || 24} + stroke="currentColor" + fill="none" + style={style}> + <Path + strokeWidth={strokeWidth} + d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z" + /> + </Svg> + ) +} + +export function HomeIconSolid({ + style, + size, +}: { + style?: StyleProp<ViewStyle> + size?: string | number +}) { + return ( + <Svg + viewBox="0 0 48 48" + width={size || 24} + height={size || 24} + stroke="currentColor" + style={style}> + <Path + strokeWidth={2} + fill="currentColor" + d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z" + /> + </Svg> + ) +} + +// Copyright (c) 2020 Refactoring UI Inc. +// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE +export function MagnifyingGlassIcon({ + style, + size, + strokeWidth = 2, +}: { + style?: StyleProp<ViewStyle> + size?: string | number + strokeWidth?: number +}) { + return ( + <Svg + fill="none" + viewBox="0 0 24 24" + strokeWidth={strokeWidth} + stroke="currentColor" + width={size || 24} + height={size || 24} + style={style}> + <Path + strokeLinecap="round" + strokeLinejoin="round" + d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" + /> + </Svg> + ) +} + +// https://github.com/Remix-Design/RemixIcon/blob/master/License +export function BellIcon({ + style, + size, +}: { + style?: StyleProp<ViewStyle> + size?: string | number +}) { + return ( + <Svg + fill="none" + viewBox="0 0 24 24" + width={size || 24} + height={size || 24} + style={style}> + <Path fill="none" d="M0 0h24v24H0z" /> + <Path + fill="currentColor" + d="M20 17h2v2H2v-2h2v-7a8 8 0 1 1 16 0v7zm-2 0v-7a6 6 0 1 0-12 0v7h12zm-9 4h6v2H9v-2z" + /> + </Svg> + ) +} + +// https://github.com/Remix-Design/RemixIcon/blob/master/License +export function BellIconSolid({ + style, + size, +}: { + style?: StyleProp<ViewStyle> + size?: string | number +}) { + return ( + <Svg + fill="none" + viewBox="0 0 24 24" + width={size || 24} + height={size || 24} + style={style}> + <Path fill="none" d="M0 0h24v24H0z" /> + <Path + fill="currentColor" + d="M 20 17 L 22 17 L 22 19 L 2 19 L 2 17 L 4 17 L 4 10 C 4 3.842 10.667 -0.007 16 3.072 C 18.475 4.501 20 7.142 20 10 L 20 17 Z M 9 21 L 15 21 L 15 23 L 9 23 L 9 21 Z" + /> + </Svg> + ) +} + +export function CogIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp<ViewStyle> + size?: string | number + strokeWidth: number +}) { + return ( + <Svg + fill="none" + viewBox="0 0 24 24" + width={size || 32} + height={size || 32} + strokeWidth={strokeWidth} + stroke="currentColor" + style={style}> + <Path + strokeLinecap="round" + strokeLinejoin="round" + d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" + /> + <Path + strokeLinecap="round" + strokeLinejoin="round" + d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" + /> + </Svg> + ) +} + +// Copyright (c) 2020 Refactoring UI Inc. +// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE +export function UserIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp<ViewStyle> + size?: string | number + strokeWidth?: number +}) { + return ( + <Svg + fill="none" + viewBox="0 0 24 24" + width={size || 32} + height={size || 32} + strokeWidth={strokeWidth} + stroke="currentColor" + style={style}> + <Path + strokeLinecap="round" + strokeLinejoin="round" + d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" + /> + </Svg> + ) +} + +// Copyright (c) 2020 Refactoring UI Inc. +// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE +export function UserGroupIcon({ + style, + size, +}: { + style?: StyleProp<ViewStyle> + size?: string | number +}) { + return ( + <Svg + fill="none" + viewBox="0 0 24 24" + width={size || 32} + height={size || 32} + strokeWidth={2} + stroke="currentColor" + style={style}> + <Path + strokeLinecap="round" + strokeLinejoin="round" + d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" + /> + </Svg> + ) +} + +export function RepostIcon({ + style, + size = 24, + strokeWidth = 1.5, +}: { + style?: StyleProp<ViewStyle> + size?: string | number + strokeWidth: number +}) { + return ( + <Svg viewBox="0 0 24 24" width={size} height={size} style={style}> + <Path + stroke="currentColor" + strokeWidth={strokeWidth} + strokeLinejoin="round" + fill="none" + d="M 14.437 17.081 L 5.475 17.095 C 4.7 17.095 4.072 16.467 4.072 15.692 L 4.082 5.65 L 1.22 9.854 M 4.082 5.65 L 7.006 9.854 M 9.859 5.65 L 18.625 5.654 C 19.4 5.654 20.028 6.282 20.028 7.057 L 20.031 17.081 L 17.167 12.646 M 20.031 17.081 L 22.866 12.646" + /> + </Svg> + ) +} + +// Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. +export function HeartIcon({ + style, + size = 24, + strokeWidth = 1.5, +}: { + style?: StyleProp<ViewStyle> + size?: string | number + strokeWidth: number +}) { + return ( + <Svg viewBox="0 0 24 24" width={size} height={size} style={style}> + <Path + strokeWidth={strokeWidth} + stroke="currentColor" + fill="none" + d="M 3.859 13.537 L 10.918 20.127 C 11.211 20.4 11.598 20.552 12 20.552 C 12.402 20.552 12.789 20.4 13.082 20.127 L 20.141 13.537 C 21.328 12.431 22 10.88 22 9.259 L 22 9.033 C 22 6.302 20.027 3.974 17.336 3.525 C 15.555 3.228 13.742 3.81 12.469 5.084 L 12 5.552 L 11.531 5.084 C 10.258 3.81 8.445 3.228 6.664 3.525 C 3.973 3.974 2 6.302 2 9.033 L 2 9.259 C 2 10.88 2.672 12.431 3.859 13.537 Z" + /> + </Svg> + ) +} + +// Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. +export function HeartIconSolid({ + style, + size = 24, +}: { + style?: StyleProp<ViewStyle> + size?: string | number +}) { + return ( + <Svg viewBox="0 0 24 24" width={size} height={size} style={style}> + <Path + fill="currentColor" + stroke="currentColor" + strokeWidth={1} + d="M 3.859 13.537 L 10.918 20.127 C 11.211 20.4 11.598 20.552 12 20.552 C 12.402 20.552 12.789 20.4 13.082 20.127 L 20.141 13.537 C 21.328 12.431 22 10.88 22 9.259 L 22 9.033 C 22 6.302 20.027 3.974 17.336 3.525 C 15.555 3.228 13.742 3.81 12.469 5.084 L 12 5.552 L 11.531 5.084 C 10.258 3.81 8.445 3.228 6.664 3.525 C 3.973 3.974 2 6.302 2 9.033 L 2 9.259 C 2 10.88 2.672 12.431 3.859 13.537 Z" + /> + </Svg> + ) +} + +export function UpIcon({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp<ViewStyle> + size?: string | number + strokeWidth: number +}) { + return ( + <Svg + viewBox="0 0 14 14" + width={size || 24} + height={size || 24} + style={style}> + <Path + strokeWidth={strokeWidth} + stroke="currentColor" + strokeLinecap="round" + strokeLinejoin="round" + d="M 7 3 L 2 8 L 4.5 8 L 4.5 11.5 L 9.5 11.5 L 9.5 8 L 12 8 L 7 3 Z" + /> + </Svg> + ) +} + +export function UpIconSolid({ + style, + size, +}: { + style?: StyleProp<ViewStyle> + size?: string | number +}) { + return ( + <Svg + viewBox="0 0 14 14" + width={size || 24} + height={size || 24} + style={style}> + <Path + strokeWidth={1.3} + stroke="currentColor" + fill="currentColor" + strokeLinecap="round" + strokeLinejoin="round" + d="M 7 3 L 2 8 L 4.5 8 L 4.5 11.5 L 9.5 11.5 L 9.5 8 L 12 8 L 7 3 Z" + /> + </Svg> + ) +} + +export function DownIcon({ + style, + size, +}: { + style?: StyleProp<ViewStyle> + size?: string | number +}) { + return ( + <Svg + viewBox="0 0 14 14" + width={size || 24} + height={size || 24} + style={style}> + <Path + strokeWidth={1.3} + stroke="currentColor" + strokeLinecap="round" + strokeLinejoin="round" + d="M 7 11.5 L 2 6.5 L 4.5 6.5 L 4.5 3 L 9.5 3 L 9.5 6.5 L 12 6.5 L 7 11.5 Z" + /> + </Svg> + ) +} + +export function DownIconSolid({ + style, + size, +}: { + style?: StyleProp<ViewStyle> + size?: string | number +}) { + return ( + <Svg + viewBox="0 0 14 14" + width={size || 24} + height={size || 24} + style={style}> + <Path + strokeWidth={1.3} + stroke="currentColor" + fill="currentColor" + strokeLinecap="round" + strokeLinejoin="round" + d="M 7 11.5 L 2 6.5 L 4.5 6.5 L 4.5 3 L 9.5 3 L 9.5 6.5 L 12 6.5 L 7 11.5 Z" + /> + </Svg> + ) +} + +// Copyright (c) 2020 Refactoring UI Inc. +// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE +export function CommentBottomArrow({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp<TextStyle> + size?: string | number + strokeWidth?: number +}) { + let color = 'currentColor' + if ( + style && + typeof style === 'object' && + 'color' in style && + typeof style.color === 'string' + ) { + color = style.color + } + return ( + <Svg + fill="none" + viewBox="0 0 24 24" + strokeWidth={strokeWidth || 2.5} + stroke={color} + width={size || 24} + height={size || 24} + style={style}> + <Path + strokeLinecap="round" + strokeLinejoin="round" + d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.068.157 2.148.279 3.238.364.466.037.893.281 1.153.671L12 21l2.652-3.978c.26-.39.687-.634 1.153-.67 1.09-.086 2.17-.208 3.238-.365 1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" + /> + </Svg> + ) +} + +export function SquareIcon({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp<TextStyle> + size?: string | number + strokeWidth?: number +}) { + return ( + <Svg + fill="none" + viewBox="0 0 24 24" + strokeWidth={strokeWidth || 1} + stroke="currentColor" + width={size || 24} + height={size || 24} + style={style}> + <Rect x="6" y="6" width="12" height="12" strokeLinejoin="round" /> + </Svg> + ) +} + +export function RectWideIcon({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp<TextStyle> + size?: string | number + strokeWidth?: number +}) { + return ( + <Svg + fill="none" + viewBox="0 0 24 24" + strokeWidth={strokeWidth || 1} + stroke="currentColor" + width={size || 24} + height={size || 24} + style={style}> + <Rect x="4" y="6" width="16" height="12" strokeLinejoin="round" /> + </Svg> + ) +} + +export function RectTallIcon({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp<TextStyle> + size?: string | number + strokeWidth?: number +}) { + return ( + <Svg + fill="none" + viewBox="0 0 24 24" + strokeWidth={strokeWidth || 1} + stroke="currentColor" + width={size || 24} + height={size || 24} + style={style}> + <Rect x="6" y="4" width="12" height="16" strokeLinejoin="round" /> + </Svg> + ) +} diff --git a/src/lib/images.ts b/src/lib/images.ts index 8d5eaded0..609e03bda 100644 --- a/src/lib/images.ts +++ b/src/lib/images.ts @@ -2,8 +2,8 @@ import RNFetchBlob from 'rn-fetch-blob' import ImageResizer from '@bam.tech/react-native-image-resizer' import {Share} from 'react-native' import RNFS from 'react-native-fs' - -import * as Toast from '../view/com/util/Toast' +import uuid from 'react-native-uuid' +import * as Toast from 'view/com/util/Toast' export interface DownloadAndResizeOpts { uri: string @@ -23,16 +23,12 @@ export interface Image { } export async function downloadAndResize(opts: DownloadAndResizeOpts) { - let appendExt + let appendExt = 'jpeg' try { const urip = new URL(opts.uri) const ext = urip.pathname.split('.').pop() - if (ext === 'jpg' || ext === 'jpeg') { - appendExt = 'jpeg' - } else if (ext === 'png') { + if (ext === 'png') { appendExt = 'png' - } else { - return } } catch (e: any) { console.error('Invalid URI', opts.uri, e) @@ -109,12 +105,18 @@ export async function compressIfNeeded( if (img.size < maxSize) { return img } - return await resize(origUri, { + const resizedImage = await resize(origUri, { width: img.width, height: img.height, mode: 'stretch', maxSize, }) + const finalImageMovedPath = await moveToPremanantPath(resizedImage.path) + const finalImg = { + ...resizedImage, + path: finalImageMovedPath, + } + return finalImg } export interface Dim { @@ -150,3 +152,15 @@ export const saveImageModal = async ({uri}: {uri: string}) => { } RNFS.unlink(imagePath) } + +export const moveToPremanantPath = async (path: string) => { + /* + Since this package stores images in a temp directory, we need to move the file to a permanent location. + Relevant: IOS bug when trying to open a second time: + https://github.com/ivpusic/react-native-image-crop-picker/issues/1199 + */ + const filename = uuid.v4() + const destinationPath = `${RNFS.TemporaryDirectoryPath}/${filename}` + RNFS.moveFile(path, destinationPath) + return destinationPath +} diff --git a/src/lib/images.web.ts b/src/lib/images.web.ts index 5158e005f..4b6d93af2 100644 --- a/src/lib/images.web.ts +++ b/src/lib/images.web.ts @@ -1,6 +1,5 @@ -import {Share} from 'react-native' - -import * as Toast from '../view/com/util/Toast' +// import {Share} from 'react-native' +// import * as Toast from 'view/com/util/Toast' export interface DownloadAndResizeOpts { uri: string diff --git a/src/lib/extractBskyMeta.ts b/src/lib/link-meta/bsky.ts index e53036aec..fba41260d 100644 --- a/src/lib/extractBskyMeta.ts +++ b/src/lib/link-meta/bsky.ts @@ -1,18 +1,18 @@ import {LikelyType, LinkMeta} from './link-meta' -import {match as matchRoute} from '../view/routes' -import {convertBskyAppUrlIfNeeded, makeRecordUri} from './strings' -import {RootStoreModel} from '../state' -import {PostThreadViewModel} from '../state/models/post-thread-view' +import {match as matchRoute} from 'view/routes' +import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers' +import {RootStoreModel} from 'state/index' +import {PostThreadViewModel} from 'state/models/post-thread-view' -import {Home} from '../view/screens/Home' -import {Search} from '../view/screens/Search' -import {Notifications} from '../view/screens/Notifications' -import {PostThread} from '../view/screens/PostThread' -import {PostUpvotedBy} from '../view/screens/PostUpvotedBy' -import {PostRepostedBy} from '../view/screens/PostRepostedBy' -import {Profile} from '../view/screens/Profile' -import {ProfileFollowers} from '../view/screens/ProfileFollowers' -import {ProfileFollows} from '../view/screens/ProfileFollows' +import {Home} from 'view/screens/Home' +import {Search} from 'view/screens/Search' +import {Notifications} from 'view/screens/Notifications' +import {PostThread} from 'view/screens/PostThread' +import {PostUpvotedBy} from 'view/screens/PostUpvotedBy' +import {PostRepostedBy} from 'view/screens/PostRepostedBy' +import {Profile} from 'view/screens/Profile' +import {ProfileFollowers} from 'view/screens/ProfileFollowers' +import {ProfileFollows} from 'view/screens/ProfileFollows' // NOTE // this is a hack around the lack of hosted social metadata diff --git a/src/lib/extractHtmlMeta.ts b/src/lib/link-meta/html.ts index 70387f71d..220f8431d 100644 --- a/src/lib/extractHtmlMeta.ts +++ b/src/lib/link-meta/html.ts @@ -1,5 +1,5 @@ -import {extractTwitterMeta} from './extractTwitterMeta' -import {extractYoutubeMeta} from './extractYoutubeMeta' +import {extractTwitterMeta} from './twitter' +import {extractYoutubeMeta} from './youtube' interface ExtractHtmlMetaInput { html: string diff --git a/src/lib/link-meta.ts b/src/lib/link-meta/link-meta.ts index 2826e969a..6c4ad5384 100644 --- a/src/lib/link-meta.ts +++ b/src/lib/link-meta/link-meta.ts @@ -1,8 +1,8 @@ import he from 'he' -import {isBskyAppUrl} from './strings' -import {RootStoreModel} from '../state' -import {extractBskyMeta} from './extractBskyMeta' -import {extractHtmlMeta} from './extractHtmlMeta' +import {isBskyAppUrl} from '../strings/url-helpers' +import {RootStoreModel} from 'state/index' +import {extractBskyMeta} from './bsky' +import {extractHtmlMeta} from './html' export enum LikelyType { HTML, diff --git a/src/lib/extractTwitterMeta.ts b/src/lib/link-meta/twitter.ts index d785903c0..d785903c0 100644 --- a/src/lib/extractTwitterMeta.ts +++ b/src/lib/link-meta/twitter.ts diff --git a/src/lib/extractYoutubeMeta.ts b/src/lib/link-meta/youtube.ts index 566e3be46..42eed51e8 100644 --- a/src/lib/extractYoutubeMeta.ts +++ b/src/lib/link-meta/youtube.ts @@ -4,10 +4,12 @@ export const extractYoutubeMeta = (html: string): Record<string, string> => { const youtubeDescriptionRegex = /"videoDetails":.*"shortDescription":"([^"]*)"/i const youtubeThumbnailRegex = /"videoDetails":.*"url":"(.*)(default\.jpg)/i - + const youtubeAvatarRegex = + /"avatar":{"thumbnails":\[{.*?url.*?url.*?url":"([^"]*)"/i const youtubeTitleMatch = youtubeTitleRegex.exec(html) const youtubeDescriptionMatch = youtubeDescriptionRegex.exec(html) const youtubeThumbnailMatch = youtubeThumbnailRegex.exec(html) + const youtubeAvatarMatch = youtubeAvatarRegex.exec(html) if (youtubeTitleMatch && youtubeTitleMatch.length >= 1) { res.title = decodeURI(youtubeTitleMatch[1]) @@ -21,6 +23,9 @@ export const extractYoutubeMeta = (html: string): Record<string, string> => { if (youtubeThumbnailMatch && youtubeThumbnailMatch.length >= 2) { res.image = youtubeThumbnailMatch[1] + 'default.jpg' } + if (!res.image && youtubeAvatarMatch && youtubeAvatarMatch.length >= 1) { + res.image = youtubeAvatarMatch[1] + } return res } diff --git a/src/lib/notifee.ts b/src/lib/notifee.ts new file mode 100644 index 000000000..fb0afdd60 --- /dev/null +++ b/src/lib/notifee.ts @@ -0,0 +1,74 @@ +import notifee, {EventType} from '@notifee/react-native' +import {AppBskyEmbedImages} from '@atproto/api' +import {RootStoreModel} from 'state/models/root-store' +import {TabPurpose} from 'state/models/navigation' +import {NotificationsViewItemModel} from 'state/models/notifications-view' +import {enforceLen} from 'lib/strings/helpers' + +export function init(store: RootStoreModel) { + store.onUnreadNotifications(count => notifee.setBadgeCount(count)) + store.onPushNotification(displayNotificationFromModel) + store.onSessionLoaded(() => { + // request notifications permission once the user has logged in + notifee.requestPermission() + }) + notifee.onForegroundEvent(async ({type}: {type: EventType}) => { + store.log.debug('Notifee foreground event', {type}) + if (type === EventType.PRESS) { + store.log.debug('User pressed a notifee, opening notifications') + store.nav.switchTo(TabPurpose.Notifs, true) + } + }) + notifee.onBackgroundEvent(async _e => {}) // notifee requires this but we handle it with onForegroundEvent +} + +export function displayNotification( + title: string, + body?: string, + image?: string, +) { + const opts: {title: string; body?: string; ios?: any} = {title} + if (body) { + opts.body = enforceLen(body, 70, true) + } + if (image) { + opts.ios = { + attachments: [{url: image}], + } + } + return notifee.displayNotification(opts) +} + +export function displayNotificationFromModel( + notif: NotificationsViewItemModel, +) { + let author = notif.author.displayName || notif.author.handle + let title: string + let body: string = '' + if (notif.isUpvote) { + title = `${author} liked your post` + body = notif.additionalPost?.thread?.postRecord?.text || '' + } else if (notif.isRepost) { + title = `${author} reposted your post` + body = notif.additionalPost?.thread?.postRecord?.text || '' + } else if (notif.isMention) { + title = `${author} mentioned you` + body = notif.additionalPost?.thread?.postRecord?.text || '' + } else if (notif.isReply) { + title = `${author} replied to your post` + body = notif.additionalPost?.thread?.postRecord?.text || '' + } else if (notif.isFollow) { + title = 'New follower!' + body = `${author} has followed you` + } else { + return + } + let image + if ( + AppBskyEmbedImages.isPresented(notif.additionalPost?.thread?.post.embed) && + notif.additionalPost?.thread?.post.embed.images[0]?.thumb + ) { + image = notif.additionalPost.thread.post.embed.images[0].thumb + } + return displayNotification(title, body, image) +} diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 000000000..ab2c73ca6 --- /dev/null +++ b/src/lib/permissions.ts @@ -0,0 +1,61 @@ +import {Alert} from 'react-native' +import { + check, + openSettings, + Permission, + PermissionStatus, + PERMISSIONS, + RESULTS, +} from 'react-native-permissions' + +export const PHOTO_LIBRARY = PERMISSIONS.IOS.PHOTO_LIBRARY +export const CAMERA = PERMISSIONS.IOS.CAMERA + +/** + * Returns `true` if the user has granted permission or hasn't made + * a decision yet. Returns `false` if unavailable or not granted. + */ +export async function hasAccess(perm: Permission): Promise<boolean> { + const status = await check(perm) + return isntANo(status) +} + +export async function requestAccessIfNeeded( + perm: Permission, +): Promise<boolean> { + if (await hasAccess(perm)) { + return true + } + let permDescription + if (perm === PHOTO_LIBRARY) { + permDescription = 'photo library' + } else if (perm === CAMERA) { + permDescription = 'camera' + } else { + return false + } + Alert.alert( + 'Permission needed', + `Bluesky does not have permission to access your ${permDescription}.`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + {text: 'Open Settings', onPress: () => openSettings()}, + ], + ) + return false +} + +export async function requestPhotoAccessIfNeeded() { + return requestAccessIfNeeded(PHOTO_LIBRARY) +} + +export async function requestCameraAccessIfNeeded() { + return requestAccessIfNeeded(CAMERA) +} + +function isntANo(status: PermissionStatus): boolean { + return status !== RESULTS.UNAVAILABLE && status !== RESULTS.BLOCKED +} diff --git a/src/lib/permissions.web.ts b/src/lib/permissions.web.ts new file mode 100644 index 000000000..5b69637ed --- /dev/null +++ b/src/lib/permissions.web.ts @@ -0,0 +1,22 @@ +/* +At the moment, Web doesn't have any equivalence for these. +*/ + +export const PHOTO_LIBRARY = '' +export const CAMERA = '' + +export async function hasAccess(_perm: any): Promise<boolean> { + return true +} + +export async function requestAccessIfNeeded(_perm: any): Promise<boolean> { + return true +} + +export async function requestPhotoAccessIfNeeded() { + return requestAccessIfNeeded(PHOTO_LIBRARY) +} + +export async function requestCameraAccessIfNeeded() { + return requestAccessIfNeeded(CAMERA) +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 000000000..dc5fb620f --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,52 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +export async function loadString(key: string): Promise<string | null> { + try { + return await AsyncStorage.getItem(key) + } catch { + // not sure why this would fail... even reading the RN docs I'm unclear + return null + } +} + +export async function saveString(key: string, value: string): Promise<boolean> { + try { + await AsyncStorage.setItem(key, value) + return true + } catch { + return false + } +} + +export async function load(key: string): Promise<any | null> { + try { + const str = await AsyncStorage.getItem(key) + if (typeof str !== 'string') { + return null + } + return JSON.parse(str) + } catch { + return null + } +} + +export async function save(key: string, value: any): Promise<boolean> { + try { + await AsyncStorage.setItem(key, JSON.stringify(value)) + return true + } catch { + return false + } +} + +export async function remove(key: string): Promise<void> { + try { + await AsyncStorage.removeItem(key) + } catch {} +} + +export async function clear(): Promise<void> { + try { + await AsyncStorage.clear() + } catch {} +} diff --git a/src/lib/strings.ts b/src/lib/strings.ts deleted file mode 100644 index 8b93fa933..000000000 --- a/src/lib/strings.ts +++ /dev/null @@ -1,267 +0,0 @@ -import {AtUri} from '../third-party/uri' -import {AppBskyFeedPost} from '@atproto/api' -type Entity = AppBskyFeedPost.Entity -import {PROD_SERVICE} from '../state' -import {isNetworkError} from './errors' -import TLDs from 'tlds' - -export const MAX_DISPLAY_NAME = 64 -export const MAX_DESCRIPTION = 256 - -export function pluralize(n: number, base: string, plural?: string): string { - if (n === 1) { - return base - } - if (plural) { - return plural - } - return base + 's' -} - -export function makeRecordUri( - didOrName: string, - collection: string, - rkey: string, -) { - const urip = new AtUri('at://host/') - urip.host = didOrName - urip.collection = collection - urip.rkey = rkey - return urip.toString() -} - -const MINUTE = 60 -const HOUR = MINUTE * 60 -const DAY = HOUR * 24 -const MONTH = DAY * 30 -const YEAR = DAY * 365 -export function ago(date: number | string | Date): string { - let ts: number - if (typeof date === 'string') { - ts = Number(new Date(date)) - } else if (date instanceof Date) { - ts = Number(date) - } else { - ts = date - } - const diffSeconds = Math.floor((Date.now() - ts) / 1e3) - if (diffSeconds < MINUTE) { - return `${diffSeconds}s` - } else if (diffSeconds < HOUR) { - return `${Math.floor(diffSeconds / MINUTE)}m` - } else if (diffSeconds < DAY) { - return `${Math.floor(diffSeconds / HOUR)}h` - } else if (diffSeconds < MONTH) { - return `${Math.floor(diffSeconds / DAY)}d` - } else if (diffSeconds < YEAR) { - return `${Math.floor(diffSeconds / MONTH)}mo` - } else { - return new Date(ts).toLocaleDateString() - } -} - -export function isValidDomain(str: string): boolean { - return !!TLDs.find(tld => { - let i = str.lastIndexOf(tld) - if (i === -1) { - return false - } - return str.charAt(i - 1) === '.' && i === str.length - tld.length - }) -} - -export function extractEntities( - text: string, - knownHandles?: Set<string>, -): Entity[] | undefined { - let match - let ents: Entity[] = [] - { - // mentions - const re = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g - while ((match = re.exec(text))) { - if (knownHandles && !knownHandles.has(match[3])) { - continue // not a known handle - } else if (!match[3].includes('.')) { - continue // probably not a handle - } - const start = text.indexOf(match[3], match.index) - 1 - ents.push({ - type: 'mention', - value: match[3], - index: {start, end: start + match[3].length + 1}, - }) - } - } - { - // links - const re = - /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim - while ((match = re.exec(text))) { - let value = match[2] - if (!value.startsWith('http')) { - const domain = match.groups?.domain - if (!domain || !isValidDomain(domain)) { - continue - } - value = `https://${value}` - } - const start = text.indexOf(match[2], match.index) - const index = {start, end: start + match[2].length} - // strip ending puncuation - if (/[.,;!?]$/.test(value)) { - value = value.slice(0, -1) - index.end-- - } - if (/[)]$/.test(value) && !value.includes('(')) { - value = value.slice(0, -1) - index.end-- - } - ents.push({ - type: 'link', - value, - index, - }) - } - } - return ents.length > 0 ? ents : undefined -} - -interface DetectedLink { - link: string -} -type DetectedLinkable = string | DetectedLink -export function detectLinkables(text: string): DetectedLinkable[] { - const re = - /((^|\s|\()@[a-z0-9.-]*)|((^|\s|\()https?:\/\/[\S]+)|((^|\s|\()(?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*)/gi - const segments = [] - let match - let start = 0 - while ((match = re.exec(text))) { - let matchIndex = match.index - let matchValue = match[0] - - if (match.groups?.domain && !isValidDomain(match.groups?.domain)) { - continue - } - - if (/\s|\(/.test(matchValue)) { - // HACK - // skip the starting space - // we have to do this because RN doesnt support negative lookaheads - // -prf - matchIndex++ - matchValue = matchValue.slice(1) - } - - // strip ending puncuation - if (/[.,;!?]$/.test(matchValue)) { - matchValue = matchValue.slice(0, -1) - } - if (/[)]$/.test(matchValue) && !matchValue.includes('(')) { - matchValue = matchValue.slice(0, -1) - } - - if (start !== matchIndex) { - segments.push(text.slice(start, matchIndex)) - } - segments.push({link: matchValue}) - start = matchIndex + matchValue.length - } - if (start < text.length) { - segments.push(text.slice(start)) - } - return segments -} - -export function makeValidHandle(str: string): string { - if (str.length > 20) { - str = str.slice(0, 20) - } - str = str.toLowerCase() - return str.replace(/^[^a-z]+/g, '').replace(/[^a-z0-9-]/g, '') -} - -export function createFullHandle(name: string, domain: string): string { - name = (name || '').replace(/[.]+$/, '') - domain = (domain || '').replace(/^[.]+/, '') - return `${name}.${domain}` -} - -export function enforceLen(str: string, len: number, ellipsis = false): string { - str = str || '' - if (str.length > len) { - return str.slice(0, len) + (ellipsis ? '...' : '') - } - return str -} - -export function cleanError(str: any): string { - if (!str) { - return str - } - if (typeof str !== 'string') { - str = str.toString() - } - if (isNetworkError(str)) { - return 'Unable to connect. Please check your internet connection and try again.' - } - if (str.startsWith('Error: ')) { - return str.slice('Error: '.length) - } - return str -} - -export function toNiceDomain(url: string): string { - try { - const urlp = new URL(url) - if (`https://${urlp.host}` === PROD_SERVICE) { - return 'Bluesky Social' - } - return urlp.host - } catch (e) { - return url - } -} - -export function toShortUrl(url: string): string { - try { - const urlp = new URL(url) - const shortened = - urlp.host + - (urlp.pathname === '/' ? '' : urlp.pathname) + - urlp.search + - urlp.hash - if (shortened.length > 30) { - return shortened.slice(0, 27) + '...' - } - return shortened - } catch (e) { - return url - } -} - -export function toShareUrl(url: string): string { - if (!url.startsWith('https')) { - const urlp = new URL('https://bsky.app') - urlp.pathname = url - url = urlp.toString() - } - return url -} - -export function isBskyAppUrl(url: string): boolean { - return url.startsWith('https://bsky.app/') -} - -export function convertBskyAppUrlIfNeeded(url: string): string { - if (isBskyAppUrl(url)) { - try { - const urlp = new URL(url) - return urlp.pathname - } catch (e) { - console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e) - } - } - return url -} diff --git a/src/lib/strings/errors.ts b/src/lib/strings/errors.ts new file mode 100644 index 000000000..0efcad335 --- /dev/null +++ b/src/lib/strings/errors.ts @@ -0,0 +1,23 @@ +export function cleanError(str: any): string { + if (!str) { + return '' + } + if (typeof str !== 'string') { + str = str.toString() + } + if (isNetworkError(str)) { + return 'Unable to connect. Please check your internet connection and try again.' + } + if (str.includes('Upstream Failure')) { + return 'The server appears to be experiencing issues. Please try again in a few moments.' + } + if (str.startsWith('Error: ')) { + return str.slice('Error: '.length) + } + return str +} + +export function isNetworkError(e: unknown) { + const str = String(e) + return str.includes('Abort') || str.includes('Network request failed') +} diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts new file mode 100644 index 000000000..3409a0312 --- /dev/null +++ b/src/lib/strings/handles.ts @@ -0,0 +1,13 @@ +export function makeValidHandle(str: string): string { + if (str.length > 20) { + str = str.slice(0, 20) + } + str = str.toLowerCase() + return str.replace(/^[^a-z]+/g, '').replace(/[^a-z0-9-]/g, '') +} + +export function createFullHandle(name: string, domain: string): string { + name = (name || '').replace(/[.]+$/, '') + domain = (domain || '').replace(/^[.]+/, '') + return `${name}.${domain}` +} diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts new file mode 100644 index 000000000..183d53e31 --- /dev/null +++ b/src/lib/strings/helpers.ts @@ -0,0 +1,17 @@ +export function pluralize(n: number, base: string, plural?: string): string { + if (n === 1) { + return base + } + if (plural) { + return plural + } + return base + 's' +} + +export function enforceLen(str: string, len: number, ellipsis = false): string { + str = str || '' + if (str.length > len) { + return str.slice(0, len) + (ellipsis ? '...' : '') + } + return str +} diff --git a/src/lib/strings/mention-manip.ts b/src/lib/strings/mention-manip.ts new file mode 100644 index 000000000..1f7cbe434 --- /dev/null +++ b/src/lib/strings/mention-manip.ts @@ -0,0 +1,37 @@ +interface FoundMention { + value: string + index: number +} + +export function getMentionAt( + text: string, + cursorPos: number, +): FoundMention | undefined { + let re = /(^|\s)@([a-z0-9.]*)/gi + let match + while ((match = re.exec(text))) { + const spaceOffset = match[1].length + const index = match.index + spaceOffset + if ( + cursorPos >= index && + cursorPos <= index + match[0].length - spaceOffset + ) { + return {value: match[2], index} + } + } + return undefined +} + +export function insertMentionAt( + text: string, + cursorPos: number, + mention: string, +) { + const target = getMentionAt(text, cursorPos) + if (target) { + return `${text.slice(0, target.index)}@${mention} ${text.slice( + target.index + target.value.length + 1, // add 1 to include the "@" + )}` + } + return text +} diff --git a/src/lib/strings/rich-text-detection.ts b/src/lib/strings/rich-text-detection.ts new file mode 100644 index 000000000..386ed48e1 --- /dev/null +++ b/src/lib/strings/rich-text-detection.ts @@ -0,0 +1,107 @@ +import {AppBskyFeedPost} from '@atproto/api' +type Entity = AppBskyFeedPost.Entity +import {isValidDomain} from './url-helpers' + +export function extractEntities( + text: string, + knownHandles?: Set<string>, +): Entity[] | undefined { + let match + let ents: Entity[] = [] + { + // mentions + const re = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g + while ((match = re.exec(text))) { + if (knownHandles && !knownHandles.has(match[3])) { + continue // not a known handle + } else if (!match[3].includes('.')) { + continue // probably not a handle + } + const start = text.indexOf(match[3], match.index) - 1 + ents.push({ + type: 'mention', + value: match[3], + index: {start, end: start + match[3].length + 1}, + }) + } + } + { + // links + const re = + /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim + while ((match = re.exec(text))) { + let value = match[2] + if (!value.startsWith('http')) { + const domain = match.groups?.domain + if (!domain || !isValidDomain(domain)) { + continue + } + value = `https://${value}` + } + const start = text.indexOf(match[2], match.index) + const index = {start, end: start + match[2].length} + // strip ending puncuation + if (/[.,;!?]$/.test(value)) { + value = value.slice(0, -1) + index.end-- + } + if (/[)]$/.test(value) && !value.includes('(')) { + value = value.slice(0, -1) + index.end-- + } + ents.push({ + type: 'link', + value, + index, + }) + } + } + return ents.length > 0 ? ents : undefined +} + +interface DetectedLink { + link: string +} +type DetectedLinkable = string | DetectedLink +export function detectLinkables(text: string): DetectedLinkable[] { + const re = + /((^|\s|\()@[a-z0-9.-]*)|((^|\s|\()https?:\/\/[\S]+)|((^|\s|\()(?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*)/gi + const segments = [] + let match + let start = 0 + while ((match = re.exec(text))) { + let matchIndex = match.index + let matchValue = match[0] + + if (match.groups?.domain && !isValidDomain(match.groups?.domain)) { + continue + } + + if (/\s|\(/.test(matchValue)) { + // HACK + // skip the starting space + // we have to do this because RN doesnt support negative lookaheads + // -prf + matchIndex++ + matchValue = matchValue.slice(1) + } + + // strip ending puncuation + if (/[.,;!?]$/.test(matchValue)) { + matchValue = matchValue.slice(0, -1) + } + if (/[)]$/.test(matchValue) && !matchValue.includes('(')) { + matchValue = matchValue.slice(0, -1) + } + + if (start !== matchIndex) { + segments.push(text.slice(start, matchIndex)) + } + segments.push({link: matchValue}) + start = matchIndex + matchValue.length + } + if (start < text.length) { + segments.push(text.slice(start)) + } + return segments +} diff --git a/src/lib/strings/rich-text-sanitize.ts b/src/lib/strings/rich-text-sanitize.ts new file mode 100644 index 000000000..0b5895707 --- /dev/null +++ b/src/lib/strings/rich-text-sanitize.ts @@ -0,0 +1,32 @@ +import {RichText} from './rich-text' + +const EXCESS_SPACE_RE = /[\r\n]([\u00AD\u2060\u200D\u200C\u200B\s]*[\r\n]){2,}/ +const REPLACEMENT_STR = '\n\n' + +export function removeExcessNewlines(richText: RichText): RichText { + return clean(richText, EXCESS_SPACE_RE, REPLACEMENT_STR) +} + +// TODO: check on whether this works correctly with multi-byte codepoints +export function clean( + richText: RichText, + targetRegexp: RegExp, + replacementString: string, +): RichText { + richText = richText.clone() + + let match = richText.text.match(targetRegexp) + while (match && typeof match.index !== 'undefined') { + const oldText = richText.text + const removeStartIndex = match.index + const removeEndIndex = removeStartIndex + match[0].length + richText.delete(removeStartIndex, removeEndIndex) + if (richText.text === oldText) { + break // sanity check + } + richText.insert(removeStartIndex, replacementString) + match = richText.text.match(targetRegexp) + } + + return richText +} diff --git a/src/lib/strings/rich-text.ts b/src/lib/strings/rich-text.ts new file mode 100644 index 000000000..1df2144e0 --- /dev/null +++ b/src/lib/strings/rich-text.ts @@ -0,0 +1,216 @@ +/* += Rich Text Manipulation + +When we sanitize rich text, we have to update the entity indices as the +text is modified. This can be modeled as inserts() and deletes() of the +rich text string. The possible scenarios are outlined below, along with +their expected behaviors. + +NOTE: Slices are start inclusive, end exclusive + +== richTextInsert() + +Target string: + + 0 1 2 3 4 5 6 7 8 910 // string indices + h e l l o w o r l d // string value + ^-------^ // target slice {start: 2, end: 7} + +Scenarios: + +A: ^ // insert "test" at 0 +B: ^ // insert "test" at 4 +C: ^ // insert "test" at 8 + +A = before -> move both by num added +B = inner -> move end by num added +C = after -> noop + +Results: + +A: 0 1 2 3 4 5 6 7 8 910 // string indices + t e s t h e l l o w // string value + ^-------^ // target slice {start: 6, end: 11} + +B: 0 1 2 3 4 5 6 7 8 910 // string indices + h e l l t e s t o w // string value + ^---------------^ // target slice {start: 2, end: 11} + +C: 0 1 2 3 4 5 6 7 8 910 // string indices + h e l l o w o t e s // string value + ^-------^ // target slice {start: 2, end: 7} + +== richTextDelete() + +Target string: + + 0 1 2 3 4 5 6 7 8 910 // string indices + h e l l o w o r l d // string value + ^-------^ // target slice {start: 2, end: 7} + +Scenarios: + +A: ^---------------^ // remove slice {start: 0, end: 9} +B: ^-----^ // remove slice {start: 7, end: 11} +C: ^-----------^ // remove slice {start: 4, end: 11} +D: ^-^ // remove slice {start: 3, end: 5} +E: ^-----^ // remove slice {start: 1, end: 5} +F: ^-^ // remove slice {start: 0, end: 2} + +A = entirely outer -> delete slice +B = entirely after -> noop +C = partially after -> move end to remove-start +D = entirely inner -> move end by num removed +E = partially before -> move start to remove-start index, move end by num removed +F = entirely before -> move both by num removed + +Results: + +A: 0 1 2 3 4 5 6 7 8 910 // string indices + l d // string value + // target slice (deleted) + +B: 0 1 2 3 4 5 6 7 8 910 // string indices + h e l l o w // string value + ^-------^ // target slice {start: 2, end: 7} + +C: 0 1 2 3 4 5 6 7 8 910 // string indices + h e l l // string value + ^-^ // target slice {start: 2, end: 4} + +D: 0 1 2 3 4 5 6 7 8 910 // string indices + h e l w o r l d // string value + ^---^ // target slice {start: 2, end: 5} + +E: 0 1 2 3 4 5 6 7 8 910 // string indices + h w o r l d // string value + ^-^ // target slice {start: 1, end: 3} + +F: 0 1 2 3 4 5 6 7 8 910 // string indices + l l o w o r l d // string value + ^-------^ // target slice {start: 0, end: 5} + */ + +import cloneDeep from 'lodash.clonedeep' +import {AppBskyFeedPost} from '@atproto/api' +import {removeExcessNewlines} from './rich-text-sanitize' + +export type Entity = AppBskyFeedPost.Entity +export interface RichTextOpts { + cleanNewlines?: boolean +} + +export class RichText { + constructor( + public text: string, + public entities?: Entity[], + opts?: RichTextOpts, + ) { + if (opts?.cleanNewlines) { + removeExcessNewlines(this).copyInto(this) + } + } + + clone() { + return new RichText(this.text, cloneDeep(this.entities)) + } + + copyInto(target: RichText) { + target.text = this.text + target.entities = cloneDeep(this.entities) + } + + insert(insertIndex: number, insertText: string) { + this.text = + this.text.slice(0, insertIndex) + + insertText + + this.text.slice(insertIndex) + + if (!this.entities?.length) { + return this + } + + const numCharsAdded = insertText.length + for (const ent of this.entities) { + // see comment at top of file for labels of each scenario + // scenario A (before) + if (insertIndex <= ent.index.start) { + // move both by num added + ent.index.start += numCharsAdded + ent.index.end += numCharsAdded + } + // scenario B (inner) + else if (insertIndex >= ent.index.start && insertIndex < ent.index.end) { + // move end by num added + ent.index.end += numCharsAdded + } + // scenario C (after) + // noop + } + return this + } + + delete(removeStartIndex: number, removeEndIndex: number) { + this.text = + this.text.slice(0, removeStartIndex) + this.text.slice(removeEndIndex) + + if (!this.entities?.length) { + return this + } + + const numCharsRemoved = removeEndIndex - removeStartIndex + for (const ent of this.entities) { + // see comment at top of file for labels of each scenario + // scenario A (entirely outer) + if ( + removeStartIndex <= ent.index.start && + removeEndIndex >= ent.index.end + ) { + // delete slice (will get removed in final pass) + ent.index.start = 0 + ent.index.end = 0 + } + // scenario B (entirely after) + else if (removeStartIndex > ent.index.end) { + // noop + } + // scenario C (partially after) + else if ( + removeStartIndex > ent.index.start && + removeStartIndex <= ent.index.end && + removeEndIndex > ent.index.end + ) { + // move end to remove start + ent.index.end = removeStartIndex + } + // scenario D (entirely inner) + else if ( + removeStartIndex >= ent.index.start && + removeEndIndex <= ent.index.end + ) { + // move end by num removed + ent.index.end -= numCharsRemoved + } + // scenario E (partially before) + else if ( + removeStartIndex < ent.index.start && + removeEndIndex >= ent.index.start && + removeEndIndex <= ent.index.end + ) { + // move start to remove-start index, move end by num removed + ent.index.start = removeStartIndex + ent.index.end -= numCharsRemoved + } + // scenario F (entirely before) + else if (removeEndIndex < ent.index.start) { + // move both by num removed + ent.index.start -= numCharsRemoved + ent.index.end -= numCharsRemoved + } + } + + // filter out any entities that were made irrelevant + this.entities = this.entities.filter(ent => ent.index.start < ent.index.end) + return this + } +} diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts new file mode 100644 index 000000000..4f62eeba9 --- /dev/null +++ b/src/lib/strings/time.ts @@ -0,0 +1,29 @@ +const MINUTE = 60 +const HOUR = MINUTE * 60 +const DAY = HOUR * 24 +const MONTH = DAY * 30 +const YEAR = DAY * 365 +export function ago(date: number | string | Date): string { + let ts: number + if (typeof date === 'string') { + ts = Number(new Date(date)) + } else if (date instanceof Date) { + ts = Number(date) + } else { + ts = date + } + const diffSeconds = Math.floor((Date.now() - ts) / 1e3) + if (diffSeconds < MINUTE) { + return `${diffSeconds}s` + } else if (diffSeconds < HOUR) { + return `${Math.floor(diffSeconds / MINUTE)}m` + } else if (diffSeconds < DAY) { + return `${Math.floor(diffSeconds / HOUR)}h` + } else if (diffSeconds < MONTH) { + return `${Math.floor(diffSeconds / DAY)}d` + } else if (diffSeconds < YEAR) { + return `${Math.floor(diffSeconds / MONTH)}mo` + } else { + return new Date(ts).toLocaleDateString() + } +} diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts new file mode 100644 index 000000000..a149f49c3 --- /dev/null +++ b/src/lib/strings/url-helpers.ts @@ -0,0 +1,108 @@ +import {AtUri} from '../../third-party/uri' +import {PROD_SERVICE} from 'state/index' +import TLDs from 'tlds' + +export function isValidDomain(str: string): boolean { + return !!TLDs.find(tld => { + let i = str.lastIndexOf(tld) + if (i === -1) { + return false + } + return str.charAt(i - 1) === '.' && i === str.length - tld.length + }) +} + +export function makeRecordUri( + didOrName: string, + collection: string, + rkey: string, +) { + const urip = new AtUri('at://host/') + urip.host = didOrName + urip.collection = collection + urip.rkey = rkey + return urip.toString() +} + +export function toNiceDomain(url: string): string { + try { + const urlp = new URL(url) + if (`https://${urlp.host}` === PROD_SERVICE) { + return 'Bluesky Social' + } + return urlp.host + } catch (e) { + return url + } +} + +export function toShortUrl(url: string): string { + try { + const urlp = new URL(url) + const shortened = + urlp.host + + (urlp.pathname === '/' ? '' : urlp.pathname) + + urlp.search + + urlp.hash + if (shortened.length > 30) { + return shortened.slice(0, 27) + '...' + } + return shortened + } catch (e) { + return url + } +} + +export function toShareUrl(url: string): string { + if (!url.startsWith('https')) { + const urlp = new URL('https://bsky.app') + urlp.pathname = url + url = urlp.toString() + } + return url +} + +export function isBskyAppUrl(url: string): boolean { + return url.startsWith('https://bsky.app/') +} + +export function convertBskyAppUrlIfNeeded(url: string): string { + if (isBskyAppUrl(url)) { + try { + const urlp = new URL(url) + return urlp.pathname + } catch (e) { + console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e) + } + } + return url +} + +export function getYoutubeVideoId(link: string): string | undefined { + let url + try { + url = new URL(link) + } catch (e) { + return undefined + } + + if ( + url.hostname !== 'www.youtube.com' && + url.hostname !== 'youtube.com' && + url.hostname !== 'youtu.be' + ) { + return undefined + } + if (url.hostname === 'youtu.be') { + const videoId = url.pathname.split('/')[1] + if (!videoId) { + return undefined + } + return videoId + } + const videoId = url.searchParams.get('v') as string + if (!videoId) { + return undefined + } + return videoId +} diff --git a/src/lib/styles.ts b/src/lib/styles.ts new file mode 100644 index 000000000..db6c03606 --- /dev/null +++ b/src/lib/styles.ts @@ -0,0 +1,218 @@ +import {StyleProp, StyleSheet, TextStyle} from 'react-native' +import {Theme, TypographyVariant} from './ThemeContext' + +// 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest +export const colors = { + white: '#ffffff', + black: '#000000', + + gray1: '#F3F3F8', + gray2: '#E2E2E4', + gray3: '#B9B9C1', + gray4: '#8D8E96', + gray5: '#545664', + gray6: '#373942', + gray7: '#26272D', + gray8: '#101013', + + blue0: '#bfe1ff', + blue1: '#8bc7fd', + blue2: '#52acfe', + blue3: '#0085ff', + blue4: '#0062bd', + blue5: '#034581', + + red1: '#ffe6f2', + red2: '#fba2ce', + red3: '#ec4899', + red4: '#d1106f', + red5: '#97074e', + + pink1: '#f8ccff', + pink2: '#e966ff', + pink3: '#db00ff', + pink4: '#a601c1', + pink5: '#570066', + + purple1: '#ebdbff', + purple2: '#ba85ff', + purple3: '#9747ff', + purple4: '#6d00fa', + purple5: '#380080', + + green1: '#c1ffb8', + green2: '#27f406', + green3: '#20bc07', + green4: '#148203', + green5: '#082b03', + + unreadNotifBg: '#ebf6ff', +} + +export const gradients = { + blueLight: {start: '#5A71FA', end: colors.blue3}, // buttons + blue: {start: '#5E55FB', end: colors.blue3}, // fab + blueDark: {start: '#5F45E0', end: colors.blue3}, // avis, banner +} + +export const s = StyleSheet.create({ + // helpers + footerSpacer: {height: 100}, + contentContainer: {paddingBottom: 200}, + border1: {borderWidth: 1}, + + // font weights + fw600: {fontWeight: '600'}, + bold: {fontWeight: 'bold'}, + fw500: {fontWeight: '500'}, + semiBold: {fontWeight: '500'}, + fw400: {fontWeight: '400'}, + normal: {fontWeight: '400'}, + fw300: {fontWeight: '300'}, + light: {fontWeight: '300'}, + fw200: {fontWeight: '200'}, + + // text decoration + underline: {textDecorationLine: 'underline'}, + + // font sizes + f9: {fontSize: 9}, + f10: {fontSize: 10}, + f11: {fontSize: 11}, + f12: {fontSize: 12}, + f13: {fontSize: 13}, + f14: {fontSize: 14}, + f15: {fontSize: 15}, + f16: {fontSize: 16}, + f17: {fontSize: 17}, + f18: {fontSize: 18}, + + // line heights + ['lh13-1']: {lineHeight: 13}, + ['lh13-1.3']: {lineHeight: 16.9}, // 1.3 of 13px + ['lh14-1']: {lineHeight: 14}, + ['lh14-1.3']: {lineHeight: 18.2}, // 1.3 of 14px + ['lh15-1']: {lineHeight: 15}, + ['lh15-1.3']: {lineHeight: 19.5}, // 1.3 of 15px + ['lh16-1']: {lineHeight: 16}, + ['lh16-1.3']: {lineHeight: 20.8}, // 1.3 of 16px + ['lh17-1']: {lineHeight: 17}, + ['lh17-1.3']: {lineHeight: 22.1}, // 1.3 of 17px + ['lh18-1']: {lineHeight: 18}, + ['lh18-1.3']: {lineHeight: 23.4}, // 1.3 of 18px + + // margins + mr2: {marginRight: 2}, + mr5: {marginRight: 5}, + mr10: {marginRight: 10}, + ml2: {marginLeft: 2}, + ml5: {marginLeft: 5}, + ml10: {marginLeft: 10}, + mt2: {marginTop: 2}, + mt5: {marginTop: 5}, + mt10: {marginTop: 10}, + mb2: {marginBottom: 2}, + mb5: {marginBottom: 5}, + mb10: {marginBottom: 10}, + + // paddings + p2: {padding: 2}, + p5: {padding: 5}, + p10: {padding: 10}, + p20: {padding: 20}, + pr2: {paddingRight: 2}, + pr5: {paddingRight: 5}, + pr10: {paddingRight: 10}, + pr20: {paddingRight: 20}, + pl2: {paddingLeft: 2}, + pl5: {paddingLeft: 5}, + pl10: {paddingLeft: 10}, + pl20: {paddingLeft: 20}, + pt2: {paddingTop: 2}, + pt5: {paddingTop: 5}, + pt10: {paddingTop: 10}, + pt20: {paddingTop: 20}, + pb2: {paddingBottom: 2}, + pb5: {paddingBottom: 5}, + pb10: {paddingBottom: 10}, + pb20: {paddingBottom: 20}, + + // flex + flexRow: {flexDirection: 'row'}, + flexCol: {flexDirection: 'column'}, + flex1: {flex: 1}, + alignCenter: {alignItems: 'center'}, + alignBaseline: {alignItems: 'baseline'}, + + // position + absolute: {position: 'absolute'}, + + // dimensions + w100pct: {width: '100%'}, + h100pct: {height: '100%'}, + + // text align + textLeft: {textAlign: 'left'}, + textCenter: {textAlign: 'center'}, + textRight: {textAlign: 'right'}, + + // colors + white: {color: colors.white}, + black: {color: colors.black}, + + gray1: {color: colors.gray1}, + gray2: {color: colors.gray2}, + gray3: {color: colors.gray3}, + gray4: {color: colors.gray4}, + gray5: {color: colors.gray5}, + + blue1: {color: colors.blue1}, + blue2: {color: colors.blue2}, + blue3: {color: colors.blue3}, + blue4: {color: colors.blue4}, + blue5: {color: colors.blue5}, + + red1: {color: colors.red1}, + red2: {color: colors.red2}, + red3: {color: colors.red3}, + red4: {color: colors.red4}, + red5: {color: colors.red5}, + + pink1: {color: colors.pink1}, + pink2: {color: colors.pink2}, + pink3: {color: colors.pink3}, + pink4: {color: colors.pink4}, + pink5: {color: colors.pink5}, + + purple1: {color: colors.purple1}, + purple2: {color: colors.purple2}, + purple3: {color: colors.purple3}, + purple4: {color: colors.purple4}, + purple5: {color: colors.purple5}, + + green1: {color: colors.green1}, + green2: {color: colors.green2}, + green3: {color: colors.green3}, + green4: {color: colors.green4}, + green5: {color: colors.green5}, +}) + +export function lh( + theme: Theme, + type: TypographyVariant, + height: number, +): TextStyle { + return { + lineHeight: (theme.typography[type].fontSize || 16) * height, + } +} + +export function addStyle<T>( + base: StyleProp<T>, + addedStyle: StyleProp<T>, +): StyleProp<T> { + if (Array.isArray(base)) { + return base.concat([addedStyle]) + } + return [base, addedStyle] +} diff --git a/src/lib/themes.ts b/src/lib/themes.ts new file mode 100644 index 000000000..c544eebf2 --- /dev/null +++ b/src/lib/themes.ts @@ -0,0 +1,307 @@ +import {Platform} from 'react-native' +import type {Theme} from './ThemeContext' +import {colors} from './styles' + +export const defaultTheme: Theme = { + colorScheme: 'light', + palette: { + default: { + background: colors.white, + backgroundLight: colors.gray1, + text: colors.black, + textLight: colors.gray5, + textInverted: colors.white, + link: colors.blue3, + border: '#f0e9e9', + borderDark: '#e0d9d9', + icon: colors.gray4, + + // non-standard + textVeryLight: colors.gray4, + replyLine: colors.gray2, + replyLineDot: colors.gray3, + unreadNotifBg: '#ebf6ff', + postCtrl: '#71768A', + brandText: '#0066FF', + emptyStateIcon: '#B6B6C9', + }, + primary: { + background: colors.blue3, + backgroundLight: colors.blue2, + text: colors.white, + textLight: colors.blue0, + textInverted: colors.blue3, + link: colors.blue0, + border: colors.blue4, + borderDark: colors.blue5, + icon: colors.blue4, + }, + secondary: { + background: colors.green3, + backgroundLight: colors.green2, + text: colors.white, + textLight: colors.green1, + textInverted: colors.green4, + link: colors.green1, + border: colors.green4, + borderDark: colors.green5, + icon: colors.green4, + }, + inverted: { + background: colors.black, + backgroundLight: colors.gray6, + text: colors.white, + textLight: colors.gray3, + textInverted: colors.black, + link: colors.blue2, + border: colors.gray3, + borderDark: colors.gray2, + icon: colors.gray5, + }, + error: { + background: colors.red3, + backgroundLight: colors.red2, + text: colors.white, + textLight: colors.red1, + textInverted: colors.red3, + link: colors.red1, + border: colors.red4, + borderDark: colors.red5, + icon: colors.red4, + }, + }, + shapes: { + button: { + // TODO + }, + bigButton: { + // TODO + }, + smallButton: { + // TODO + }, + }, + typography: { + 'xl-thin': { + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '300', + }, + xl: { + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '400', + }, + 'xl-medium': { + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '500', + }, + 'xl-bold': { + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '700', + }, + 'xl-heavy': { + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '800', + }, + 'lg-thin': { + fontSize: 16, + letterSpacing: 0.25, + fontWeight: '300', + }, + lg: { + fontSize: 16, + letterSpacing: 0.25, + fontWeight: '400', + }, + 'lg-medium': { + fontSize: 16, + letterSpacing: 0.25, + fontWeight: '500', + }, + 'lg-bold': { + fontSize: 16, + letterSpacing: 0.25, + fontWeight: '700', + }, + 'lg-heavy': { + fontSize: 16, + letterSpacing: 0.25, + fontWeight: '800', + }, + 'md-thin': { + fontSize: 15, + letterSpacing: 0.25, + fontWeight: '300', + }, + md: { + fontSize: 15, + letterSpacing: 0.25, + fontWeight: '400', + }, + 'md-medium': { + fontSize: 15, + letterSpacing: 0.25, + fontWeight: '500', + }, + 'md-bold': { + fontSize: 15, + letterSpacing: 0.25, + fontWeight: '700', + }, + 'md-heavy': { + fontSize: 15, + letterSpacing: 0.25, + fontWeight: '800', + }, + 'sm-thin': { + fontSize: 14, + letterSpacing: 0.25, + fontWeight: '300', + }, + sm: { + fontSize: 14, + letterSpacing: 0.25, + fontWeight: '400', + }, + 'sm-medium': { + fontSize: 14, + letterSpacing: 0.25, + fontWeight: '500', + }, + 'sm-bold': { + fontSize: 14, + letterSpacing: 0.25, + fontWeight: '700', + }, + 'sm-heavy': { + fontSize: 14, + letterSpacing: 0.25, + fontWeight: '800', + }, + 'xs-thin': { + fontSize: 13, + letterSpacing: 0.25, + fontWeight: '300', + }, + xs: { + fontSize: 13, + letterSpacing: 0.25, + fontWeight: '400', + }, + 'xs-medium': { + fontSize: 13, + letterSpacing: 0.25, + fontWeight: '500', + }, + 'xs-bold': { + fontSize: 13, + letterSpacing: 0.25, + fontWeight: '700', + }, + 'xs-heavy': { + fontSize: 13, + letterSpacing: 0.25, + fontWeight: '800', + }, + + 'title-2xl': { + fontSize: 34, + letterSpacing: 0.25, + fontWeight: '500', + }, + 'title-xl': { + fontSize: 28, + letterSpacing: 0.25, + fontWeight: '500', + }, + 'title-lg': { + fontSize: 22, + fontWeight: '500', + }, + title: { + fontWeight: '500', + fontSize: 20, + letterSpacing: 0.15, + }, + 'title-sm': { + fontWeight: 'bold', + fontSize: 17, + letterSpacing: 0.15, + }, + 'post-text': { + fontSize: 16, + letterSpacing: 0.2, + fontWeight: '400', + }, + 'post-text-lg': { + fontSize: 22, + letterSpacing: 0.4, + fontWeight: '400', + }, + 'button-lg': { + fontWeight: '500', + fontSize: 18, + letterSpacing: 0.5, + }, + button: { + fontWeight: '500', + fontSize: 14, + letterSpacing: 0.5, + }, + mono: { + fontSize: 14, + fontFamily: Platform.OS === 'android' ? 'monospace' : 'Courier New', + }, + }, +} + +export const darkTheme: Theme = { + ...defaultTheme, + colorScheme: 'dark', + palette: { + ...defaultTheme.palette, + default: { + background: colors.gray8, + backgroundLight: colors.gray6, + text: colors.white, + textLight: colors.gray3, + textInverted: colors.black, + link: colors.blue3, + border: colors.gray6, + borderDark: colors.gray5, + icon: colors.gray4, + + // non-standard + textVeryLight: colors.gray4, + replyLine: colors.gray5, + replyLineDot: colors.gray6, + unreadNotifBg: colors.blue5, + postCtrl: '#61657A', + brandText: '#0085ff', + emptyStateIcon: colors.gray4, + }, + primary: { + ...defaultTheme.palette.primary, + textInverted: colors.blue2, + }, + secondary: { + ...defaultTheme.palette.secondary, + textInverted: colors.green2, + }, + inverted: { + background: colors.white, + backgroundLight: colors.gray2, + text: colors.black, + textLight: colors.gray5, + textInverted: colors.white, + link: colors.blue3, + border: colors.gray3, + borderDark: colors.gray4, + icon: colors.gray1, + }, + }, +} diff --git a/src/lib/type-guards.ts b/src/lib/type-guards.ts new file mode 100644 index 000000000..8fe651ffb --- /dev/null +++ b/src/lib/type-guards.ts @@ -0,0 +1,14 @@ +export function isObj(v: unknown): v is Record<string, unknown> { + return !!v && typeof v === 'object' +} + +export function hasProp<K extends PropertyKey>( + data: object, + prop: K, +): data is Record<K, unknown> { + return prop in data +} + +export function isStrArray(v: unknown): v is string[] { + return Array.isArray(v) && v.every(item => typeof item === 'string') +} |