Merge main into the Web PR (#230)

* Update to RN 71.1.0 (#100)

* Update to RN 71

* Adds missing lint plugin

* Add missing native changes

* Bump @atproto/api@0.0.7 (#112)

* Image not loading on swipe (#114)

* Adds prefetching to images

* Adds image prefetch

* bugfix for images not showing on swipe

* Fixes prefetch bug

* Update src/view/com/util/PostEmbeds.tsx

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* Fixes to session management (#117)

* Update session-management to solve incorrectly dropped sessions

* Reset the nav on account switch

* Reset the feed on me.load()

* Update tests to reflect new account-switching behavior

* Increase max image resolutions and sizes (#118)

* Slightly increase the hitslop for post controls

* Fix character counter color in dark mode

* Update login to use new session.create api, which enables email login (close #93) (#119)

* Replaces the alert with dropdown for profile image and banner (#123)

* replaces the alert with dropdown for profile image and banner

* lint

* Fix to ordering of images in the embed grid (#121)

* Add explicit link-embed controls to the composer (#120)

* Add explicit link-embed controls

* Update the target rez/size of link embed thumbs

* Remove the alert before publishing without a link card

* [Draft] Fixes image failing on reupload issue (#128)

* Fixes image failing on reupload issue

* Use tmp folder instead of documents

* lint

* Image performance improvements (#126)

* Switch out most images for FastImage

* Add image loading placeholders

* Fix tests

* Collection of fixes to list rendering (#127)

* Fix bug that caused endless spinners in profile feeds

* Bundle fetches of suggested actors into one update

* Fixes to suggested follow rendering

* Fix missing replacement of flex:1 to height:100

* Fixes to navigation swipes (#129)

* Nav swipe: increase the distance traveled in response to gesture movement.

This causes swipes to feel faster and more responsive.

* Fix: fully clamp the swipe against the edge

* Improve the performance of swipes by skipping the interaction manager

* Adds dark mode to the edit screen (#130)

* Adds dark mode to edit screen

* lint

* lint

* lint

* Reduce render cost of post controls and improve perceived responsiveness (#132)

* Move post control animations into conditional render and increase perceived responsiveness

* Remove log

* Adds dark mode to the dropdown (#131)

* Adds dark mode to the bottom sheet

* Make background button lighter (like before)

* lint

* Fix bug in lightbox rendering (#133)

* Fix layout in onboarding to not overflow the footer

* Configure feed FlatList (removeClippedSubviews=true) to improve scroll performance (#136)

* Disable like/repost animations to see if theyre causing #135 (#137)

* Composer: mention tagging now works in middle of text (close #105) (#139)

* Implement account deletion (#141)

* Fix photo & camera permission management (#140)

* Check photo & camera perms and alert the user if not available (close #64)

- Adds perms checks with a prompt to update settings if needed
- Moves initial access of photos in the composer so that the initial prompt
  occurs at an intuitive time.

* Add react-native-permissions test mock

* Fix issue causing multiple access requests

* Use longer var names

* Update podfile.lock

* Lint fix

* Move photo perm request in composer to the gallery btn instead of when the carousel is opened

* Adds more tracking all around the app (#142)

* Adds more tracking all around the app

* more events

* lint

* using better analytics naming

* missed file

* more fixes

* Calculate image aspect ratio on load (#146)

* Calculate image aspect ratio on load

* Move aspect ratio bounds to constants

* Adds detox testing and instructions (#147)

* Adds detox testing and instructions

* lint

* lint

* Error cleanup (close #79) (#148)

* Avoid surfacing errors to the user when it's not critical

* Remove now-unused GetAssertionsView

* Apply cleanError() consistently

* Give a better error message for Upstream Failures (http status 502)

* Hide errors in notifications because they're not useful

* More e2e tests (create account) (#150)

* Adds respots under the 'post' tab under profile (#158)

* Adds dark mode to delete account screen (#159)

* 87 dark mode edit profile (#162)

* Adds dark mode to delete account screen

* Adds one more missed darkmode

* more fixes

* Remove fallback gradient on external links without thumbs (#164)

* Remove fallback gradient on external links without thumbs

* Remove fallback gradient on external links without thumbs in the composer preview

* Fix refresh behavior around a series of models (repost, graph, vote) (#163)

* Fix refresh behavior around a series of models (repost, graph, vote)

* Fix cursor behavior in reposted-by view

* Fixes issue where retrying on image upload fails (#166)

* Fixes issue where retrying on image upload fails

* Lint, longer test time

* Longer waitfor time in tests

* even longer timeout

* longer timeout

* missed file

* Update src/view/com/composer/ComposePost.tsx

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* Update src/view/com/composer/ComposePost.tsx

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* 154 cached image profile (#167)

* Fixes issue where retrying on image upload fails

* Lint, longer test time

* Longer waitfor time in tests

* even longer timeout

* longer timeout

* missed file

* Fixes image cache error on second try for profile screen

* lint

* lint

* lint

* Refactor session management to use a new "Agent" API (#165)

* Add the atp-agent implementation (temporarily in this repo)

* Rewrite all session & API management to use the new atp-agent

* Update tests for the atp-agent refactor

* Refactor management of session-related state. Includes:
- More careful management of when state is cleared or fetched
- Debug logging to help trace future issues
- Clearer APIs overall

* Bubble session-expiration events to the user and display a toast to explain

* Switch to the new @atproto/api@0.1.0

* Minor aesthetic cleanup in SessionModel

* Wire up ReportAccount and ReportPost (#168)

* Fixes embeds for youtube channels (#169)

* Bump app ios version to 1.1 (needed after app store submission)

* Fix potential issues with promise guards when an error occurs (#170)

* Refactor models to use bundleAsync and lock regions (#171)

* Fix to an edge case with feed re-ordering for threads (#172)

* 151 fix youtube channel embed (#173)

* Fixes embeds for youtube channels

* Tests for youtube extract meta

* lint

* Add 'doesnt use non-exempt encryption' to ios config

* Rework the search UI and add  (#174)

* Add search tab and move icon to footer

* Remove subtitles from view header

* Remove unused code

* Clean up UI of search screen

* Search: give better user feedback to UI state and add a cancel button

* Add WhoToFollow section to search

* Add a temporary SuggestedPosts solution using the patented 'bsky team algo'

* Trigger reload of suggested content in search on open

* Wait five min between reloading discovery content

* Reduce weight of solid search icon in footer

* Fix lint

* Fix tests

* 151 feat youtube embed iframe (#176)

* youtube embed iframe temp commit

* Fixes styling and code cleanup

* lint

* Now clicking between the pause and settings button doesn't trigger the parent

* use modest branding (less yt logos)

* Stop playing the video once there's a navigation event

* Make sure the iframe is unmounted on any navigation event

* fixes tests

* lint

* Add scroll-to-top for all screens (#177)

* Adds hardcoded suggested list (#178)

* Adds hardcoded suggested list

* Update suggested-actors-view to support page sizes smaller than the hardcoded list

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* more robust centering of the play button (#181)

Co-authored-by: Aryan Goharzad <arrygoo@gmail.com>

* Bundle of UI modifications (#175)

* Adjust visual balance of SuggestedPosts and WhoToFollow

* Fix bug in the discovery load trigger

* Adjust search header aesthetic and have it scroll away

* More visual balance tweaks on the search page

* Even more visual balance tweaks on the search page

* Hide the footer on scroll in search

* Ditch the composer prompt buttons in the home feed

* Center the view header title

* Hide header on scroll on the home feed

* Fix e2e tests

* Fix home feed positioning (closes #189) (#195)

* Fix home feed positioning for floating header

* Fix positioning of errors in home feed

* Fix lint

* Don't show new-content notification for reposts (close #179) (#197)

* Show the splash screen during session resumption (close #186) (#199)

* Fix to suggested follows: chunk the hardcoded fetches to 25 at a time (close #196) (#198)

* UI updates to the floating action button (#201)

* Update FAB to use a plus icon and not drop shadow

* Update FAB positioning to be more consistent in different shell modes

* Animate the FAB's repositioning

* Remove the 'loading' placeholder from images as it degraded feed perf (#202)

* Remove the 'loading' placeholder from images as it degraded feed perf

* Remove references

* Fix RN bug that causes home feed not to load more; also fix home feed load view. (#208)

RN has a bug where rendering a flatlist with an empty array appears to break its
virtual list windowing behaviors. See https://stackoverflow.com/a/67873596

* Only give the loading spinner on the home feed during PTR (#207)

(cherry picked from commit b7a5da12fdfacef74873b5cf6d75f20d259bde0e)

* Implement our own lifecycle tracking to ensure it never fires while the app is backgrounded (close #193) (#211)

* Push notification fixes (#210)

* Fix to when screen analytics events are firing

* Fix: dont trigger update state when backgrounded

* Small fix to notifee API usage

* Fix: properly load notification info for push card

* Add feedback link to main menu (close #191) (#212)

* Add "follows you" information and sync follow state between views (#215)

* Bump @atproto/api@0.1.2 and update API usage

* Add 'follows you' pill to profile header (close #110)

* Add 'follows you' to followers and follows (close #103)

* Update reposted-by and liked-by views to use the same components as followers and following

* Create a local follows cache MyFollowsModel to keep views in sync (close #205)

* Add incremental hydration to the MyFollows model

* Fix tests

* Update deps

* Fix lint

* Fix to paginated fetches

* Fix reference

* Fix potential state-desync issue

* Fixes to notifications (#216)

* Improve push-notification for follows

* Refresh notifications on screen open (close #214)

* Avoid showing loader more than needed in post threads

* Refactor notification polling to handle view-state more effectively

* Delete a bunch of tests taht werent adding value

* Remove the accounts integration test; we'll use the e2e test instead

* Load latest in notifications when the screen is open rather than full refresh

* Randomize hard-coded suggested follows (#226)

* Ensure follows are loaded before filtering hardcoded suggestions

* Randomize hard-coded suggested profiles (close #219)

* Sanitizes posts on publish and render (#217)

* Sanatizes posts on publish and render

* lint

* lint and added sanitize to thread view as well

* adjusts indices based on replaced text

* Woops, fixes a bug

* bugfix + cleanup

* comment

* lint

* move sanitize text to later in the flow

* undo changes to compose post

* Add RichText library building upon the sanitizePost library method

* Add lodash.clonedeep dep

* Switch to RichText processing on record load & render

* Fix lint

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* A group of notifications fixes (#227)

* Fix: don't group together notifications that can't visually be grouped (close #221)

* Mark all notifications read on PTR

* Small optimization: useCallback and useMemo in posts feed

* Add loading spinner to footer of notifications (close #222)

* Fix to scrolling to posts within a thread (#228)

* Fix: render the entire thread at start so that scrollToIndex works always (close #270)

* Visual fixes to thread 'load more'

* A few small perf improvements to thread rendering

* Fix lint

* 1.2

* Remove unused logger lib

* Remove state-mock

* Type fixes

* Reorganize the folder structure for lib and switch to typescript path aliases

* Move build-flags into lib

* Move to the state path alias

* Add view path alias

* Fix lint

* iOS build fixes

* Wrap analytics in native/web splitter and re-enable in all view code

* Add web version of react-native-webview

* Add web split for version number

* Fix BlurView import for web

* Add web split for fastimage

* Create web split for permissions lib

* Fix for web high priority images

---------

Co-authored-by: Aryan Goharzad <arrygoo@gmail.com>
This commit is contained in:
Paul Frazee 2023-02-22 14:23:57 -06:00 committed by GitHub
parent 7916b26aad
commit f28334739b
242 changed files with 8400 additions and 7454 deletions

95
src/lib/ThemeContext.tsx Normal file
View file

@ -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>
}

74
src/lib/analytics.tsx Normal file
View file

@ -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>
)
}

16
src/lib/analytics.web.tsx Normal file
View file

@ -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
}

View file

@ -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,
}
}

View file

@ -0,0 +1,4 @@
export function doPolyfill() {
// TODO needed? native fetch may work fine -prf
// AtpApi.xrpc.fetch = fetchHandler
}

201
src/lib/api/index.ts Normal file
View file

@ -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,
})
}

4
src/lib/app-info.ts Normal file
View file

@ -0,0 +1,4 @@
import VersionNumber from 'react-native-version-number'
export const appVersion = VersionNumber.appVersion
export const buildVersion = VersionNumber.buildVersion

3
src/lib/app-info.web.ts Normal file
View file

@ -0,0 +1,3 @@
// TODO
export const appVersion = 'TODO'
export const buildVersion = 'TODO'

5
src/lib/assets.native.ts Normal file
View file

@ -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')

10
src/lib/assets.ts Normal file
View file

@ -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'}

24
src/lib/async/bundle.ts Normal file
View file

@ -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
}
}
}

18
src/lib/bg-scheduler.ts Normal file
View file

@ -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)
}

View file

@ -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
}

2
src/lib/build-flags.ts Normal file
View file

@ -0,0 +1,2 @@
export const LOGIN_INCLUDE_DEV_SERVERS = true
export const TABS_ENABLED = false

65
src/lib/constants.ts Normal file
View file

@ -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`,
)

View file

@ -1,4 +0,0 @@
export function isNetworkError(e: unknown) {
const str = String(e)
return str.includes('Abort') || str.includes('Network request failed')
}

View file

@ -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
}

View file

@ -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
}
}
}

View file

@ -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,
},
}
}

529
src/lib/icons.tsx Normal file
View file

@ -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>
)
}

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -1,5 +1,5 @@
import {extractTwitterMeta} from './extractTwitterMeta'
import {extractYoutubeMeta} from './extractYoutubeMeta'
import {extractTwitterMeta} from './twitter'
import {extractYoutubeMeta} from './youtube'
interface ExtractHtmlMetaInput {
html: string

View file

@ -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,

View file

@ -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
}

74
src/lib/notifee.ts Normal file
View file

@ -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)
}

61
src/lib/permissions.ts Normal file
View file

@ -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
}

View file

@ -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)
}

52
src/lib/storage.ts Normal file
View file

@ -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 {}
}

View file

@ -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
}

23
src/lib/strings/errors.ts Normal file
View file

@ -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')
}

View file

@ -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}`
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}
}

29
src/lib/strings/time.ts Normal file
View file

@ -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()
}
}

View file

@ -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
}

218
src/lib/styles.ts Normal file
View file

@ -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]
}

307
src/lib/themes.ts Normal file
View file

@ -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,
},
},
}

14
src/lib/type-guards.ts Normal file
View file

@ -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')
}