From f28334739b107f3e9f7b6ca2670778dba280600d Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 22 Feb 2023 14:23:57 -0600 Subject: 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 * 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 * Update src/view/com/composer/ComposePost.tsx Co-authored-by: Paul Frazee --------- Co-authored-by: Paul Frazee * 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 * more robust centering of the play button (#181) Co-authored-by: Aryan Goharzad * 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 * 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 --- src/lib/ThemeContext.tsx | 95 +++ src/lib/analytics.tsx | 74 ++ src/lib/analytics.web.tsx | 16 + src/lib/api/api-polyfill.ts | 79 ++ src/lib/api/api-polyfill.web.ts | 4 + src/lib/api/index.ts | 201 +++++ src/lib/app-info.ts | 4 + src/lib/app-info.web.ts | 3 + src/lib/assets.native.ts | 5 + src/lib/assets.ts | 10 + src/lib/async/bundle.ts | 24 + src/lib/bg-scheduler.ts | 18 + src/lib/bg-scheduler.web.ts | 13 + src/lib/build-flags.ts | 2 + src/lib/constants.ts | 65 ++ src/lib/errors.ts | 4 - src/lib/extractBskyMeta.ts | 99 --- src/lib/extractHtmlMeta.ts | 71 -- src/lib/extractTwitterMeta.ts | 20 - src/lib/extractYoutubeMeta.ts | 26 - src/lib/hooks/useAnimatedValue.ts | 12 + src/lib/hooks/useOnMainScroll.ts | 25 + src/lib/hooks/usePalette.ts | 48 ++ src/lib/icons.tsx | 529 +++++++++++++ src/lib/images.ts | 32 +- src/lib/images.web.ts | 5 +- src/lib/link-meta.ts | 1290 -------------------------------- src/lib/link-meta/bsky.ts | 99 +++ src/lib/link-meta/html.ts | 71 ++ src/lib/link-meta/link-meta.ts | 1290 ++++++++++++++++++++++++++++++++ src/lib/link-meta/twitter.ts | 20 + src/lib/link-meta/youtube.ts | 31 + src/lib/notifee.ts | 74 ++ src/lib/permissions.ts | 61 ++ src/lib/permissions.web.ts | 22 + src/lib/storage.ts | 52 ++ src/lib/strings.ts | 267 ------- src/lib/strings/errors.ts | 23 + src/lib/strings/handles.ts | 13 + src/lib/strings/helpers.ts | 17 + src/lib/strings/mention-manip.ts | 37 + src/lib/strings/rich-text-detection.ts | 107 +++ src/lib/strings/rich-text-sanitize.ts | 32 + src/lib/strings/rich-text.ts | 216 ++++++ src/lib/strings/time.ts | 29 + src/lib/strings/url-helpers.ts | 108 +++ src/lib/styles.ts | 218 ++++++ src/lib/themes.ts | 307 ++++++++ src/lib/type-guards.ts | 14 + 49 files changed, 4093 insertions(+), 1789 deletions(-) create mode 100644 src/lib/ThemeContext.tsx create mode 100644 src/lib/analytics.tsx create mode 100644 src/lib/analytics.web.tsx create mode 100644 src/lib/api/api-polyfill.ts create mode 100644 src/lib/api/api-polyfill.web.ts create mode 100644 src/lib/api/index.ts create mode 100644 src/lib/app-info.ts create mode 100644 src/lib/app-info.web.ts create mode 100644 src/lib/assets.native.ts create mode 100644 src/lib/assets.ts create mode 100644 src/lib/async/bundle.ts create mode 100644 src/lib/bg-scheduler.ts create mode 100644 src/lib/bg-scheduler.web.ts create mode 100644 src/lib/build-flags.ts create mode 100644 src/lib/constants.ts delete mode 100644 src/lib/errors.ts delete mode 100644 src/lib/extractBskyMeta.ts delete mode 100644 src/lib/extractHtmlMeta.ts delete mode 100644 src/lib/extractTwitterMeta.ts delete mode 100644 src/lib/extractYoutubeMeta.ts create mode 100644 src/lib/hooks/useAnimatedValue.ts create mode 100644 src/lib/hooks/useOnMainScroll.ts create mode 100644 src/lib/hooks/usePalette.ts create mode 100644 src/lib/icons.tsx delete mode 100644 src/lib/link-meta.ts create mode 100644 src/lib/link-meta/bsky.ts create mode 100644 src/lib/link-meta/html.ts create mode 100644 src/lib/link-meta/link-meta.ts create mode 100644 src/lib/link-meta/twitter.ts create mode 100644 src/lib/link-meta/youtube.ts create mode 100644 src/lib/notifee.ts create mode 100644 src/lib/permissions.ts create mode 100644 src/lib/permissions.web.ts create mode 100644 src/lib/storage.ts delete mode 100644 src/lib/strings.ts create mode 100644 src/lib/strings/errors.ts create mode 100644 src/lib/strings/handles.ts create mode 100644 src/lib/strings/helpers.ts create mode 100644 src/lib/strings/mention-manip.ts create mode 100644 src/lib/strings/rich-text-detection.ts create mode 100644 src/lib/strings/rich-text-sanitize.ts create mode 100644 src/lib/strings/rich-text.ts create mode 100644 src/lib/strings/time.ts create mode 100644 src/lib/strings/url-helpers.ts create mode 100644 src/lib/styles.ts create mode 100644 src/lib/themes.ts create mode 100644 src/lib/type-guards.ts (limited to 'src/lib') 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 + +export type ShapeName = 'button' | 'bigButton' | 'smallButton' +export type Shapes = Record + +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 + +export interface Theme { + colorScheme: ColorScheme + palette: Palette + shapes: Shapes + typography: Typography +} + +export interface ThemeProviderProps { + theme?: ColorScheme +} + +export const ThemeContext = createContext(defaultTheme) + +export const useTheme = () => useContext(ThemeContext) + +export const ThemeProvider: React.FC = ({ + theme, + children, +}) => { + const colorScheme = useColorScheme() + + const value = useMemo( + () => ((theme || colorScheme) === 'dark' ? darkTheme : defaultTheme), + [colorScheme, theme], + ) + + return {children} +} 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 ( + {children} + ) +} 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 + body: ArrayBuffer | undefined +} + +async function fetchHandler( + reqUri: string, + reqMethod: string, + reqHeaders: Record, + reqBody: any, +): Promise { + 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 = {} + 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, + 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: Args +) => Promise + +/** + * A helper which ensures that multiple calls to an async function + * only produces one in-flight request at a time. + */ +export function bundleAsync( + fn: BundledFn, +): BundledFn { + let promise: Promise | 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, + timeoutHandler: (taskId: string) => void, +): Promise { + 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, + _timeoutHandler: (taskId: string) => Promise, +): Promise { + // 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/extractBskyMeta.ts b/src/lib/extractBskyMeta.ts deleted file mode 100644 index e53036aec..000000000 --- a/src/lib/extractBskyMeta.ts +++ /dev/null @@ -1,99 +0,0 @@ -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 {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 -// remove once that's implemented -// -prf -export async function extractBskyMeta( - store: RootStoreModel, - url: string, -): Promise { - url = convertBskyAppUrlIfNeeded(url) - const route = matchRoute(url) - let meta: LinkMeta = { - likelyType: LikelyType.AtpData, - url, - title: route.defaultTitle, - } - - if (route.Com === Home) { - meta = { - ...meta, - title: 'Bluesky', - description: 'A new kind of social network', - } - } else if (route.Com === Search) { - meta = { - ...meta, - title: 'Search - Bluesky', - description: 'A new kind of social network', - } - } else if (route.Com === Notifications) { - meta = { - ...meta, - title: 'Notifications - Bluesky', - description: 'A new kind of social network', - } - } else if ( - route.Com === PostThread || - route.Com === PostUpvotedBy || - route.Com === PostRepostedBy - ) { - // post and post-related screens - const threadUri = makeRecordUri( - route.params.name, - 'app.bsky.feed.post', - route.params.rkey, - ) - const threadView = new PostThreadViewModel(store, { - uri: threadUri, - depth: 0, - }) - await threadView.setup().catch(_err => undefined) - const title = [ - route.Com === PostUpvotedBy - ? 'Likes on a post by' - : route.Com === PostRepostedBy - ? 'Reposts of a post by' - : 'Post by', - threadView.thread?.post.author.displayName || - threadView.thread?.post.author.handle || - 'a bluesky user', - ].join(' ') - meta = { - ...meta, - title, - description: threadView.thread?.postRecord?.text, - } - } else if ( - route.Com === Profile || - route.Com === ProfileFollowers || - route.Com === ProfileFollows - ) { - // profile and profile-related screens - const profile = await store.profiles.getProfile(route.params.name) - if (profile?.data) { - meta = { - ...meta, - title: profile.data.displayName || profile.data.handle, - description: profile.data.description, - } - } - } - - return meta -} diff --git a/src/lib/extractHtmlMeta.ts b/src/lib/extractHtmlMeta.ts deleted file mode 100644 index 70387f71d..000000000 --- a/src/lib/extractHtmlMeta.ts +++ /dev/null @@ -1,71 +0,0 @@ -import {extractTwitterMeta} from './extractTwitterMeta' -import {extractYoutubeMeta} from './extractYoutubeMeta' - -interface ExtractHtmlMetaInput { - html: string - hostname?: string - pathname?: string -} - -export const extractHtmlMeta = ({ - html, - hostname, - pathname, -}: ExtractHtmlMetaInput): Record => { - const htmlTitleRegex = /([^<]+)<\/title>/i - - let res: Record = {} - - const match = htmlTitleRegex.exec(html) - - if (match) { - res.title = match[1].trim() - } - - let metaMatch - let propMatch - const metaRe = /]+)>/gis - while ((metaMatch = metaRe.exec(html))) { - let propName - let propValue - const propRe = /(name|property|content)="([^"]+)"/gis - while ((propMatch = propRe.exec(metaMatch[1]))) { - if (propMatch[1] === 'content') { - propValue = propMatch[2] - } else { - propName = propMatch[2] - } - } - if (!propName || !propValue) { - continue - } - switch (propName?.trim()) { - case 'title': - case 'og:title': - case 'twitter:title': - res.title = propValue?.trim() - break - case 'description': - case 'og:description': - case 'twitter:description': - res.description = propValue?.trim() - break - case 'og:image': - case 'twitter:image': - res.image = propValue?.trim() - break - } - } - - const isYoutubeUrl = - hostname?.includes('youtube.') || hostname?.includes('youtu.be') - const isTwitterUrl = hostname?.includes('twitter.') - // Workaround for some websites not having a title or description in the meta tags in the initial serve - if (isYoutubeUrl) { - res = {...res, ...extractYoutubeMeta(html)} - } else if (isTwitterUrl && pathname) { - res = {...extractTwitterMeta({pathname})} - } - - return res -} diff --git a/src/lib/extractTwitterMeta.ts b/src/lib/extractTwitterMeta.ts deleted file mode 100644 index d785903c0..000000000 --- a/src/lib/extractTwitterMeta.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const extractTwitterMeta = ({ - pathname, -}: { - pathname: string -}): Record => { - const res = {title: 'Twitter'} - const parsedPathname = pathname.split('/') - if (parsedPathname.length <= 1 || parsedPathname[1].length <= 1) { - // Excluding one letter usernames as they're reserved by twitter for things like cases like twitter.com/i/articles/follows/-1675653703 - return res - } - const username = parsedPathname?.[1] - const isUserProfile = parsedPathname?.length === 2 - - res.title = isUserProfile - ? `@${username} on Twitter` - : `Tweet by @${username}` - - return res -} diff --git a/src/lib/extractYoutubeMeta.ts b/src/lib/extractYoutubeMeta.ts deleted file mode 100644 index 566e3be46..000000000 --- a/src/lib/extractYoutubeMeta.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const extractYoutubeMeta = (html: string): Record => { - const res: Record = {} - const youtubeTitleRegex = /"videoDetails":.*"title":"([^"]*)"/i - const youtubeDescriptionRegex = - /"videoDetails":.*"shortDescription":"([^"]*)"/i - const youtubeThumbnailRegex = /"videoDetails":.*"url":"(.*)(default\.jpg)/i - - const youtubeTitleMatch = youtubeTitleRegex.exec(html) - const youtubeDescriptionMatch = youtubeDescriptionRegex.exec(html) - const youtubeThumbnailMatch = youtubeThumbnailRegex.exec(html) - - if (youtubeTitleMatch && youtubeTitleMatch.length >= 1) { - res.title = decodeURI(youtubeTitleMatch[1]) - } - if (youtubeDescriptionMatch && youtubeDescriptionMatch.length >= 1) { - res.description = decodeURI(youtubeDescriptionMatch[1]).replace( - /\\n/g, - '\n', - ) - } - if (youtubeThumbnailMatch && youtubeThumbnailMatch.length >= 2) { - res.image = youtubeThumbnailMatch[1] + 'default.jpg' - } - - return res -} 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() + + 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, +) => void + +export function useOnMainScroll(store: RootStoreModel) { + let [lastY, setLastY] = useState(0) + let isMinimal = store.shell.minimalShellMode + return function onMainScroll(event: NativeSyntheticEvent) { + 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 + solid?: boolean +}) { + const DIM = 4 + const ARC = 2 + return ( + + + + + + + ) +} +export function GridIconSolid({style}: {style?: StyleProp}) { + return +} + +export function HomeIcon({ + style, + size, + strokeWidth = 4, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} + +export function HomeIconSolid({ + style, + size, +}: { + style?: StyleProp + size?: string | number +}) { + return ( + + + + ) +} + +// Copyright (c) 2020 Refactoring UI Inc. +// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE +export function MagnifyingGlassIcon({ + style, + size, + strokeWidth = 2, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} + +// https://github.com/Remix-Design/RemixIcon/blob/master/License +export function BellIcon({ + style, + size, +}: { + style?: StyleProp + size?: string | number +}) { + return ( + + + + + ) +} + +// https://github.com/Remix-Design/RemixIcon/blob/master/License +export function BellIconSolid({ + style, + size, +}: { + style?: StyleProp + size?: string | number +}) { + return ( + + + + + ) +} + +export function CogIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp + size?: string | number + strokeWidth: number +}) { + return ( + + + + + ) +} + +// Copyright (c) 2020 Refactoring UI Inc. +// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE +export function UserIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} + +// Copyright (c) 2020 Refactoring UI Inc. +// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE +export function UserGroupIcon({ + style, + size, +}: { + style?: StyleProp + size?: string | number +}) { + return ( + + + + ) +} + +export function RepostIcon({ + style, + size = 24, + strokeWidth = 1.5, +}: { + style?: StyleProp + size?: string | number + strokeWidth: number +}) { + return ( + + + + ) +} + +// 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 + size?: string | number + strokeWidth: number +}) { + return ( + + + + ) +} + +// 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 + size?: string | number +}) { + return ( + + + + ) +} + +export function UpIcon({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp + size?: string | number + strokeWidth: number +}) { + return ( + + + + ) +} + +export function UpIconSolid({ + style, + size, +}: { + style?: StyleProp + size?: string | number +}) { + return ( + + + + ) +} + +export function DownIcon({ + style, + size, +}: { + style?: StyleProp + size?: string | number +}) { + return ( + + + + ) +} + +export function DownIconSolid({ + style, + size, +}: { + style?: StyleProp + size?: string | number +}) { + return ( + + + + ) +} + +// Copyright (c) 2020 Refactoring UI Inc. +// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE +export function CommentBottomArrow({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + let color = 'currentColor' + if ( + style && + typeof style === 'object' && + 'color' in style && + typeof style.color === 'string' + ) { + color = style.color + } + return ( + + + + ) +} + +export function SquareIcon({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} + +export function RectWideIcon({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} + +export function RectTallIcon({ + style, + size, + strokeWidth = 1.3, +}: { + style?: StyleProp + size?: string | number + strokeWidth?: number +}) { + return ( + + + + ) +} 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/link-meta.ts b/src/lib/link-meta.ts deleted file mode 100644 index 2826e969a..000000000 --- a/src/lib/link-meta.ts +++ /dev/null @@ -1,1290 +0,0 @@ -import he from 'he' -import {isBskyAppUrl} from './strings' -import {RootStoreModel} from '../state' -import {extractBskyMeta} from './extractBskyMeta' -import {extractHtmlMeta} from './extractHtmlMeta' - -export enum LikelyType { - HTML, - Text, - Image, - Video, - Audio, - AtpData, - Other, -} - -export interface LinkMeta { - error?: string - likelyType: LikelyType - url: string - title?: string - description?: string - image?: string -} - -export async function getLinkMeta( - store: RootStoreModel, - url: string, - timeout = 5e3, -): Promise { - if (isBskyAppUrl(url)) { - return extractBskyMeta(store, url) - } - - let urlp - try { - urlp = new URL(url) - } catch (e) { - return { - error: 'Invalid URL', - likelyType: LikelyType.Other, - url, - } - } - const likelyType = getLikelyType(urlp) - const meta: LinkMeta = { - likelyType, - url, - } - if (likelyType !== LikelyType.HTML) { - return meta - } - - try { - const controller = new AbortController() - const to = setTimeout(() => controller.abort(), timeout || 5e3) - const httpRes = await fetch(url, { - headers: {accept: 'text/html'}, - signal: controller.signal, - }) - const httpResBody = await httpRes.text() - clearTimeout(to) - const httpResMeta = extractHtmlMeta({ - html: httpResBody, - hostname: urlp?.hostname, - pathname: urlp?.pathname, - }) - meta.title = httpResMeta.title ? he.decode(httpResMeta.title) : undefined - meta.description = httpResMeta.description - ? he.decode(httpResMeta.description) - : undefined - meta.image = httpResMeta.image - } catch (e) { - // failed - console.error(e) - meta.error = 'Failed to fetch link' - } - - return meta -} - -export function getLikelyType(url: URL | string): LikelyType { - if (typeof url === 'string') { - try { - url = new URL(url) - } catch (e) { - return LikelyType.Other - } - } - - const ext = url.pathname.split('.').pop() || '' - if (ext === 'html' || ext === 'htm') { - return LikelyType.HTML - } - const mimeType = EXT_MIME_TYPES[ext] - if (!mimeType) { - return LikelyType.HTML - } - if (mimeType.startsWith('text/')) { - return LikelyType.Text - } - if (mimeType.startsWith('image/')) { - return LikelyType.Image - } - if (mimeType.startsWith('video/')) { - return LikelyType.Video - } - if (mimeType.startsWith('audio/')) { - return LikelyType.Audio - } - return LikelyType.Other -} - -const EXT_MIME_TYPES: Record = { - '123': 'application/vnd.lotus-1-2-3', - '1km': 'application/vnd.1000minds.decision-model+xml', - '3dml': 'text/vnd.in3d.3dml', - '3ds': 'image/x-3ds', - '3g2': 'video/3gpp2', - '3gp': 'video/3gpp', - '3gpp': 'video/3gpp', - '3mf': 'model/3mf', - '7z': 'application/x-7z-compressed', - aab: 'application/x-authorware-bin', - aac: 'audio/x-aac', - aam: 'application/x-authorware-map', - aas: 'application/x-authorware-seg', - abw: 'application/x-abiword', - ac: 'application/vnd.nokia.n-gage.ac+xml', - acc: 'application/vnd.americandynamics.acc', - ace: 'application/x-ace-compressed', - acu: 'application/vnd.acucobol', - acutc: 'application/vnd.acucorp', - adp: 'audio/adpcm', - aep: 'application/vnd.audiograph', - afm: 'application/x-font-type1', - afp: 'application/vnd.ibm.modcap', - age: 'application/vnd.age', - ahead: 'application/vnd.ahead.space', - ai: 'application/postscript', - aif: 'audio/x-aiff', - aifc: 'audio/x-aiff', - aiff: 'audio/x-aiff', - air: 'application/vnd.adobe.air-application-installer-package+zip', - ait: 'application/vnd.dvb.ait', - ami: 'application/vnd.amiga.ami', - amr: 'audio/amr', - apk: 'application/vnd.android.package-archive', - apng: 'image/apng', - appcache: 'text/cache-manifest', - application: 'application/x-ms-application', - apr: 'application/vnd.lotus-approach', - arc: 'application/x-freearc', - arj: 'application/x-arj', - asc: 'application/pgp-signature', - asf: 'video/x-ms-asf', - asm: 'text/x-asm', - aso: 'application/vnd.accpac.simply.aso', - asx: 'video/x-ms-asf', - atc: 'application/vnd.acucorp', - atom: 'application/atom+xml', - atomcat: 'application/atomcat+xml', - atomdeleted: 'application/atomdeleted+xml', - atomsvc: 'application/atomsvc+xml', - atx: 'application/vnd.antix.game-component', - au: 'audio/basic', - avi: 'video/x-msvideo', - avif: 'image/avif', - aw: 'application/applixware', - azf: 'application/vnd.airzip.filesecure.azf', - azs: 'application/vnd.airzip.filesecure.azs', - azv: 'image/vnd.airzip.accelerator.azv', - azw: 'application/vnd.amazon.ebook', - b16: 'image/vnd.pco.b16', - bat: 'application/x-msdownload', - bcpio: 'application/x-bcpio', - bdf: 'application/x-font-bdf', - bdm: 'application/vnd.syncml.dm+wbxml', - bdoc: 'application/x-bdoc', - bed: 'application/vnd.realvnc.bed', - bh2: 'application/vnd.fujitsu.oasysprs', - bin: 'application/octet-stream', - blb: 'application/x-blorb', - blorb: 'application/x-blorb', - bmi: 'application/vnd.bmi', - bmml: 'application/vnd.balsamiq.bmml+xml', - bmp: 'image/x-ms-bmp', - book: 'application/vnd.framemaker', - box: 'application/vnd.previewsystems.box', - boz: 'application/x-bzip2', - bpk: 'application/octet-stream', - bsp: 'model/vnd.valve.source.compiled-map', - btif: 'image/prs.btif', - buffer: 'application/octet-stream', - bz: 'application/x-bzip', - bz2: 'application/x-bzip2', - c: 'text/x-c', - c11amc: 'application/vnd.cluetrust.cartomobile-config', - c11amz: 'application/vnd.cluetrust.cartomobile-config-pkg', - c4d: 'application/vnd.clonk.c4group', - c4f: 'application/vnd.clonk.c4group', - c4g: 'application/vnd.clonk.c4group', - c4p: 'application/vnd.clonk.c4group', - c4u: 'application/vnd.clonk.c4group', - cab: 'application/vnd.ms-cab-compressed', - caf: 'audio/x-caf', - cap: 'application/vnd.tcpdump.pcap', - car: 'application/vnd.curl.car', - cat: 'application/vnd.ms-pki.seccat', - cb7: 'application/x-cbr', - cba: 'application/x-cbr', - cbr: 'application/x-cbr', - cbt: 'application/x-cbr', - cbz: 'application/x-cbr', - cc: 'text/x-c', - cco: 'application/x-cocoa', - cct: 'application/x-director', - ccxml: 'application/ccxml+xml', - cdbcmsg: 'application/vnd.contact.cmsg', - cdf: 'application/x-netcdf', - cdfx: 'application/cdfx+xml', - cdkey: 'application/vnd.mediastation.cdkey', - cdmia: 'application/cdmi-capability', - cdmic: 'application/cdmi-container', - cdmid: 'application/cdmi-domain', - cdmio: 'application/cdmi-object', - cdmiq: 'application/cdmi-queue', - cdx: 'chemical/x-cdx', - cdxml: 'application/vnd.chemdraw+xml', - cdy: 'application/vnd.cinderella', - cer: 'application/pkix-cert', - cfs: 'application/x-cfs-compressed', - cgm: 'image/cgm', - chat: 'application/x-chat', - chm: 'application/vnd.ms-htmlhelp', - chrt: 'application/vnd.kde.kchart', - cif: 'chemical/x-cif', - cii: 'application/vnd.anser-web-certificate-issue-initiation', - cil: 'application/vnd.ms-artgalry', - cjs: 'application/node', - cla: 'application/vnd.claymore', - class: 'application/java-vm', - clkk: 'application/vnd.crick.clicker.keyboard', - clkp: 'application/vnd.crick.clicker.palette', - clkt: 'application/vnd.crick.clicker.template', - clkw: 'application/vnd.crick.clicker.wordbank', - clkx: 'application/vnd.crick.clicker', - clp: 'application/x-msclip', - cmc: 'application/vnd.cosmocaller', - cmdf: 'chemical/x-cmdf', - cml: 'chemical/x-cml', - cmp: 'application/vnd.yellowriver-custom-menu', - cmx: 'image/x-cmx', - cod: 'application/vnd.rim.cod', - coffee: 'text/coffeescript', - com: 'application/x-msdownload', - conf: 'text/plain', - cpio: 'application/x-cpio', - cpp: 'text/x-c', - cpt: 'application/mac-compactpro', - crd: 'application/x-mscardfile', - crl: 'application/pkix-crl', - crt: 'application/x-x509-ca-cert', - crx: 'application/x-chrome-extension', - cryptonote: 'application/vnd.rig.cryptonote', - csh: 'application/x-csh', - csl: 'application/vnd.citationstyles.style+xml', - csml: 'chemical/x-csml', - csp: 'application/vnd.commonspace', - css: 'text/css', - cst: 'application/x-director', - csv: 'text/csv', - cu: 'application/cu-seeme', - curl: 'text/vnd.curl', - cww: 'application/prs.cww', - cxt: 'application/x-director', - cxx: 'text/x-c', - dae: 'model/vnd.collada+xml', - daf: 'application/vnd.mobius.daf', - dart: 'application/vnd.dart', - dataless: 'application/vnd.fdsn.seed', - davmount: 'application/davmount+xml', - dbf: 'application/vnd.dbf', - dbk: 'application/docbook+xml', - dcr: 'application/x-director', - dcurl: 'text/vnd.curl.dcurl', - dd2: 'application/vnd.oma.dd2+xml', - ddd: 'application/vnd.fujixerox.ddd', - ddf: 'application/vnd.syncml.dmddf+xml', - dds: 'image/vnd.ms-dds', - deb: 'application/x-debian-package', - def: 'text/plain', - deploy: 'application/octet-stream', - der: 'application/x-x509-ca-cert', - dfac: 'application/vnd.dreamfactory', - dgc: 'application/x-dgc-compressed', - dic: 'text/x-c', - dir: 'application/x-director', - dis: 'application/vnd.mobius.dis', - 'disposition-notification': 'message/disposition-notification', - dist: 'application/octet-stream', - distz: 'application/octet-stream', - djv: 'image/vnd.djvu', - djvu: 'image/vnd.djvu', - dll: 'application/x-msdownload', - dmg: 'application/x-apple-diskimage', - dmp: 'application/vnd.tcpdump.pcap', - dms: 'application/octet-stream', - dna: 'application/vnd.dna', - doc: 'application/msword', - docm: 'application/vnd.ms-word.document.macroenabled.12', - docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - dot: 'application/msword', - dotm: 'application/vnd.ms-word.template.macroenabled.12', - dotx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', - dp: 'application/vnd.osgi.dp', - dpg: 'application/vnd.dpgraph', - dra: 'audio/vnd.dra', - drle: 'image/dicom-rle', - dsc: 'text/prs.lines.tag', - dssc: 'application/dssc+der', - dtb: 'application/x-dtbook+xml', - dtd: 'application/xml-dtd', - dts: 'audio/vnd.dts', - dtshd: 'audio/vnd.dts.hd', - dump: 'application/octet-stream', - dvb: 'video/vnd.dvb.file', - dvi: 'application/x-dvi', - dwd: 'application/atsc-dwd+xml', - dwf: 'model/vnd.dwf', - dwg: 'image/vnd.dwg', - dxf: 'image/vnd.dxf', - dxp: 'application/vnd.spotfire.dxp', - dxr: 'application/x-director', - ear: 'application/java-archive', - ecelp4800: 'audio/vnd.nuera.ecelp4800', - ecelp7470: 'audio/vnd.nuera.ecelp7470', - ecelp9600: 'audio/vnd.nuera.ecelp9600', - ecma: 'application/ecmascript', - edm: 'application/vnd.novadigm.edm', - edx: 'application/vnd.novadigm.edx', - efif: 'application/vnd.picsel', - ei6: 'application/vnd.pg.osasli', - elc: 'application/octet-stream', - emf: 'image/emf', - eml: 'message/rfc822', - emma: 'application/emma+xml', - emotionml: 'application/emotionml+xml', - emz: 'application/x-msmetafile', - eol: 'audio/vnd.digital-winds', - eot: 'application/vnd.ms-fontobject', - eps: 'application/postscript', - epub: 'application/epub+zip', - es: 'application/ecmascript', - es3: 'application/vnd.eszigno3+xml', - esa: 'application/vnd.osgi.subsystem', - esf: 'application/vnd.epson.esf', - et3: 'application/vnd.eszigno3+xml', - etx: 'text/x-setext', - eva: 'application/x-eva', - evy: 'application/x-envoy', - exe: 'application/x-msdownload', - exi: 'application/exi', - exp: 'application/express', - exr: 'image/aces', - ext: 'application/vnd.novadigm.ext', - ez: 'application/andrew-inset', - ez2: 'application/vnd.ezpix-album', - ez3: 'application/vnd.ezpix-package', - f: 'text/x-fortran', - f4v: 'video/x-f4v', - f77: 'text/x-fortran', - f90: 'text/x-fortran', - fbs: 'image/vnd.fastbidsheet', - fcdt: 'application/vnd.adobe.formscentral.fcdt', - fcs: 'application/vnd.isac.fcs', - fdf: 'application/vnd.fdf', - fdt: 'application/fdt+xml', - fe_launch: 'application/vnd.denovo.fcselayout-link', - fg5: 'application/vnd.fujitsu.oasysgp', - fgd: 'application/x-director', - fh: 'image/x-freehand', - fh4: 'image/x-freehand', - fh5: 'image/x-freehand', - fh7: 'image/x-freehand', - fhc: 'image/x-freehand', - fig: 'application/x-xfig', - fits: 'image/fits', - flac: 'audio/x-flac', - fli: 'video/x-fli', - flo: 'application/vnd.micrografx.flo', - flv: 'video/x-flv', - flw: 'application/vnd.kde.kivio', - flx: 'text/vnd.fmi.flexstor', - fly: 'text/vnd.fly', - fm: 'application/vnd.framemaker', - fnc: 'application/vnd.frogans.fnc', - fo: 'application/vnd.software602.filler.form+xml', - for: 'text/x-fortran', - fpx: 'image/vnd.fpx', - frame: 'application/vnd.framemaker', - fsc: 'application/vnd.fsc.weblaunch', - fst: 'image/vnd.fst', - ftc: 'application/vnd.fluxtime.clip', - fti: 'application/vnd.anser-web-funds-transfer-initiation', - fvt: 'video/vnd.fvt', - fxp: 'application/vnd.adobe.fxp', - fxpl: 'application/vnd.adobe.fxp', - fzs: 'application/vnd.fuzzysheet', - g2w: 'application/vnd.geoplan', - g3: 'image/g3fax', - g3w: 'application/vnd.geospace', - gac: 'application/vnd.groove-account', - gam: 'application/x-tads', - gbr: 'application/rpki-ghostbusters', - gca: 'application/x-gca-compressed', - gdl: 'model/vnd.gdl', - gdoc: 'application/vnd.google-apps.document', - ged: 'text/vnd.familysearch.gedcom', - geo: 'application/vnd.dynageo', - geojson: 'application/geo+json', - gex: 'application/vnd.geometry-explorer', - ggb: 'application/vnd.geogebra.file', - ggt: 'application/vnd.geogebra.tool', - ghf: 'application/vnd.groove-help', - gif: 'image/gif', - gim: 'application/vnd.groove-identity-message', - glb: 'model/gltf-binary', - gltf: 'model/gltf+json', - gml: 'application/gml+xml', - gmx: 'application/vnd.gmx', - gnumeric: 'application/x-gnumeric', - gph: 'application/vnd.flographit', - gpx: 'application/gpx+xml', - gqf: 'application/vnd.grafeq', - gqs: 'application/vnd.grafeq', - gram: 'application/srgs', - gramps: 'application/x-gramps-xml', - gre: 'application/vnd.geometry-explorer', - grv: 'application/vnd.groove-injector', - grxml: 'application/srgs+xml', - gsf: 'application/x-font-ghostscript', - gsheet: 'application/vnd.google-apps.spreadsheet', - gslides: 'application/vnd.google-apps.presentation', - gtar: 'application/x-gtar', - gtm: 'application/vnd.groove-tool-message', - gtw: 'model/vnd.gtw', - gv: 'text/vnd.graphviz', - gxf: 'application/gxf', - gxt: 'application/vnd.geonext', - gz: 'application/gzip', - h: 'text/x-c', - h261: 'video/h261', - h263: 'video/h263', - h264: 'video/h264', - hal: 'application/vnd.hal+xml', - hbci: 'application/vnd.hbci', - hbs: 'text/x-handlebars-template', - hdd: 'application/x-virtualbox-hdd', - hdf: 'application/x-hdf', - heic: 'image/heic', - heics: 'image/heic-sequence', - heif: 'image/heif', - heifs: 'image/heif-sequence', - hej2: 'image/hej2k', - held: 'application/atsc-held+xml', - hh: 'text/x-c', - hjson: 'application/hjson', - hlp: 'application/winhlp', - hpgl: 'application/vnd.hp-hpgl', - hpid: 'application/vnd.hp-hpid', - hps: 'application/vnd.hp-hps', - hqx: 'application/mac-binhex40', - hsj2: 'image/hsj2', - htc: 'text/x-component', - htke: 'application/vnd.kenameaapp', - htm: 'text/html', - html: 'text/html', - hvd: 'application/vnd.yamaha.hv-dic', - hvp: 'application/vnd.yamaha.hv-voice', - hvs: 'application/vnd.yamaha.hv-script', - i2g: 'application/vnd.intergeo', - icc: 'application/vnd.iccprofile', - ice: 'x-conference/x-cooltalk', - icm: 'application/vnd.iccprofile', - ico: 'image/x-icon', - ics: 'text/calendar', - ief: 'image/ief', - ifb: 'text/calendar', - ifm: 'application/vnd.shana.informed.formdata', - iges: 'model/iges', - igl: 'application/vnd.igloader', - igm: 'application/vnd.insors.igm', - igs: 'model/iges', - igx: 'application/vnd.micrografx.igx', - iif: 'application/vnd.shana.informed.interchange', - img: 'application/octet-stream', - imp: 'application/vnd.accpac.simply.imp', - ims: 'application/vnd.ms-ims', - in: 'text/plain', - ini: 'text/plain', - ink: 'application/inkml+xml', - inkml: 'application/inkml+xml', - install: 'application/x-install-instructions', - iota: 'application/vnd.astraea-software.iota', - ipfix: 'application/ipfix', - ipk: 'application/vnd.shana.informed.package', - irm: 'application/vnd.ibm.rights-management', - irp: 'application/vnd.irepository.package+xml', - iso: 'application/x-iso9660-image', - itp: 'application/vnd.shana.informed.formtemplate', - its: 'application/its+xml', - ivp: 'application/vnd.immervision-ivp', - ivu: 'application/vnd.immervision-ivu', - jad: 'text/vnd.sun.j2me.app-descriptor', - jade: 'text/jade', - jam: 'application/vnd.jam', - jar: 'application/java-archive', - jardiff: 'application/x-java-archive-diff', - java: 'text/x-java-source', - jhc: 'image/jphc', - jisp: 'application/vnd.jisp', - jls: 'image/jls', - jlt: 'application/vnd.hp-jlyt', - jng: 'image/x-jng', - jnlp: 'application/x-java-jnlp-file', - joda: 'application/vnd.joost.joda-archive', - jp2: 'image/jp2', - jpe: 'image/jpeg', - jpeg: 'image/jpeg', - jpf: 'image/jpx', - jpg: 'image/jpeg', - jpg2: 'image/jp2', - jpgm: 'video/jpm', - jpgv: 'video/jpeg', - jph: 'image/jph', - jpm: 'video/jpm', - jpx: 'image/jpx', - js: 'application/javascript', - json: 'application/json', - json5: 'application/json5', - jsonld: 'application/ld+json', - jsonml: 'application/jsonml+json', - jsx: 'text/jsx', - jxr: 'image/jxr', - jxra: 'image/jxra', - jxrs: 'image/jxrs', - jxs: 'image/jxs', - jxsc: 'image/jxsc', - jxsi: 'image/jxsi', - jxss: 'image/jxss', - kar: 'audio/midi', - karbon: 'application/vnd.kde.karbon', - kdbx: 'application/x-keepass2', - key: 'application/x-iwork-keynote-sffkey', - kfo: 'application/vnd.kde.kformula', - kia: 'application/vnd.kidspiration', - kml: 'application/vnd.google-earth.kml+xml', - kmz: 'application/vnd.google-earth.kmz', - kne: 'application/vnd.kinar', - knp: 'application/vnd.kinar', - kon: 'application/vnd.kde.kontour', - kpr: 'application/vnd.kde.kpresenter', - kpt: 'application/vnd.kde.kpresenter', - kpxx: 'application/vnd.ds-keypoint', - ksp: 'application/vnd.kde.kspread', - ktr: 'application/vnd.kahootz', - ktx: 'image/ktx', - ktx2: 'image/ktx2', - ktz: 'application/vnd.kahootz', - kwd: 'application/vnd.kde.kword', - kwt: 'application/vnd.kde.kword', - lasxml: 'application/vnd.las.las+xml', - latex: 'application/x-latex', - lbd: 'application/vnd.llamagraphics.life-balance.desktop', - lbe: 'application/vnd.llamagraphics.life-balance.exchange+xml', - les: 'application/vnd.hhe.lesson-player', - less: 'text/less', - lgr: 'application/lgr+xml', - lha: 'application/x-lzh-compressed', - link66: 'application/vnd.route66.link66+xml', - list: 'text/plain', - list3820: 'application/vnd.ibm.modcap', - listafp: 'application/vnd.ibm.modcap', - litcoffee: 'text/coffeescript', - lnk: 'application/x-ms-shortcut', - log: 'text/plain', - lostxml: 'application/lost+xml', - lrf: 'application/octet-stream', - lrm: 'application/vnd.ms-lrm', - ltf: 'application/vnd.frogans.ltf', - lua: 'text/x-lua', - luac: 'application/x-lua-bytecode', - lvp: 'audio/vnd.lucent.voice', - lwp: 'application/vnd.lotus-wordpro', - lzh: 'application/x-lzh-compressed', - m13: 'application/x-msmediaview', - m14: 'application/x-msmediaview', - m1v: 'video/mpeg', - m21: 'application/mp21', - m2a: 'audio/mpeg', - m2v: 'video/mpeg', - m3a: 'audio/mpeg', - m3u: 'audio/x-mpegurl', - m3u8: 'application/vnd.apple.mpegurl', - m4a: 'audio/x-m4a', - m4p: 'application/mp4', - m4s: 'video/iso.segment', - m4u: 'video/vnd.mpegurl', - m4v: 'video/x-m4v', - ma: 'application/mathematica', - mads: 'application/mads+xml', - maei: 'application/mmt-aei+xml', - mag: 'application/vnd.ecowin.chart', - maker: 'application/vnd.framemaker', - man: 'text/troff', - manifest: 'text/cache-manifest', - map: 'application/json', - mar: 'application/octet-stream', - markdown: 'text/markdown', - mathml: 'application/mathml+xml', - mb: 'application/mathematica', - mbk: 'application/vnd.mobius.mbk', - mbox: 'application/mbox', - mc1: 'application/vnd.medcalcdata', - mcd: 'application/vnd.mcd', - mcurl: 'text/vnd.curl.mcurl', - md: 'text/markdown', - mdb: 'application/x-msaccess', - mdi: 'image/vnd.ms-modi', - mdx: 'text/mdx', - me: 'text/troff', - mesh: 'model/mesh', - meta4: 'application/metalink4+xml', - metalink: 'application/metalink+xml', - mets: 'application/mets+xml', - mfm: 'application/vnd.mfmp', - mft: 'application/rpki-manifest', - mgp: 'application/vnd.osgeo.mapguide.package', - mgz: 'application/vnd.proteus.magazine', - mid: 'audio/midi', - midi: 'audio/midi', - mie: 'application/x-mie', - mif: 'application/vnd.mif', - mime: 'message/rfc822', - mj2: 'video/mj2', - mjp2: 'video/mj2', - mjs: 'application/javascript', - mk3d: 'video/x-matroska', - mka: 'audio/x-matroska', - mkd: 'text/x-markdown', - mks: 'video/x-matroska', - mkv: 'video/x-matroska', - mlp: 'application/vnd.dolby.mlp', - mmd: 'application/vnd.chipnuts.karaoke-mmd', - mmf: 'application/vnd.smaf', - mml: 'text/mathml', - mmr: 'image/vnd.fujixerox.edmics-mmr', - mng: 'video/x-mng', - mny: 'application/x-msmoney', - mobi: 'application/x-mobipocket-ebook', - mods: 'application/mods+xml', - mov: 'video/quicktime', - movie: 'video/x-sgi-movie', - mp2: 'audio/mpeg', - mp21: 'application/mp21', - mp2a: 'audio/mpeg', - mp3: 'audio/mpeg', - mp4: 'video/mp4', - mp4a: 'audio/mp4', - mp4s: 'application/mp4', - mp4v: 'video/mp4', - mpc: 'application/vnd.mophun.certificate', - mpd: 'application/dash+xml', - mpe: 'video/mpeg', - mpeg: 'video/mpeg', - mpg: 'video/mpeg', - mpg4: 'video/mp4', - mpga: 'audio/mpeg', - mpkg: 'application/vnd.apple.installer+xml', - mpm: 'application/vnd.blueice.multipass', - mpn: 'application/vnd.mophun.application', - mpp: 'application/vnd.ms-project', - mpt: 'application/vnd.ms-project', - mpy: 'application/vnd.ibm.minipay', - mqy: 'application/vnd.mobius.mqy', - mrc: 'application/marc', - mrcx: 'application/marcxml+xml', - ms: 'text/troff', - mscml: 'application/mediaservercontrol+xml', - mseed: 'application/vnd.fdsn.mseed', - mseq: 'application/vnd.mseq', - msf: 'application/vnd.epson.msf', - msg: 'application/vnd.ms-outlook', - msh: 'model/mesh', - msi: 'application/x-msdownload', - msl: 'application/vnd.mobius.msl', - msm: 'application/octet-stream', - msp: 'application/octet-stream', - msty: 'application/vnd.muvee.style', - mtl: 'model/mtl', - mts: 'model/vnd.mts', - mus: 'application/vnd.musician', - musd: 'application/mmt-usd+xml', - musicxml: 'application/vnd.recordare.musicxml+xml', - mvb: 'application/x-msmediaview', - mvt: 'application/vnd.mapbox-vector-tile', - mwf: 'application/vnd.mfer', - mxf: 'application/mxf', - mxl: 'application/vnd.recordare.musicxml', - mxmf: 'audio/mobile-xmf', - mxml: 'application/xv+xml', - mxs: 'application/vnd.triscape.mxs', - mxu: 'video/vnd.mpegurl', - 'n-gage': 'application/vnd.nokia.n-gage.symbian.install', - n3: 'text/n3', - nb: 'application/mathematica', - nbp: 'application/vnd.wolfram.player', - nc: 'application/x-netcdf', - ncx: 'application/x-dtbncx+xml', - nfo: 'text/x-nfo', - ngdat: 'application/vnd.nokia.n-gage.data', - nitf: 'application/vnd.nitf', - nlu: 'application/vnd.neurolanguage.nlu', - nml: 'application/vnd.enliven', - nnd: 'application/vnd.noblenet-directory', - nns: 'application/vnd.noblenet-sealer', - nnw: 'application/vnd.noblenet-web', - npx: 'image/vnd.net-fpx', - nq: 'application/n-quads', - nsc: 'application/x-conference', - nsf: 'application/vnd.lotus-notes', - nt: 'application/n-triples', - ntf: 'application/vnd.nitf', - numbers: 'application/x-iwork-numbers-sffnumbers', - nzb: 'application/x-nzb', - oa2: 'application/vnd.fujitsu.oasys2', - oa3: 'application/vnd.fujitsu.oasys3', - oas: 'application/vnd.fujitsu.oasys', - obd: 'application/x-msbinder', - obgx: 'application/vnd.openblox.game+xml', - obj: 'model/obj', - oda: 'application/oda', - odb: 'application/vnd.oasis.opendocument.database', - odc: 'application/vnd.oasis.opendocument.chart', - odf: 'application/vnd.oasis.opendocument.formula', - odft: 'application/vnd.oasis.opendocument.formula-template', - odg: 'application/vnd.oasis.opendocument.graphics', - odi: 'application/vnd.oasis.opendocument.image', - odm: 'application/vnd.oasis.opendocument.text-master', - odp: 'application/vnd.oasis.opendocument.presentation', - ods: 'application/vnd.oasis.opendocument.spreadsheet', - odt: 'application/vnd.oasis.opendocument.text', - oga: 'audio/ogg', - ogex: 'model/vnd.opengex', - ogg: 'audio/ogg', - ogv: 'video/ogg', - ogx: 'application/ogg', - omdoc: 'application/omdoc+xml', - onepkg: 'application/onenote', - onetmp: 'application/onenote', - onetoc: 'application/onenote', - onetoc2: 'application/onenote', - opf: 'application/oebps-package+xml', - opml: 'text/x-opml', - oprc: 'application/vnd.palm', - opus: 'audio/ogg', - org: 'text/x-org', - osf: 'application/vnd.yamaha.openscoreformat', - osfpvg: 'application/vnd.yamaha.openscoreformat.osfpvg+xml', - osm: 'application/vnd.openstreetmap.data+xml', - otc: 'application/vnd.oasis.opendocument.chart-template', - otf: 'font/otf', - otg: 'application/vnd.oasis.opendocument.graphics-template', - oth: 'application/vnd.oasis.opendocument.text-web', - oti: 'application/vnd.oasis.opendocument.image-template', - otp: 'application/vnd.oasis.opendocument.presentation-template', - ots: 'application/vnd.oasis.opendocument.spreadsheet-template', - ott: 'application/vnd.oasis.opendocument.text-template', - ova: 'application/x-virtualbox-ova', - ovf: 'application/x-virtualbox-ovf', - owl: 'application/rdf+xml', - oxps: 'application/oxps', - oxt: 'application/vnd.openofficeorg.extension', - p: 'text/x-pascal', - p10: 'application/pkcs10', - p12: 'application/x-pkcs12', - p7b: 'application/x-pkcs7-certificates', - p7c: 'application/pkcs7-mime', - p7m: 'application/pkcs7-mime', - p7r: 'application/x-pkcs7-certreqresp', - p7s: 'application/pkcs7-signature', - p8: 'application/pkcs8', - pac: 'application/x-ns-proxy-autoconfig', - pages: 'application/x-iwork-pages-sffpages', - pas: 'text/x-pascal', - paw: 'application/vnd.pawaafile', - pbd: 'application/vnd.powerbuilder6', - pbm: 'image/x-portable-bitmap', - pcap: 'application/vnd.tcpdump.pcap', - pcf: 'application/x-font-pcf', - pcl: 'application/vnd.hp-pcl', - pclxl: 'application/vnd.hp-pclxl', - pct: 'image/x-pict', - pcurl: 'application/vnd.curl.pcurl', - pcx: 'image/x-pcx', - pdb: 'application/x-pilot', - pde: 'text/x-processing', - pdf: 'application/pdf', - pem: 'application/x-x509-ca-cert', - pfa: 'application/x-font-type1', - pfb: 'application/x-font-type1', - pfm: 'application/x-font-type1', - pfr: 'application/font-tdpfr', - pfx: 'application/x-pkcs12', - pgm: 'image/x-portable-graymap', - pgn: 'application/x-chess-pgn', - pgp: 'application/pgp-encrypted', - php: 'application/x-httpd-php', - pic: 'image/x-pict', - pkg: 'application/octet-stream', - pki: 'application/pkixcmp', - pkipath: 'application/pkix-pkipath', - pkpass: 'application/vnd.apple.pkpass', - pl: 'application/x-perl', - plb: 'application/vnd.3gpp.pic-bw-large', - plc: 'application/vnd.mobius.plc', - plf: 'application/vnd.pocketlearn', - pls: 'application/pls+xml', - pm: 'application/x-perl', - pml: 'application/vnd.ctc-posml', - png: 'image/png', - pnm: 'image/x-portable-anymap', - portpkg: 'application/vnd.macports.portpkg', - pot: 'application/vnd.ms-powerpoint', - potm: 'application/vnd.ms-powerpoint.template.macroenabled.12', - potx: 'application/vnd.openxmlformats-officedocument.presentationml.template', - ppam: 'application/vnd.ms-powerpoint.addin.macroenabled.12', - ppd: 'application/vnd.cups-ppd', - ppm: 'image/x-portable-pixmap', - pps: 'application/vnd.ms-powerpoint', - ppsm: 'application/vnd.ms-powerpoint.slideshow.macroenabled.12', - ppsx: 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', - ppt: 'application/vnd.ms-powerpoint', - pptm: 'application/vnd.ms-powerpoint.presentation.macroenabled.12', - pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - pqa: 'application/vnd.palm', - prc: 'application/x-pilot', - pre: 'application/vnd.lotus-freelance', - prf: 'application/pics-rules', - provx: 'application/provenance+xml', - ps: 'application/postscript', - psb: 'application/vnd.3gpp.pic-bw-small', - psd: 'image/vnd.adobe.photoshop', - psf: 'application/x-font-linux-psf', - pskcxml: 'application/pskc+xml', - pti: 'image/prs.pti', - ptid: 'application/vnd.pvi.ptid1', - pub: 'application/x-mspublisher', - pvb: 'application/vnd.3gpp.pic-bw-var', - pwn: 'application/vnd.3m.post-it-notes', - pya: 'audio/vnd.ms-playready.media.pya', - pyv: 'video/vnd.ms-playready.media.pyv', - qam: 'application/vnd.epson.quickanime', - qbo: 'application/vnd.intu.qbo', - qfx: 'application/vnd.intu.qfx', - qps: 'application/vnd.publishare-delta-tree', - qt: 'video/quicktime', - qwd: 'application/vnd.quark.quarkxpress', - qwt: 'application/vnd.quark.quarkxpress', - qxb: 'application/vnd.quark.quarkxpress', - qxd: 'application/vnd.quark.quarkxpress', - qxl: 'application/vnd.quark.quarkxpress', - qxt: 'application/vnd.quark.quarkxpress', - ra: 'audio/x-realaudio', - ram: 'audio/x-pn-realaudio', - raml: 'application/raml+yaml', - rapd: 'application/route-apd+xml', - rar: 'application/x-rar-compressed', - ras: 'image/x-cmu-raster', - rcprofile: 'application/vnd.ipunplugged.rcprofile', - rdf: 'application/rdf+xml', - rdz: 'application/vnd.data-vision.rdz', - relo: 'application/p2p-overlay+xml', - rep: 'application/vnd.businessobjects', - res: 'application/x-dtbresource+xml', - rgb: 'image/x-rgb', - rif: 'application/reginfo+xml', - rip: 'audio/vnd.rip', - ris: 'application/x-research-info-systems', - rl: 'application/resource-lists+xml', - rlc: 'image/vnd.fujixerox.edmics-rlc', - rld: 'application/resource-lists-diff+xml', - rm: 'application/vnd.rn-realmedia', - rmi: 'audio/midi', - rmp: 'audio/x-pn-realaudio-plugin', - rms: 'application/vnd.jcp.javame.midlet-rms', - rmvb: 'application/vnd.rn-realmedia-vbr', - rnc: 'application/relax-ng-compact-syntax', - rng: 'application/xml', - roa: 'application/rpki-roa', - roff: 'text/troff', - rp9: 'application/vnd.cloanto.rp9', - rpm: 'application/x-redhat-package-manager', - rpss: 'application/vnd.nokia.radio-presets', - rpst: 'application/vnd.nokia.radio-preset', - rq: 'application/sparql-query', - rs: 'application/rls-services+xml', - rsat: 'application/atsc-rsat+xml', - rsd: 'application/rsd+xml', - rsheet: 'application/urc-ressheet+xml', - rss: 'application/rss+xml', - rtf: 'text/rtf', - rtx: 'text/richtext', - run: 'application/x-makeself', - rusd: 'application/route-usd+xml', - s: 'text/x-asm', - s3m: 'audio/s3m', - saf: 'application/vnd.yamaha.smaf-audio', - sass: 'text/x-sass', - sbml: 'application/sbml+xml', - sc: 'application/vnd.ibm.secure-container', - scd: 'application/x-msschedule', - scm: 'application/vnd.lotus-screencam', - scq: 'application/scvp-cv-request', - scs: 'application/scvp-cv-response', - scss: 'text/x-scss', - scurl: 'text/vnd.curl.scurl', - sda: 'application/vnd.stardivision.draw', - sdc: 'application/vnd.stardivision.calc', - sdd: 'application/vnd.stardivision.impress', - sdkd: 'application/vnd.solent.sdkm+xml', - sdkm: 'application/vnd.solent.sdkm+xml', - sdp: 'application/sdp', - sdw: 'application/vnd.stardivision.writer', - sea: 'application/x-sea', - see: 'application/vnd.seemail', - seed: 'application/vnd.fdsn.seed', - sema: 'application/vnd.sema', - semd: 'application/vnd.semd', - semf: 'application/vnd.semf', - senmlx: 'application/senml+xml', - sensmlx: 'application/sensml+xml', - ser: 'application/java-serialized-object', - setpay: 'application/set-payment-initiation', - setreg: 'application/set-registration-initiation', - 'sfd-hdstx': 'application/vnd.hydrostatix.sof-data', - sfs: 'application/vnd.spotfire.sfs', - sfv: 'text/x-sfv', - sgi: 'image/sgi', - sgl: 'application/vnd.stardivision.writer-global', - sgm: 'text/sgml', - sgml: 'text/sgml', - sh: 'application/x-sh', - shar: 'application/x-shar', - shex: 'text/shex', - shf: 'application/shf+xml', - shtml: 'text/html', - sid: 'image/x-mrsid-image', - sieve: 'application/sieve', - sig: 'application/pgp-signature', - sil: 'audio/silk', - silo: 'model/mesh', - sis: 'application/vnd.symbian.install', - sisx: 'application/vnd.symbian.install', - sit: 'application/x-stuffit', - sitx: 'application/x-stuffitx', - siv: 'application/sieve', - skd: 'application/vnd.koan', - skm: 'application/vnd.koan', - skp: 'application/vnd.koan', - skt: 'application/vnd.koan', - sldm: 'application/vnd.ms-powerpoint.slide.macroenabled.12', - sldx: 'application/vnd.openxmlformats-officedocument.presentationml.slide', - slim: 'text/slim', - slm: 'text/slim', - sls: 'application/route-s-tsid+xml', - slt: 'application/vnd.epson.salt', - sm: 'application/vnd.stepmania.stepchart', - smf: 'application/vnd.stardivision.math', - smi: 'application/smil+xml', - smil: 'application/smil+xml', - smv: 'video/x-smv', - smzip: 'application/vnd.stepmania.package', - snd: 'audio/basic', - snf: 'application/x-font-snf', - so: 'application/octet-stream', - spc: 'application/x-pkcs7-certificates', - spdx: 'text/spdx', - spf: 'application/vnd.yamaha.smaf-phrase', - spl: 'application/x-futuresplash', - spot: 'text/vnd.in3d.spot', - spp: 'application/scvp-vp-response', - spq: 'application/scvp-vp-request', - spx: 'audio/ogg', - sql: 'application/x-sql', - src: 'application/x-wais-source', - srt: 'application/x-subrip', - sru: 'application/sru+xml', - srx: 'application/sparql-results+xml', - ssdl: 'application/ssdl+xml', - sse: 'application/vnd.kodak-descriptor', - ssf: 'application/vnd.epson.ssf', - ssml: 'application/ssml+xml', - st: 'application/vnd.sailingtracker.track', - stc: 'application/vnd.sun.xml.calc.template', - std: 'application/vnd.sun.xml.draw.template', - stf: 'application/vnd.wt.stf', - sti: 'application/vnd.sun.xml.impress.template', - stk: 'application/hyperstudio', - stl: 'model/stl', - stpx: 'model/step+xml', - stpxz: 'model/step-xml+zip', - stpz: 'model/step+zip', - str: 'application/vnd.pg.format', - stw: 'application/vnd.sun.xml.writer.template', - styl: 'text/stylus', - stylus: 'text/stylus', - sub: 'text/vnd.dvb.subtitle', - sus: 'application/vnd.sus-calendar', - susp: 'application/vnd.sus-calendar', - sv4cpio: 'application/x-sv4cpio', - sv4crc: 'application/x-sv4crc', - svc: 'application/vnd.dvb.service', - svd: 'application/vnd.svd', - svg: 'image/svg+xml', - svgz: 'image/svg+xml', - swa: 'application/x-director', - swf: 'application/x-shockwave-flash', - swi: 'application/vnd.aristanetworks.swi', - swidtag: 'application/swid+xml', - sxc: 'application/vnd.sun.xml.calc', - sxd: 'application/vnd.sun.xml.draw', - sxg: 'application/vnd.sun.xml.writer.global', - sxi: 'application/vnd.sun.xml.impress', - sxm: 'application/vnd.sun.xml.math', - sxw: 'application/vnd.sun.xml.writer', - t: 'text/troff', - t3: 'application/x-t3vm-image', - t38: 'image/t38', - taglet: 'application/vnd.mynfc', - tao: 'application/vnd.tao.intent-module-archive', - tap: 'image/vnd.tencent.tap', - tar: 'application/x-tar', - tcap: 'application/vnd.3gpp2.tcap', - tcl: 'application/x-tcl', - td: 'application/urc-targetdesc+xml', - teacher: 'application/vnd.smart.teacher', - tei: 'application/tei+xml', - teicorpus: 'application/tei+xml', - tex: 'application/x-tex', - texi: 'application/x-texinfo', - texinfo: 'application/x-texinfo', - text: 'text/plain', - tfi: 'application/thraud+xml', - tfm: 'application/x-tex-tfm', - tfx: 'image/tiff-fx', - tga: 'image/x-tga', - thmx: 'application/vnd.ms-officetheme', - tif: 'image/tiff', - tiff: 'image/tiff', - tk: 'application/x-tcl', - tmo: 'application/vnd.tmobile-livetv', - toml: 'application/toml', - torrent: 'application/x-bittorrent', - tpl: 'application/vnd.groove-tool-template', - tpt: 'application/vnd.trid.tpt', - tr: 'text/troff', - tra: 'application/vnd.trueapp', - trig: 'application/trig', - trm: 'application/x-msterminal', - ts: 'video/mp2t', - tsd: 'application/timestamped-data', - tsv: 'text/tab-separated-values', - ttc: 'font/collection', - ttf: 'font/ttf', - ttl: 'text/turtle', - ttml: 'application/ttml+xml', - twd: 'application/vnd.simtech-mindmapper', - twds: 'application/vnd.simtech-mindmapper', - txd: 'application/vnd.genomatix.tuxedo', - txf: 'application/vnd.mobius.txf', - txt: 'text/plain', - u32: 'application/x-authorware-bin', - u8dsn: 'message/global-delivery-status', - u8hdr: 'message/global-headers', - u8mdn: 'message/global-disposition-notification', - u8msg: 'message/global', - ubj: 'application/ubjson', - udeb: 'application/x-debian-package', - ufd: 'application/vnd.ufdl', - ufdl: 'application/vnd.ufdl', - ulx: 'application/x-glulx', - umj: 'application/vnd.umajin', - unityweb: 'application/vnd.unity', - uoml: 'application/vnd.uoml+xml', - uri: 'text/uri-list', - uris: 'text/uri-list', - urls: 'text/uri-list', - usdz: 'model/vnd.usdz+zip', - ustar: 'application/x-ustar', - utz: 'application/vnd.uiq.theme', - uu: 'text/x-uuencode', - uva: 'audio/vnd.dece.audio', - uvd: 'application/vnd.dece.data', - uvf: 'application/vnd.dece.data', - uvg: 'image/vnd.dece.graphic', - uvh: 'video/vnd.dece.hd', - uvi: 'image/vnd.dece.graphic', - uvm: 'video/vnd.dece.mobile', - uvp: 'video/vnd.dece.pd', - uvs: 'video/vnd.dece.sd', - uvt: 'application/vnd.dece.ttml+xml', - uvu: 'video/vnd.uvvu.mp4', - uvv: 'video/vnd.dece.video', - uvva: 'audio/vnd.dece.audio', - uvvd: 'application/vnd.dece.data', - uvvf: 'application/vnd.dece.data', - uvvg: 'image/vnd.dece.graphic', - uvvh: 'video/vnd.dece.hd', - uvvi: 'image/vnd.dece.graphic', - uvvm: 'video/vnd.dece.mobile', - uvvp: 'video/vnd.dece.pd', - uvvs: 'video/vnd.dece.sd', - uvvt: 'application/vnd.dece.ttml+xml', - uvvu: 'video/vnd.uvvu.mp4', - uvvv: 'video/vnd.dece.video', - uvvx: 'application/vnd.dece.unspecified', - uvvz: 'application/vnd.dece.zip', - uvx: 'application/vnd.dece.unspecified', - uvz: 'application/vnd.dece.zip', - vbox: 'application/x-virtualbox-vbox', - 'vbox-extpack': 'application/x-virtualbox-vbox-extpack', - vcard: 'text/vcard', - vcd: 'application/x-cdlink', - vcf: 'text/x-vcard', - vcg: 'application/vnd.groove-vcard', - vcs: 'text/x-vcalendar', - vcx: 'application/vnd.vcx', - vdi: 'application/x-virtualbox-vdi', - vds: 'model/vnd.sap.vds', - vhd: 'application/x-virtualbox-vhd', - vis: 'application/vnd.visionary', - viv: 'video/vnd.vivo', - vmdk: 'application/x-virtualbox-vmdk', - vob: 'video/x-ms-vob', - vor: 'application/vnd.stardivision.writer', - vox: 'application/x-authorware-bin', - vrml: 'model/vrml', - vsd: 'application/vnd.visio', - vsf: 'application/vnd.vsf', - vss: 'application/vnd.visio', - vst: 'application/vnd.visio', - vsw: 'application/vnd.visio', - vtf: 'image/vnd.valve.source.texture', - vtt: 'text/vtt', - vtu: 'model/vnd.vtu', - vxml: 'application/voicexml+xml', - w3d: 'application/x-director', - wad: 'application/x-doom', - wadl: 'application/vnd.sun.wadl+xml', - war: 'application/java-archive', - wasm: 'application/wasm', - wav: 'audio/x-wav', - wax: 'audio/x-ms-wax', - wbmp: 'image/vnd.wap.wbmp', - wbs: 'application/vnd.criticaltools.wbs+xml', - wbxml: 'application/vnd.wap.wbxml', - wcm: 'application/vnd.ms-works', - wdb: 'application/vnd.ms-works', - wdp: 'image/vnd.ms-photo', - weba: 'audio/webm', - webapp: 'application/x-web-app-manifest+json', - webm: 'video/webm', - webmanifest: 'application/manifest+json', - webp: 'image/webp', - wg: 'application/vnd.pmi.widget', - wgt: 'application/widget', - wks: 'application/vnd.ms-works', - wm: 'video/x-ms-wm', - wma: 'audio/x-ms-wma', - wmd: 'application/x-ms-wmd', - wmf: 'image/wmf', - wml: 'text/vnd.wap.wml', - wmlc: 'application/vnd.wap.wmlc', - wmls: 'text/vnd.wap.wmlscript', - wmlsc: 'application/vnd.wap.wmlscriptc', - wmv: 'video/x-ms-wmv', - wmx: 'video/x-ms-wmx', - wmz: 'application/x-msmetafile', - woff: 'font/woff', - woff2: 'font/woff2', - wpd: 'application/vnd.wordperfect', - wpl: 'application/vnd.ms-wpl', - wps: 'application/vnd.ms-works', - wqd: 'application/vnd.wqd', - wri: 'application/x-mswrite', - wrl: 'model/vrml', - wsc: 'message/vnd.wfa.wsc', - wsdl: 'application/wsdl+xml', - wspolicy: 'application/wspolicy+xml', - wtb: 'application/vnd.webturbo', - wvx: 'video/x-ms-wvx', - x32: 'application/x-authorware-bin', - x3d: 'model/x3d+xml', - x3db: 'model/x3d+fastinfoset', - x3dbz: 'model/x3d+binary', - x3dv: 'model/x3d-vrml', - x3dvz: 'model/x3d+vrml', - x3dz: 'model/x3d+xml', - x_b: 'model/vnd.parasolid.transmit.binary', - x_t: 'model/vnd.parasolid.transmit.text', - xaml: 'application/xaml+xml', - xap: 'application/x-silverlight-app', - xar: 'application/vnd.xara', - xav: 'application/xcap-att+xml', - xbap: 'application/x-ms-xbap', - xbd: 'application/vnd.fujixerox.docuworks.binder', - xbm: 'image/x-xbitmap', - xca: 'application/xcap-caps+xml', - xcs: 'application/calendar+xml', - xdf: 'application/xcap-diff+xml', - xdm: 'application/vnd.syncml.dm+xml', - xdp: 'application/vnd.adobe.xdp+xml', - xdssc: 'application/dssc+xml', - xdw: 'application/vnd.fujixerox.docuworks', - xel: 'application/xcap-el+xml', - xenc: 'application/xenc+xml', - xer: 'application/patch-ops-error+xml', - xfdf: 'application/vnd.adobe.xfdf', - xfdl: 'application/vnd.xfdl', - xht: 'application/xhtml+xml', - xhtml: 'application/xhtml+xml', - xhvml: 'application/xv+xml', - xif: 'image/vnd.xiff', - xla: 'application/vnd.ms-excel', - xlam: 'application/vnd.ms-excel.addin.macroenabled.12', - xlc: 'application/vnd.ms-excel', - xlf: 'application/xliff+xml', - xlm: 'application/vnd.ms-excel', - xls: 'application/vnd.ms-excel', - xlsb: 'application/vnd.ms-excel.sheet.binary.macroenabled.12', - xlsm: 'application/vnd.ms-excel.sheet.macroenabled.12', - xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - xlt: 'application/vnd.ms-excel', - xltm: 'application/vnd.ms-excel.template.macroenabled.12', - xltx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', - xlw: 'application/vnd.ms-excel', - xm: 'audio/xm', - xml: 'text/xml', - xns: 'application/xcap-ns+xml', - xo: 'application/vnd.olpc-sugar', - xop: 'application/xop+xml', - xpi: 'application/x-xpinstall', - xpl: 'application/xproc+xml', - xpm: 'image/x-xpixmap', - xpr: 'application/vnd.is-xpr', - xps: 'application/vnd.ms-xpsdocument', - xpw: 'application/vnd.intercon.formnet', - xpx: 'application/vnd.intercon.formnet', - xsd: 'application/xml', - xsl: 'application/xslt+xml', - xslt: 'application/xslt+xml', - xsm: 'application/vnd.syncml+xml', - xspf: 'application/xspf+xml', - xul: 'application/vnd.mozilla.xul+xml', - xvm: 'application/xv+xml', - xvml: 'application/xv+xml', - xwd: 'image/x-xwindowdump', - xyz: 'chemical/x-xyz', - xz: 'application/x-xz', - yaml: 'text/yaml', - yang: 'application/yang', - yin: 'application/yin+xml', - yml: 'text/yaml', - ymp: 'text/x-suse-ymp', - z1: 'application/x-zmachine', - z2: 'application/x-zmachine', - z3: 'application/x-zmachine', - z4: 'application/x-zmachine', - z5: 'application/x-zmachine', - z6: 'application/x-zmachine', - z7: 'application/x-zmachine', - z8: 'application/x-zmachine', - zaz: 'application/vnd.zzazz.deck+xml', - zip: 'application/zip', - zir: 'application/vnd.zul', - zirz: 'application/vnd.zul', - zmm: 'application/vnd.handheld-entertainment+xml', -} diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts new file mode 100644 index 000000000..fba41260d --- /dev/null +++ b/src/lib/link-meta/bsky.ts @@ -0,0 +1,99 @@ +import {LikelyType, LinkMeta} from './link-meta' +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' + +// NOTE +// this is a hack around the lack of hosted social metadata +// remove once that's implemented +// -prf +export async function extractBskyMeta( + store: RootStoreModel, + url: string, +): Promise { + url = convertBskyAppUrlIfNeeded(url) + const route = matchRoute(url) + let meta: LinkMeta = { + likelyType: LikelyType.AtpData, + url, + title: route.defaultTitle, + } + + if (route.Com === Home) { + meta = { + ...meta, + title: 'Bluesky', + description: 'A new kind of social network', + } + } else if (route.Com === Search) { + meta = { + ...meta, + title: 'Search - Bluesky', + description: 'A new kind of social network', + } + } else if (route.Com === Notifications) { + meta = { + ...meta, + title: 'Notifications - Bluesky', + description: 'A new kind of social network', + } + } else if ( + route.Com === PostThread || + route.Com === PostUpvotedBy || + route.Com === PostRepostedBy + ) { + // post and post-related screens + const threadUri = makeRecordUri( + route.params.name, + 'app.bsky.feed.post', + route.params.rkey, + ) + const threadView = new PostThreadViewModel(store, { + uri: threadUri, + depth: 0, + }) + await threadView.setup().catch(_err => undefined) + const title = [ + route.Com === PostUpvotedBy + ? 'Likes on a post by' + : route.Com === PostRepostedBy + ? 'Reposts of a post by' + : 'Post by', + threadView.thread?.post.author.displayName || + threadView.thread?.post.author.handle || + 'a bluesky user', + ].join(' ') + meta = { + ...meta, + title, + description: threadView.thread?.postRecord?.text, + } + } else if ( + route.Com === Profile || + route.Com === ProfileFollowers || + route.Com === ProfileFollows + ) { + // profile and profile-related screens + const profile = await store.profiles.getProfile(route.params.name) + if (profile?.data) { + meta = { + ...meta, + title: profile.data.displayName || profile.data.handle, + description: profile.data.description, + } + } + } + + return meta +} diff --git a/src/lib/link-meta/html.ts b/src/lib/link-meta/html.ts new file mode 100644 index 000000000..220f8431d --- /dev/null +++ b/src/lib/link-meta/html.ts @@ -0,0 +1,71 @@ +import {extractTwitterMeta} from './twitter' +import {extractYoutubeMeta} from './youtube' + +interface ExtractHtmlMetaInput { + html: string + hostname?: string + pathname?: string +} + +export const extractHtmlMeta = ({ + html, + hostname, + pathname, +}: ExtractHtmlMetaInput): Record => { + const htmlTitleRegex = /([^<]+)<\/title>/i + + let res: Record = {} + + const match = htmlTitleRegex.exec(html) + + if (match) { + res.title = match[1].trim() + } + + let metaMatch + let propMatch + const metaRe = /]+)>/gis + while ((metaMatch = metaRe.exec(html))) { + let propName + let propValue + const propRe = /(name|property|content)="([^"]+)"/gis + while ((propMatch = propRe.exec(metaMatch[1]))) { + if (propMatch[1] === 'content') { + propValue = propMatch[2] + } else { + propName = propMatch[2] + } + } + if (!propName || !propValue) { + continue + } + switch (propName?.trim()) { + case 'title': + case 'og:title': + case 'twitter:title': + res.title = propValue?.trim() + break + case 'description': + case 'og:description': + case 'twitter:description': + res.description = propValue?.trim() + break + case 'og:image': + case 'twitter:image': + res.image = propValue?.trim() + break + } + } + + const isYoutubeUrl = + hostname?.includes('youtube.') || hostname?.includes('youtu.be') + const isTwitterUrl = hostname?.includes('twitter.') + // Workaround for some websites not having a title or description in the meta tags in the initial serve + if (isYoutubeUrl) { + res = {...res, ...extractYoutubeMeta(html)} + } else if (isTwitterUrl && pathname) { + res = {...extractTwitterMeta({pathname})} + } + + return res +} diff --git a/src/lib/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts new file mode 100644 index 000000000..6c4ad5384 --- /dev/null +++ b/src/lib/link-meta/link-meta.ts @@ -0,0 +1,1290 @@ +import he from 'he' +import {isBskyAppUrl} from '../strings/url-helpers' +import {RootStoreModel} from 'state/index' +import {extractBskyMeta} from './bsky' +import {extractHtmlMeta} from './html' + +export enum LikelyType { + HTML, + Text, + Image, + Video, + Audio, + AtpData, + Other, +} + +export interface LinkMeta { + error?: string + likelyType: LikelyType + url: string + title?: string + description?: string + image?: string +} + +export async function getLinkMeta( + store: RootStoreModel, + url: string, + timeout = 5e3, +): Promise { + if (isBskyAppUrl(url)) { + return extractBskyMeta(store, url) + } + + let urlp + try { + urlp = new URL(url) + } catch (e) { + return { + error: 'Invalid URL', + likelyType: LikelyType.Other, + url, + } + } + const likelyType = getLikelyType(urlp) + const meta: LinkMeta = { + likelyType, + url, + } + if (likelyType !== LikelyType.HTML) { + return meta + } + + try { + const controller = new AbortController() + const to = setTimeout(() => controller.abort(), timeout || 5e3) + const httpRes = await fetch(url, { + headers: {accept: 'text/html'}, + signal: controller.signal, + }) + const httpResBody = await httpRes.text() + clearTimeout(to) + const httpResMeta = extractHtmlMeta({ + html: httpResBody, + hostname: urlp?.hostname, + pathname: urlp?.pathname, + }) + meta.title = httpResMeta.title ? he.decode(httpResMeta.title) : undefined + meta.description = httpResMeta.description + ? he.decode(httpResMeta.description) + : undefined + meta.image = httpResMeta.image + } catch (e) { + // failed + console.error(e) + meta.error = 'Failed to fetch link' + } + + return meta +} + +export function getLikelyType(url: URL | string): LikelyType { + if (typeof url === 'string') { + try { + url = new URL(url) + } catch (e) { + return LikelyType.Other + } + } + + const ext = url.pathname.split('.').pop() || '' + if (ext === 'html' || ext === 'htm') { + return LikelyType.HTML + } + const mimeType = EXT_MIME_TYPES[ext] + if (!mimeType) { + return LikelyType.HTML + } + if (mimeType.startsWith('text/')) { + return LikelyType.Text + } + if (mimeType.startsWith('image/')) { + return LikelyType.Image + } + if (mimeType.startsWith('video/')) { + return LikelyType.Video + } + if (mimeType.startsWith('audio/')) { + return LikelyType.Audio + } + return LikelyType.Other +} + +const EXT_MIME_TYPES: Record = { + '123': 'application/vnd.lotus-1-2-3', + '1km': 'application/vnd.1000minds.decision-model+xml', + '3dml': 'text/vnd.in3d.3dml', + '3ds': 'image/x-3ds', + '3g2': 'video/3gpp2', + '3gp': 'video/3gpp', + '3gpp': 'video/3gpp', + '3mf': 'model/3mf', + '7z': 'application/x-7z-compressed', + aab: 'application/x-authorware-bin', + aac: 'audio/x-aac', + aam: 'application/x-authorware-map', + aas: 'application/x-authorware-seg', + abw: 'application/x-abiword', + ac: 'application/vnd.nokia.n-gage.ac+xml', + acc: 'application/vnd.americandynamics.acc', + ace: 'application/x-ace-compressed', + acu: 'application/vnd.acucobol', + acutc: 'application/vnd.acucorp', + adp: 'audio/adpcm', + aep: 'application/vnd.audiograph', + afm: 'application/x-font-type1', + afp: 'application/vnd.ibm.modcap', + age: 'application/vnd.age', + ahead: 'application/vnd.ahead.space', + ai: 'application/postscript', + aif: 'audio/x-aiff', + aifc: 'audio/x-aiff', + aiff: 'audio/x-aiff', + air: 'application/vnd.adobe.air-application-installer-package+zip', + ait: 'application/vnd.dvb.ait', + ami: 'application/vnd.amiga.ami', + amr: 'audio/amr', + apk: 'application/vnd.android.package-archive', + apng: 'image/apng', + appcache: 'text/cache-manifest', + application: 'application/x-ms-application', + apr: 'application/vnd.lotus-approach', + arc: 'application/x-freearc', + arj: 'application/x-arj', + asc: 'application/pgp-signature', + asf: 'video/x-ms-asf', + asm: 'text/x-asm', + aso: 'application/vnd.accpac.simply.aso', + asx: 'video/x-ms-asf', + atc: 'application/vnd.acucorp', + atom: 'application/atom+xml', + atomcat: 'application/atomcat+xml', + atomdeleted: 'application/atomdeleted+xml', + atomsvc: 'application/atomsvc+xml', + atx: 'application/vnd.antix.game-component', + au: 'audio/basic', + avi: 'video/x-msvideo', + avif: 'image/avif', + aw: 'application/applixware', + azf: 'application/vnd.airzip.filesecure.azf', + azs: 'application/vnd.airzip.filesecure.azs', + azv: 'image/vnd.airzip.accelerator.azv', + azw: 'application/vnd.amazon.ebook', + b16: 'image/vnd.pco.b16', + bat: 'application/x-msdownload', + bcpio: 'application/x-bcpio', + bdf: 'application/x-font-bdf', + bdm: 'application/vnd.syncml.dm+wbxml', + bdoc: 'application/x-bdoc', + bed: 'application/vnd.realvnc.bed', + bh2: 'application/vnd.fujitsu.oasysprs', + bin: 'application/octet-stream', + blb: 'application/x-blorb', + blorb: 'application/x-blorb', + bmi: 'application/vnd.bmi', + bmml: 'application/vnd.balsamiq.bmml+xml', + bmp: 'image/x-ms-bmp', + book: 'application/vnd.framemaker', + box: 'application/vnd.previewsystems.box', + boz: 'application/x-bzip2', + bpk: 'application/octet-stream', + bsp: 'model/vnd.valve.source.compiled-map', + btif: 'image/prs.btif', + buffer: 'application/octet-stream', + bz: 'application/x-bzip', + bz2: 'application/x-bzip2', + c: 'text/x-c', + c11amc: 'application/vnd.cluetrust.cartomobile-config', + c11amz: 'application/vnd.cluetrust.cartomobile-config-pkg', + c4d: 'application/vnd.clonk.c4group', + c4f: 'application/vnd.clonk.c4group', + c4g: 'application/vnd.clonk.c4group', + c4p: 'application/vnd.clonk.c4group', + c4u: 'application/vnd.clonk.c4group', + cab: 'application/vnd.ms-cab-compressed', + caf: 'audio/x-caf', + cap: 'application/vnd.tcpdump.pcap', + car: 'application/vnd.curl.car', + cat: 'application/vnd.ms-pki.seccat', + cb7: 'application/x-cbr', + cba: 'application/x-cbr', + cbr: 'application/x-cbr', + cbt: 'application/x-cbr', + cbz: 'application/x-cbr', + cc: 'text/x-c', + cco: 'application/x-cocoa', + cct: 'application/x-director', + ccxml: 'application/ccxml+xml', + cdbcmsg: 'application/vnd.contact.cmsg', + cdf: 'application/x-netcdf', + cdfx: 'application/cdfx+xml', + cdkey: 'application/vnd.mediastation.cdkey', + cdmia: 'application/cdmi-capability', + cdmic: 'application/cdmi-container', + cdmid: 'application/cdmi-domain', + cdmio: 'application/cdmi-object', + cdmiq: 'application/cdmi-queue', + cdx: 'chemical/x-cdx', + cdxml: 'application/vnd.chemdraw+xml', + cdy: 'application/vnd.cinderella', + cer: 'application/pkix-cert', + cfs: 'application/x-cfs-compressed', + cgm: 'image/cgm', + chat: 'application/x-chat', + chm: 'application/vnd.ms-htmlhelp', + chrt: 'application/vnd.kde.kchart', + cif: 'chemical/x-cif', + cii: 'application/vnd.anser-web-certificate-issue-initiation', + cil: 'application/vnd.ms-artgalry', + cjs: 'application/node', + cla: 'application/vnd.claymore', + class: 'application/java-vm', + clkk: 'application/vnd.crick.clicker.keyboard', + clkp: 'application/vnd.crick.clicker.palette', + clkt: 'application/vnd.crick.clicker.template', + clkw: 'application/vnd.crick.clicker.wordbank', + clkx: 'application/vnd.crick.clicker', + clp: 'application/x-msclip', + cmc: 'application/vnd.cosmocaller', + cmdf: 'chemical/x-cmdf', + cml: 'chemical/x-cml', + cmp: 'application/vnd.yellowriver-custom-menu', + cmx: 'image/x-cmx', + cod: 'application/vnd.rim.cod', + coffee: 'text/coffeescript', + com: 'application/x-msdownload', + conf: 'text/plain', + cpio: 'application/x-cpio', + cpp: 'text/x-c', + cpt: 'application/mac-compactpro', + crd: 'application/x-mscardfile', + crl: 'application/pkix-crl', + crt: 'application/x-x509-ca-cert', + crx: 'application/x-chrome-extension', + cryptonote: 'application/vnd.rig.cryptonote', + csh: 'application/x-csh', + csl: 'application/vnd.citationstyles.style+xml', + csml: 'chemical/x-csml', + csp: 'application/vnd.commonspace', + css: 'text/css', + cst: 'application/x-director', + csv: 'text/csv', + cu: 'application/cu-seeme', + curl: 'text/vnd.curl', + cww: 'application/prs.cww', + cxt: 'application/x-director', + cxx: 'text/x-c', + dae: 'model/vnd.collada+xml', + daf: 'application/vnd.mobius.daf', + dart: 'application/vnd.dart', + dataless: 'application/vnd.fdsn.seed', + davmount: 'application/davmount+xml', + dbf: 'application/vnd.dbf', + dbk: 'application/docbook+xml', + dcr: 'application/x-director', + dcurl: 'text/vnd.curl.dcurl', + dd2: 'application/vnd.oma.dd2+xml', + ddd: 'application/vnd.fujixerox.ddd', + ddf: 'application/vnd.syncml.dmddf+xml', + dds: 'image/vnd.ms-dds', + deb: 'application/x-debian-package', + def: 'text/plain', + deploy: 'application/octet-stream', + der: 'application/x-x509-ca-cert', + dfac: 'application/vnd.dreamfactory', + dgc: 'application/x-dgc-compressed', + dic: 'text/x-c', + dir: 'application/x-director', + dis: 'application/vnd.mobius.dis', + 'disposition-notification': 'message/disposition-notification', + dist: 'application/octet-stream', + distz: 'application/octet-stream', + djv: 'image/vnd.djvu', + djvu: 'image/vnd.djvu', + dll: 'application/x-msdownload', + dmg: 'application/x-apple-diskimage', + dmp: 'application/vnd.tcpdump.pcap', + dms: 'application/octet-stream', + dna: 'application/vnd.dna', + doc: 'application/msword', + docm: 'application/vnd.ms-word.document.macroenabled.12', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + dot: 'application/msword', + dotm: 'application/vnd.ms-word.template.macroenabled.12', + dotx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', + dp: 'application/vnd.osgi.dp', + dpg: 'application/vnd.dpgraph', + dra: 'audio/vnd.dra', + drle: 'image/dicom-rle', + dsc: 'text/prs.lines.tag', + dssc: 'application/dssc+der', + dtb: 'application/x-dtbook+xml', + dtd: 'application/xml-dtd', + dts: 'audio/vnd.dts', + dtshd: 'audio/vnd.dts.hd', + dump: 'application/octet-stream', + dvb: 'video/vnd.dvb.file', + dvi: 'application/x-dvi', + dwd: 'application/atsc-dwd+xml', + dwf: 'model/vnd.dwf', + dwg: 'image/vnd.dwg', + dxf: 'image/vnd.dxf', + dxp: 'application/vnd.spotfire.dxp', + dxr: 'application/x-director', + ear: 'application/java-archive', + ecelp4800: 'audio/vnd.nuera.ecelp4800', + ecelp7470: 'audio/vnd.nuera.ecelp7470', + ecelp9600: 'audio/vnd.nuera.ecelp9600', + ecma: 'application/ecmascript', + edm: 'application/vnd.novadigm.edm', + edx: 'application/vnd.novadigm.edx', + efif: 'application/vnd.picsel', + ei6: 'application/vnd.pg.osasli', + elc: 'application/octet-stream', + emf: 'image/emf', + eml: 'message/rfc822', + emma: 'application/emma+xml', + emotionml: 'application/emotionml+xml', + emz: 'application/x-msmetafile', + eol: 'audio/vnd.digital-winds', + eot: 'application/vnd.ms-fontobject', + eps: 'application/postscript', + epub: 'application/epub+zip', + es: 'application/ecmascript', + es3: 'application/vnd.eszigno3+xml', + esa: 'application/vnd.osgi.subsystem', + esf: 'application/vnd.epson.esf', + et3: 'application/vnd.eszigno3+xml', + etx: 'text/x-setext', + eva: 'application/x-eva', + evy: 'application/x-envoy', + exe: 'application/x-msdownload', + exi: 'application/exi', + exp: 'application/express', + exr: 'image/aces', + ext: 'application/vnd.novadigm.ext', + ez: 'application/andrew-inset', + ez2: 'application/vnd.ezpix-album', + ez3: 'application/vnd.ezpix-package', + f: 'text/x-fortran', + f4v: 'video/x-f4v', + f77: 'text/x-fortran', + f90: 'text/x-fortran', + fbs: 'image/vnd.fastbidsheet', + fcdt: 'application/vnd.adobe.formscentral.fcdt', + fcs: 'application/vnd.isac.fcs', + fdf: 'application/vnd.fdf', + fdt: 'application/fdt+xml', + fe_launch: 'application/vnd.denovo.fcselayout-link', + fg5: 'application/vnd.fujitsu.oasysgp', + fgd: 'application/x-director', + fh: 'image/x-freehand', + fh4: 'image/x-freehand', + fh5: 'image/x-freehand', + fh7: 'image/x-freehand', + fhc: 'image/x-freehand', + fig: 'application/x-xfig', + fits: 'image/fits', + flac: 'audio/x-flac', + fli: 'video/x-fli', + flo: 'application/vnd.micrografx.flo', + flv: 'video/x-flv', + flw: 'application/vnd.kde.kivio', + flx: 'text/vnd.fmi.flexstor', + fly: 'text/vnd.fly', + fm: 'application/vnd.framemaker', + fnc: 'application/vnd.frogans.fnc', + fo: 'application/vnd.software602.filler.form+xml', + for: 'text/x-fortran', + fpx: 'image/vnd.fpx', + frame: 'application/vnd.framemaker', + fsc: 'application/vnd.fsc.weblaunch', + fst: 'image/vnd.fst', + ftc: 'application/vnd.fluxtime.clip', + fti: 'application/vnd.anser-web-funds-transfer-initiation', + fvt: 'video/vnd.fvt', + fxp: 'application/vnd.adobe.fxp', + fxpl: 'application/vnd.adobe.fxp', + fzs: 'application/vnd.fuzzysheet', + g2w: 'application/vnd.geoplan', + g3: 'image/g3fax', + g3w: 'application/vnd.geospace', + gac: 'application/vnd.groove-account', + gam: 'application/x-tads', + gbr: 'application/rpki-ghostbusters', + gca: 'application/x-gca-compressed', + gdl: 'model/vnd.gdl', + gdoc: 'application/vnd.google-apps.document', + ged: 'text/vnd.familysearch.gedcom', + geo: 'application/vnd.dynageo', + geojson: 'application/geo+json', + gex: 'application/vnd.geometry-explorer', + ggb: 'application/vnd.geogebra.file', + ggt: 'application/vnd.geogebra.tool', + ghf: 'application/vnd.groove-help', + gif: 'image/gif', + gim: 'application/vnd.groove-identity-message', + glb: 'model/gltf-binary', + gltf: 'model/gltf+json', + gml: 'application/gml+xml', + gmx: 'application/vnd.gmx', + gnumeric: 'application/x-gnumeric', + gph: 'application/vnd.flographit', + gpx: 'application/gpx+xml', + gqf: 'application/vnd.grafeq', + gqs: 'application/vnd.grafeq', + gram: 'application/srgs', + gramps: 'application/x-gramps-xml', + gre: 'application/vnd.geometry-explorer', + grv: 'application/vnd.groove-injector', + grxml: 'application/srgs+xml', + gsf: 'application/x-font-ghostscript', + gsheet: 'application/vnd.google-apps.spreadsheet', + gslides: 'application/vnd.google-apps.presentation', + gtar: 'application/x-gtar', + gtm: 'application/vnd.groove-tool-message', + gtw: 'model/vnd.gtw', + gv: 'text/vnd.graphviz', + gxf: 'application/gxf', + gxt: 'application/vnd.geonext', + gz: 'application/gzip', + h: 'text/x-c', + h261: 'video/h261', + h263: 'video/h263', + h264: 'video/h264', + hal: 'application/vnd.hal+xml', + hbci: 'application/vnd.hbci', + hbs: 'text/x-handlebars-template', + hdd: 'application/x-virtualbox-hdd', + hdf: 'application/x-hdf', + heic: 'image/heic', + heics: 'image/heic-sequence', + heif: 'image/heif', + heifs: 'image/heif-sequence', + hej2: 'image/hej2k', + held: 'application/atsc-held+xml', + hh: 'text/x-c', + hjson: 'application/hjson', + hlp: 'application/winhlp', + hpgl: 'application/vnd.hp-hpgl', + hpid: 'application/vnd.hp-hpid', + hps: 'application/vnd.hp-hps', + hqx: 'application/mac-binhex40', + hsj2: 'image/hsj2', + htc: 'text/x-component', + htke: 'application/vnd.kenameaapp', + htm: 'text/html', + html: 'text/html', + hvd: 'application/vnd.yamaha.hv-dic', + hvp: 'application/vnd.yamaha.hv-voice', + hvs: 'application/vnd.yamaha.hv-script', + i2g: 'application/vnd.intergeo', + icc: 'application/vnd.iccprofile', + ice: 'x-conference/x-cooltalk', + icm: 'application/vnd.iccprofile', + ico: 'image/x-icon', + ics: 'text/calendar', + ief: 'image/ief', + ifb: 'text/calendar', + ifm: 'application/vnd.shana.informed.formdata', + iges: 'model/iges', + igl: 'application/vnd.igloader', + igm: 'application/vnd.insors.igm', + igs: 'model/iges', + igx: 'application/vnd.micrografx.igx', + iif: 'application/vnd.shana.informed.interchange', + img: 'application/octet-stream', + imp: 'application/vnd.accpac.simply.imp', + ims: 'application/vnd.ms-ims', + in: 'text/plain', + ini: 'text/plain', + ink: 'application/inkml+xml', + inkml: 'application/inkml+xml', + install: 'application/x-install-instructions', + iota: 'application/vnd.astraea-software.iota', + ipfix: 'application/ipfix', + ipk: 'application/vnd.shana.informed.package', + irm: 'application/vnd.ibm.rights-management', + irp: 'application/vnd.irepository.package+xml', + iso: 'application/x-iso9660-image', + itp: 'application/vnd.shana.informed.formtemplate', + its: 'application/its+xml', + ivp: 'application/vnd.immervision-ivp', + ivu: 'application/vnd.immervision-ivu', + jad: 'text/vnd.sun.j2me.app-descriptor', + jade: 'text/jade', + jam: 'application/vnd.jam', + jar: 'application/java-archive', + jardiff: 'application/x-java-archive-diff', + java: 'text/x-java-source', + jhc: 'image/jphc', + jisp: 'application/vnd.jisp', + jls: 'image/jls', + jlt: 'application/vnd.hp-jlyt', + jng: 'image/x-jng', + jnlp: 'application/x-java-jnlp-file', + joda: 'application/vnd.joost.joda-archive', + jp2: 'image/jp2', + jpe: 'image/jpeg', + jpeg: 'image/jpeg', + jpf: 'image/jpx', + jpg: 'image/jpeg', + jpg2: 'image/jp2', + jpgm: 'video/jpm', + jpgv: 'video/jpeg', + jph: 'image/jph', + jpm: 'video/jpm', + jpx: 'image/jpx', + js: 'application/javascript', + json: 'application/json', + json5: 'application/json5', + jsonld: 'application/ld+json', + jsonml: 'application/jsonml+json', + jsx: 'text/jsx', + jxr: 'image/jxr', + jxra: 'image/jxra', + jxrs: 'image/jxrs', + jxs: 'image/jxs', + jxsc: 'image/jxsc', + jxsi: 'image/jxsi', + jxss: 'image/jxss', + kar: 'audio/midi', + karbon: 'application/vnd.kde.karbon', + kdbx: 'application/x-keepass2', + key: 'application/x-iwork-keynote-sffkey', + kfo: 'application/vnd.kde.kformula', + kia: 'application/vnd.kidspiration', + kml: 'application/vnd.google-earth.kml+xml', + kmz: 'application/vnd.google-earth.kmz', + kne: 'application/vnd.kinar', + knp: 'application/vnd.kinar', + kon: 'application/vnd.kde.kontour', + kpr: 'application/vnd.kde.kpresenter', + kpt: 'application/vnd.kde.kpresenter', + kpxx: 'application/vnd.ds-keypoint', + ksp: 'application/vnd.kde.kspread', + ktr: 'application/vnd.kahootz', + ktx: 'image/ktx', + ktx2: 'image/ktx2', + ktz: 'application/vnd.kahootz', + kwd: 'application/vnd.kde.kword', + kwt: 'application/vnd.kde.kword', + lasxml: 'application/vnd.las.las+xml', + latex: 'application/x-latex', + lbd: 'application/vnd.llamagraphics.life-balance.desktop', + lbe: 'application/vnd.llamagraphics.life-balance.exchange+xml', + les: 'application/vnd.hhe.lesson-player', + less: 'text/less', + lgr: 'application/lgr+xml', + lha: 'application/x-lzh-compressed', + link66: 'application/vnd.route66.link66+xml', + list: 'text/plain', + list3820: 'application/vnd.ibm.modcap', + listafp: 'application/vnd.ibm.modcap', + litcoffee: 'text/coffeescript', + lnk: 'application/x-ms-shortcut', + log: 'text/plain', + lostxml: 'application/lost+xml', + lrf: 'application/octet-stream', + lrm: 'application/vnd.ms-lrm', + ltf: 'application/vnd.frogans.ltf', + lua: 'text/x-lua', + luac: 'application/x-lua-bytecode', + lvp: 'audio/vnd.lucent.voice', + lwp: 'application/vnd.lotus-wordpro', + lzh: 'application/x-lzh-compressed', + m13: 'application/x-msmediaview', + m14: 'application/x-msmediaview', + m1v: 'video/mpeg', + m21: 'application/mp21', + m2a: 'audio/mpeg', + m2v: 'video/mpeg', + m3a: 'audio/mpeg', + m3u: 'audio/x-mpegurl', + m3u8: 'application/vnd.apple.mpegurl', + m4a: 'audio/x-m4a', + m4p: 'application/mp4', + m4s: 'video/iso.segment', + m4u: 'video/vnd.mpegurl', + m4v: 'video/x-m4v', + ma: 'application/mathematica', + mads: 'application/mads+xml', + maei: 'application/mmt-aei+xml', + mag: 'application/vnd.ecowin.chart', + maker: 'application/vnd.framemaker', + man: 'text/troff', + manifest: 'text/cache-manifest', + map: 'application/json', + mar: 'application/octet-stream', + markdown: 'text/markdown', + mathml: 'application/mathml+xml', + mb: 'application/mathematica', + mbk: 'application/vnd.mobius.mbk', + mbox: 'application/mbox', + mc1: 'application/vnd.medcalcdata', + mcd: 'application/vnd.mcd', + mcurl: 'text/vnd.curl.mcurl', + md: 'text/markdown', + mdb: 'application/x-msaccess', + mdi: 'image/vnd.ms-modi', + mdx: 'text/mdx', + me: 'text/troff', + mesh: 'model/mesh', + meta4: 'application/metalink4+xml', + metalink: 'application/metalink+xml', + mets: 'application/mets+xml', + mfm: 'application/vnd.mfmp', + mft: 'application/rpki-manifest', + mgp: 'application/vnd.osgeo.mapguide.package', + mgz: 'application/vnd.proteus.magazine', + mid: 'audio/midi', + midi: 'audio/midi', + mie: 'application/x-mie', + mif: 'application/vnd.mif', + mime: 'message/rfc822', + mj2: 'video/mj2', + mjp2: 'video/mj2', + mjs: 'application/javascript', + mk3d: 'video/x-matroska', + mka: 'audio/x-matroska', + mkd: 'text/x-markdown', + mks: 'video/x-matroska', + mkv: 'video/x-matroska', + mlp: 'application/vnd.dolby.mlp', + mmd: 'application/vnd.chipnuts.karaoke-mmd', + mmf: 'application/vnd.smaf', + mml: 'text/mathml', + mmr: 'image/vnd.fujixerox.edmics-mmr', + mng: 'video/x-mng', + mny: 'application/x-msmoney', + mobi: 'application/x-mobipocket-ebook', + mods: 'application/mods+xml', + mov: 'video/quicktime', + movie: 'video/x-sgi-movie', + mp2: 'audio/mpeg', + mp21: 'application/mp21', + mp2a: 'audio/mpeg', + mp3: 'audio/mpeg', + mp4: 'video/mp4', + mp4a: 'audio/mp4', + mp4s: 'application/mp4', + mp4v: 'video/mp4', + mpc: 'application/vnd.mophun.certificate', + mpd: 'application/dash+xml', + mpe: 'video/mpeg', + mpeg: 'video/mpeg', + mpg: 'video/mpeg', + mpg4: 'video/mp4', + mpga: 'audio/mpeg', + mpkg: 'application/vnd.apple.installer+xml', + mpm: 'application/vnd.blueice.multipass', + mpn: 'application/vnd.mophun.application', + mpp: 'application/vnd.ms-project', + mpt: 'application/vnd.ms-project', + mpy: 'application/vnd.ibm.minipay', + mqy: 'application/vnd.mobius.mqy', + mrc: 'application/marc', + mrcx: 'application/marcxml+xml', + ms: 'text/troff', + mscml: 'application/mediaservercontrol+xml', + mseed: 'application/vnd.fdsn.mseed', + mseq: 'application/vnd.mseq', + msf: 'application/vnd.epson.msf', + msg: 'application/vnd.ms-outlook', + msh: 'model/mesh', + msi: 'application/x-msdownload', + msl: 'application/vnd.mobius.msl', + msm: 'application/octet-stream', + msp: 'application/octet-stream', + msty: 'application/vnd.muvee.style', + mtl: 'model/mtl', + mts: 'model/vnd.mts', + mus: 'application/vnd.musician', + musd: 'application/mmt-usd+xml', + musicxml: 'application/vnd.recordare.musicxml+xml', + mvb: 'application/x-msmediaview', + mvt: 'application/vnd.mapbox-vector-tile', + mwf: 'application/vnd.mfer', + mxf: 'application/mxf', + mxl: 'application/vnd.recordare.musicxml', + mxmf: 'audio/mobile-xmf', + mxml: 'application/xv+xml', + mxs: 'application/vnd.triscape.mxs', + mxu: 'video/vnd.mpegurl', + 'n-gage': 'application/vnd.nokia.n-gage.symbian.install', + n3: 'text/n3', + nb: 'application/mathematica', + nbp: 'application/vnd.wolfram.player', + nc: 'application/x-netcdf', + ncx: 'application/x-dtbncx+xml', + nfo: 'text/x-nfo', + ngdat: 'application/vnd.nokia.n-gage.data', + nitf: 'application/vnd.nitf', + nlu: 'application/vnd.neurolanguage.nlu', + nml: 'application/vnd.enliven', + nnd: 'application/vnd.noblenet-directory', + nns: 'application/vnd.noblenet-sealer', + nnw: 'application/vnd.noblenet-web', + npx: 'image/vnd.net-fpx', + nq: 'application/n-quads', + nsc: 'application/x-conference', + nsf: 'application/vnd.lotus-notes', + nt: 'application/n-triples', + ntf: 'application/vnd.nitf', + numbers: 'application/x-iwork-numbers-sffnumbers', + nzb: 'application/x-nzb', + oa2: 'application/vnd.fujitsu.oasys2', + oa3: 'application/vnd.fujitsu.oasys3', + oas: 'application/vnd.fujitsu.oasys', + obd: 'application/x-msbinder', + obgx: 'application/vnd.openblox.game+xml', + obj: 'model/obj', + oda: 'application/oda', + odb: 'application/vnd.oasis.opendocument.database', + odc: 'application/vnd.oasis.opendocument.chart', + odf: 'application/vnd.oasis.opendocument.formula', + odft: 'application/vnd.oasis.opendocument.formula-template', + odg: 'application/vnd.oasis.opendocument.graphics', + odi: 'application/vnd.oasis.opendocument.image', + odm: 'application/vnd.oasis.opendocument.text-master', + odp: 'application/vnd.oasis.opendocument.presentation', + ods: 'application/vnd.oasis.opendocument.spreadsheet', + odt: 'application/vnd.oasis.opendocument.text', + oga: 'audio/ogg', + ogex: 'model/vnd.opengex', + ogg: 'audio/ogg', + ogv: 'video/ogg', + ogx: 'application/ogg', + omdoc: 'application/omdoc+xml', + onepkg: 'application/onenote', + onetmp: 'application/onenote', + onetoc: 'application/onenote', + onetoc2: 'application/onenote', + opf: 'application/oebps-package+xml', + opml: 'text/x-opml', + oprc: 'application/vnd.palm', + opus: 'audio/ogg', + org: 'text/x-org', + osf: 'application/vnd.yamaha.openscoreformat', + osfpvg: 'application/vnd.yamaha.openscoreformat.osfpvg+xml', + osm: 'application/vnd.openstreetmap.data+xml', + otc: 'application/vnd.oasis.opendocument.chart-template', + otf: 'font/otf', + otg: 'application/vnd.oasis.opendocument.graphics-template', + oth: 'application/vnd.oasis.opendocument.text-web', + oti: 'application/vnd.oasis.opendocument.image-template', + otp: 'application/vnd.oasis.opendocument.presentation-template', + ots: 'application/vnd.oasis.opendocument.spreadsheet-template', + ott: 'application/vnd.oasis.opendocument.text-template', + ova: 'application/x-virtualbox-ova', + ovf: 'application/x-virtualbox-ovf', + owl: 'application/rdf+xml', + oxps: 'application/oxps', + oxt: 'application/vnd.openofficeorg.extension', + p: 'text/x-pascal', + p10: 'application/pkcs10', + p12: 'application/x-pkcs12', + p7b: 'application/x-pkcs7-certificates', + p7c: 'application/pkcs7-mime', + p7m: 'application/pkcs7-mime', + p7r: 'application/x-pkcs7-certreqresp', + p7s: 'application/pkcs7-signature', + p8: 'application/pkcs8', + pac: 'application/x-ns-proxy-autoconfig', + pages: 'application/x-iwork-pages-sffpages', + pas: 'text/x-pascal', + paw: 'application/vnd.pawaafile', + pbd: 'application/vnd.powerbuilder6', + pbm: 'image/x-portable-bitmap', + pcap: 'application/vnd.tcpdump.pcap', + pcf: 'application/x-font-pcf', + pcl: 'application/vnd.hp-pcl', + pclxl: 'application/vnd.hp-pclxl', + pct: 'image/x-pict', + pcurl: 'application/vnd.curl.pcurl', + pcx: 'image/x-pcx', + pdb: 'application/x-pilot', + pde: 'text/x-processing', + pdf: 'application/pdf', + pem: 'application/x-x509-ca-cert', + pfa: 'application/x-font-type1', + pfb: 'application/x-font-type1', + pfm: 'application/x-font-type1', + pfr: 'application/font-tdpfr', + pfx: 'application/x-pkcs12', + pgm: 'image/x-portable-graymap', + pgn: 'application/x-chess-pgn', + pgp: 'application/pgp-encrypted', + php: 'application/x-httpd-php', + pic: 'image/x-pict', + pkg: 'application/octet-stream', + pki: 'application/pkixcmp', + pkipath: 'application/pkix-pkipath', + pkpass: 'application/vnd.apple.pkpass', + pl: 'application/x-perl', + plb: 'application/vnd.3gpp.pic-bw-large', + plc: 'application/vnd.mobius.plc', + plf: 'application/vnd.pocketlearn', + pls: 'application/pls+xml', + pm: 'application/x-perl', + pml: 'application/vnd.ctc-posml', + png: 'image/png', + pnm: 'image/x-portable-anymap', + portpkg: 'application/vnd.macports.portpkg', + pot: 'application/vnd.ms-powerpoint', + potm: 'application/vnd.ms-powerpoint.template.macroenabled.12', + potx: 'application/vnd.openxmlformats-officedocument.presentationml.template', + ppam: 'application/vnd.ms-powerpoint.addin.macroenabled.12', + ppd: 'application/vnd.cups-ppd', + ppm: 'image/x-portable-pixmap', + pps: 'application/vnd.ms-powerpoint', + ppsm: 'application/vnd.ms-powerpoint.slideshow.macroenabled.12', + ppsx: 'application/vnd.openxmlformats-officedocument.presentationml.slideshow', + ppt: 'application/vnd.ms-powerpoint', + pptm: 'application/vnd.ms-powerpoint.presentation.macroenabled.12', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + pqa: 'application/vnd.palm', + prc: 'application/x-pilot', + pre: 'application/vnd.lotus-freelance', + prf: 'application/pics-rules', + provx: 'application/provenance+xml', + ps: 'application/postscript', + psb: 'application/vnd.3gpp.pic-bw-small', + psd: 'image/vnd.adobe.photoshop', + psf: 'application/x-font-linux-psf', + pskcxml: 'application/pskc+xml', + pti: 'image/prs.pti', + ptid: 'application/vnd.pvi.ptid1', + pub: 'application/x-mspublisher', + pvb: 'application/vnd.3gpp.pic-bw-var', + pwn: 'application/vnd.3m.post-it-notes', + pya: 'audio/vnd.ms-playready.media.pya', + pyv: 'video/vnd.ms-playready.media.pyv', + qam: 'application/vnd.epson.quickanime', + qbo: 'application/vnd.intu.qbo', + qfx: 'application/vnd.intu.qfx', + qps: 'application/vnd.publishare-delta-tree', + qt: 'video/quicktime', + qwd: 'application/vnd.quark.quarkxpress', + qwt: 'application/vnd.quark.quarkxpress', + qxb: 'application/vnd.quark.quarkxpress', + qxd: 'application/vnd.quark.quarkxpress', + qxl: 'application/vnd.quark.quarkxpress', + qxt: 'application/vnd.quark.quarkxpress', + ra: 'audio/x-realaudio', + ram: 'audio/x-pn-realaudio', + raml: 'application/raml+yaml', + rapd: 'application/route-apd+xml', + rar: 'application/x-rar-compressed', + ras: 'image/x-cmu-raster', + rcprofile: 'application/vnd.ipunplugged.rcprofile', + rdf: 'application/rdf+xml', + rdz: 'application/vnd.data-vision.rdz', + relo: 'application/p2p-overlay+xml', + rep: 'application/vnd.businessobjects', + res: 'application/x-dtbresource+xml', + rgb: 'image/x-rgb', + rif: 'application/reginfo+xml', + rip: 'audio/vnd.rip', + ris: 'application/x-research-info-systems', + rl: 'application/resource-lists+xml', + rlc: 'image/vnd.fujixerox.edmics-rlc', + rld: 'application/resource-lists-diff+xml', + rm: 'application/vnd.rn-realmedia', + rmi: 'audio/midi', + rmp: 'audio/x-pn-realaudio-plugin', + rms: 'application/vnd.jcp.javame.midlet-rms', + rmvb: 'application/vnd.rn-realmedia-vbr', + rnc: 'application/relax-ng-compact-syntax', + rng: 'application/xml', + roa: 'application/rpki-roa', + roff: 'text/troff', + rp9: 'application/vnd.cloanto.rp9', + rpm: 'application/x-redhat-package-manager', + rpss: 'application/vnd.nokia.radio-presets', + rpst: 'application/vnd.nokia.radio-preset', + rq: 'application/sparql-query', + rs: 'application/rls-services+xml', + rsat: 'application/atsc-rsat+xml', + rsd: 'application/rsd+xml', + rsheet: 'application/urc-ressheet+xml', + rss: 'application/rss+xml', + rtf: 'text/rtf', + rtx: 'text/richtext', + run: 'application/x-makeself', + rusd: 'application/route-usd+xml', + s: 'text/x-asm', + s3m: 'audio/s3m', + saf: 'application/vnd.yamaha.smaf-audio', + sass: 'text/x-sass', + sbml: 'application/sbml+xml', + sc: 'application/vnd.ibm.secure-container', + scd: 'application/x-msschedule', + scm: 'application/vnd.lotus-screencam', + scq: 'application/scvp-cv-request', + scs: 'application/scvp-cv-response', + scss: 'text/x-scss', + scurl: 'text/vnd.curl.scurl', + sda: 'application/vnd.stardivision.draw', + sdc: 'application/vnd.stardivision.calc', + sdd: 'application/vnd.stardivision.impress', + sdkd: 'application/vnd.solent.sdkm+xml', + sdkm: 'application/vnd.solent.sdkm+xml', + sdp: 'application/sdp', + sdw: 'application/vnd.stardivision.writer', + sea: 'application/x-sea', + see: 'application/vnd.seemail', + seed: 'application/vnd.fdsn.seed', + sema: 'application/vnd.sema', + semd: 'application/vnd.semd', + semf: 'application/vnd.semf', + senmlx: 'application/senml+xml', + sensmlx: 'application/sensml+xml', + ser: 'application/java-serialized-object', + setpay: 'application/set-payment-initiation', + setreg: 'application/set-registration-initiation', + 'sfd-hdstx': 'application/vnd.hydrostatix.sof-data', + sfs: 'application/vnd.spotfire.sfs', + sfv: 'text/x-sfv', + sgi: 'image/sgi', + sgl: 'application/vnd.stardivision.writer-global', + sgm: 'text/sgml', + sgml: 'text/sgml', + sh: 'application/x-sh', + shar: 'application/x-shar', + shex: 'text/shex', + shf: 'application/shf+xml', + shtml: 'text/html', + sid: 'image/x-mrsid-image', + sieve: 'application/sieve', + sig: 'application/pgp-signature', + sil: 'audio/silk', + silo: 'model/mesh', + sis: 'application/vnd.symbian.install', + sisx: 'application/vnd.symbian.install', + sit: 'application/x-stuffit', + sitx: 'application/x-stuffitx', + siv: 'application/sieve', + skd: 'application/vnd.koan', + skm: 'application/vnd.koan', + skp: 'application/vnd.koan', + skt: 'application/vnd.koan', + sldm: 'application/vnd.ms-powerpoint.slide.macroenabled.12', + sldx: 'application/vnd.openxmlformats-officedocument.presentationml.slide', + slim: 'text/slim', + slm: 'text/slim', + sls: 'application/route-s-tsid+xml', + slt: 'application/vnd.epson.salt', + sm: 'application/vnd.stepmania.stepchart', + smf: 'application/vnd.stardivision.math', + smi: 'application/smil+xml', + smil: 'application/smil+xml', + smv: 'video/x-smv', + smzip: 'application/vnd.stepmania.package', + snd: 'audio/basic', + snf: 'application/x-font-snf', + so: 'application/octet-stream', + spc: 'application/x-pkcs7-certificates', + spdx: 'text/spdx', + spf: 'application/vnd.yamaha.smaf-phrase', + spl: 'application/x-futuresplash', + spot: 'text/vnd.in3d.spot', + spp: 'application/scvp-vp-response', + spq: 'application/scvp-vp-request', + spx: 'audio/ogg', + sql: 'application/x-sql', + src: 'application/x-wais-source', + srt: 'application/x-subrip', + sru: 'application/sru+xml', + srx: 'application/sparql-results+xml', + ssdl: 'application/ssdl+xml', + sse: 'application/vnd.kodak-descriptor', + ssf: 'application/vnd.epson.ssf', + ssml: 'application/ssml+xml', + st: 'application/vnd.sailingtracker.track', + stc: 'application/vnd.sun.xml.calc.template', + std: 'application/vnd.sun.xml.draw.template', + stf: 'application/vnd.wt.stf', + sti: 'application/vnd.sun.xml.impress.template', + stk: 'application/hyperstudio', + stl: 'model/stl', + stpx: 'model/step+xml', + stpxz: 'model/step-xml+zip', + stpz: 'model/step+zip', + str: 'application/vnd.pg.format', + stw: 'application/vnd.sun.xml.writer.template', + styl: 'text/stylus', + stylus: 'text/stylus', + sub: 'text/vnd.dvb.subtitle', + sus: 'application/vnd.sus-calendar', + susp: 'application/vnd.sus-calendar', + sv4cpio: 'application/x-sv4cpio', + sv4crc: 'application/x-sv4crc', + svc: 'application/vnd.dvb.service', + svd: 'application/vnd.svd', + svg: 'image/svg+xml', + svgz: 'image/svg+xml', + swa: 'application/x-director', + swf: 'application/x-shockwave-flash', + swi: 'application/vnd.aristanetworks.swi', + swidtag: 'application/swid+xml', + sxc: 'application/vnd.sun.xml.calc', + sxd: 'application/vnd.sun.xml.draw', + sxg: 'application/vnd.sun.xml.writer.global', + sxi: 'application/vnd.sun.xml.impress', + sxm: 'application/vnd.sun.xml.math', + sxw: 'application/vnd.sun.xml.writer', + t: 'text/troff', + t3: 'application/x-t3vm-image', + t38: 'image/t38', + taglet: 'application/vnd.mynfc', + tao: 'application/vnd.tao.intent-module-archive', + tap: 'image/vnd.tencent.tap', + tar: 'application/x-tar', + tcap: 'application/vnd.3gpp2.tcap', + tcl: 'application/x-tcl', + td: 'application/urc-targetdesc+xml', + teacher: 'application/vnd.smart.teacher', + tei: 'application/tei+xml', + teicorpus: 'application/tei+xml', + tex: 'application/x-tex', + texi: 'application/x-texinfo', + texinfo: 'application/x-texinfo', + text: 'text/plain', + tfi: 'application/thraud+xml', + tfm: 'application/x-tex-tfm', + tfx: 'image/tiff-fx', + tga: 'image/x-tga', + thmx: 'application/vnd.ms-officetheme', + tif: 'image/tiff', + tiff: 'image/tiff', + tk: 'application/x-tcl', + tmo: 'application/vnd.tmobile-livetv', + toml: 'application/toml', + torrent: 'application/x-bittorrent', + tpl: 'application/vnd.groove-tool-template', + tpt: 'application/vnd.trid.tpt', + tr: 'text/troff', + tra: 'application/vnd.trueapp', + trig: 'application/trig', + trm: 'application/x-msterminal', + ts: 'video/mp2t', + tsd: 'application/timestamped-data', + tsv: 'text/tab-separated-values', + ttc: 'font/collection', + ttf: 'font/ttf', + ttl: 'text/turtle', + ttml: 'application/ttml+xml', + twd: 'application/vnd.simtech-mindmapper', + twds: 'application/vnd.simtech-mindmapper', + txd: 'application/vnd.genomatix.tuxedo', + txf: 'application/vnd.mobius.txf', + txt: 'text/plain', + u32: 'application/x-authorware-bin', + u8dsn: 'message/global-delivery-status', + u8hdr: 'message/global-headers', + u8mdn: 'message/global-disposition-notification', + u8msg: 'message/global', + ubj: 'application/ubjson', + udeb: 'application/x-debian-package', + ufd: 'application/vnd.ufdl', + ufdl: 'application/vnd.ufdl', + ulx: 'application/x-glulx', + umj: 'application/vnd.umajin', + unityweb: 'application/vnd.unity', + uoml: 'application/vnd.uoml+xml', + uri: 'text/uri-list', + uris: 'text/uri-list', + urls: 'text/uri-list', + usdz: 'model/vnd.usdz+zip', + ustar: 'application/x-ustar', + utz: 'application/vnd.uiq.theme', + uu: 'text/x-uuencode', + uva: 'audio/vnd.dece.audio', + uvd: 'application/vnd.dece.data', + uvf: 'application/vnd.dece.data', + uvg: 'image/vnd.dece.graphic', + uvh: 'video/vnd.dece.hd', + uvi: 'image/vnd.dece.graphic', + uvm: 'video/vnd.dece.mobile', + uvp: 'video/vnd.dece.pd', + uvs: 'video/vnd.dece.sd', + uvt: 'application/vnd.dece.ttml+xml', + uvu: 'video/vnd.uvvu.mp4', + uvv: 'video/vnd.dece.video', + uvva: 'audio/vnd.dece.audio', + uvvd: 'application/vnd.dece.data', + uvvf: 'application/vnd.dece.data', + uvvg: 'image/vnd.dece.graphic', + uvvh: 'video/vnd.dece.hd', + uvvi: 'image/vnd.dece.graphic', + uvvm: 'video/vnd.dece.mobile', + uvvp: 'video/vnd.dece.pd', + uvvs: 'video/vnd.dece.sd', + uvvt: 'application/vnd.dece.ttml+xml', + uvvu: 'video/vnd.uvvu.mp4', + uvvv: 'video/vnd.dece.video', + uvvx: 'application/vnd.dece.unspecified', + uvvz: 'application/vnd.dece.zip', + uvx: 'application/vnd.dece.unspecified', + uvz: 'application/vnd.dece.zip', + vbox: 'application/x-virtualbox-vbox', + 'vbox-extpack': 'application/x-virtualbox-vbox-extpack', + vcard: 'text/vcard', + vcd: 'application/x-cdlink', + vcf: 'text/x-vcard', + vcg: 'application/vnd.groove-vcard', + vcs: 'text/x-vcalendar', + vcx: 'application/vnd.vcx', + vdi: 'application/x-virtualbox-vdi', + vds: 'model/vnd.sap.vds', + vhd: 'application/x-virtualbox-vhd', + vis: 'application/vnd.visionary', + viv: 'video/vnd.vivo', + vmdk: 'application/x-virtualbox-vmdk', + vob: 'video/x-ms-vob', + vor: 'application/vnd.stardivision.writer', + vox: 'application/x-authorware-bin', + vrml: 'model/vrml', + vsd: 'application/vnd.visio', + vsf: 'application/vnd.vsf', + vss: 'application/vnd.visio', + vst: 'application/vnd.visio', + vsw: 'application/vnd.visio', + vtf: 'image/vnd.valve.source.texture', + vtt: 'text/vtt', + vtu: 'model/vnd.vtu', + vxml: 'application/voicexml+xml', + w3d: 'application/x-director', + wad: 'application/x-doom', + wadl: 'application/vnd.sun.wadl+xml', + war: 'application/java-archive', + wasm: 'application/wasm', + wav: 'audio/x-wav', + wax: 'audio/x-ms-wax', + wbmp: 'image/vnd.wap.wbmp', + wbs: 'application/vnd.criticaltools.wbs+xml', + wbxml: 'application/vnd.wap.wbxml', + wcm: 'application/vnd.ms-works', + wdb: 'application/vnd.ms-works', + wdp: 'image/vnd.ms-photo', + weba: 'audio/webm', + webapp: 'application/x-web-app-manifest+json', + webm: 'video/webm', + webmanifest: 'application/manifest+json', + webp: 'image/webp', + wg: 'application/vnd.pmi.widget', + wgt: 'application/widget', + wks: 'application/vnd.ms-works', + wm: 'video/x-ms-wm', + wma: 'audio/x-ms-wma', + wmd: 'application/x-ms-wmd', + wmf: 'image/wmf', + wml: 'text/vnd.wap.wml', + wmlc: 'application/vnd.wap.wmlc', + wmls: 'text/vnd.wap.wmlscript', + wmlsc: 'application/vnd.wap.wmlscriptc', + wmv: 'video/x-ms-wmv', + wmx: 'video/x-ms-wmx', + wmz: 'application/x-msmetafile', + woff: 'font/woff', + woff2: 'font/woff2', + wpd: 'application/vnd.wordperfect', + wpl: 'application/vnd.ms-wpl', + wps: 'application/vnd.ms-works', + wqd: 'application/vnd.wqd', + wri: 'application/x-mswrite', + wrl: 'model/vrml', + wsc: 'message/vnd.wfa.wsc', + wsdl: 'application/wsdl+xml', + wspolicy: 'application/wspolicy+xml', + wtb: 'application/vnd.webturbo', + wvx: 'video/x-ms-wvx', + x32: 'application/x-authorware-bin', + x3d: 'model/x3d+xml', + x3db: 'model/x3d+fastinfoset', + x3dbz: 'model/x3d+binary', + x3dv: 'model/x3d-vrml', + x3dvz: 'model/x3d+vrml', + x3dz: 'model/x3d+xml', + x_b: 'model/vnd.parasolid.transmit.binary', + x_t: 'model/vnd.parasolid.transmit.text', + xaml: 'application/xaml+xml', + xap: 'application/x-silverlight-app', + xar: 'application/vnd.xara', + xav: 'application/xcap-att+xml', + xbap: 'application/x-ms-xbap', + xbd: 'application/vnd.fujixerox.docuworks.binder', + xbm: 'image/x-xbitmap', + xca: 'application/xcap-caps+xml', + xcs: 'application/calendar+xml', + xdf: 'application/xcap-diff+xml', + xdm: 'application/vnd.syncml.dm+xml', + xdp: 'application/vnd.adobe.xdp+xml', + xdssc: 'application/dssc+xml', + xdw: 'application/vnd.fujixerox.docuworks', + xel: 'application/xcap-el+xml', + xenc: 'application/xenc+xml', + xer: 'application/patch-ops-error+xml', + xfdf: 'application/vnd.adobe.xfdf', + xfdl: 'application/vnd.xfdl', + xht: 'application/xhtml+xml', + xhtml: 'application/xhtml+xml', + xhvml: 'application/xv+xml', + xif: 'image/vnd.xiff', + xla: 'application/vnd.ms-excel', + xlam: 'application/vnd.ms-excel.addin.macroenabled.12', + xlc: 'application/vnd.ms-excel', + xlf: 'application/xliff+xml', + xlm: 'application/vnd.ms-excel', + xls: 'application/vnd.ms-excel', + xlsb: 'application/vnd.ms-excel.sheet.binary.macroenabled.12', + xlsm: 'application/vnd.ms-excel.sheet.macroenabled.12', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + xlt: 'application/vnd.ms-excel', + xltm: 'application/vnd.ms-excel.template.macroenabled.12', + xltx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', + xlw: 'application/vnd.ms-excel', + xm: 'audio/xm', + xml: 'text/xml', + xns: 'application/xcap-ns+xml', + xo: 'application/vnd.olpc-sugar', + xop: 'application/xop+xml', + xpi: 'application/x-xpinstall', + xpl: 'application/xproc+xml', + xpm: 'image/x-xpixmap', + xpr: 'application/vnd.is-xpr', + xps: 'application/vnd.ms-xpsdocument', + xpw: 'application/vnd.intercon.formnet', + xpx: 'application/vnd.intercon.formnet', + xsd: 'application/xml', + xsl: 'application/xslt+xml', + xslt: 'application/xslt+xml', + xsm: 'application/vnd.syncml+xml', + xspf: 'application/xspf+xml', + xul: 'application/vnd.mozilla.xul+xml', + xvm: 'application/xv+xml', + xvml: 'application/xv+xml', + xwd: 'image/x-xwindowdump', + xyz: 'chemical/x-xyz', + xz: 'application/x-xz', + yaml: 'text/yaml', + yang: 'application/yang', + yin: 'application/yin+xml', + yml: 'text/yaml', + ymp: 'text/x-suse-ymp', + z1: 'application/x-zmachine', + z2: 'application/x-zmachine', + z3: 'application/x-zmachine', + z4: 'application/x-zmachine', + z5: 'application/x-zmachine', + z6: 'application/x-zmachine', + z7: 'application/x-zmachine', + z8: 'application/x-zmachine', + zaz: 'application/vnd.zzazz.deck+xml', + zip: 'application/zip', + zir: 'application/vnd.zul', + zirz: 'application/vnd.zul', + zmm: 'application/vnd.handheld-entertainment+xml', +} diff --git a/src/lib/link-meta/twitter.ts b/src/lib/link-meta/twitter.ts new file mode 100644 index 000000000..d785903c0 --- /dev/null +++ b/src/lib/link-meta/twitter.ts @@ -0,0 +1,20 @@ +export const extractTwitterMeta = ({ + pathname, +}: { + pathname: string +}): Record => { + const res = {title: 'Twitter'} + const parsedPathname = pathname.split('/') + if (parsedPathname.length <= 1 || parsedPathname[1].length <= 1) { + // Excluding one letter usernames as they're reserved by twitter for things like cases like twitter.com/i/articles/follows/-1675653703 + return res + } + const username = parsedPathname?.[1] + const isUserProfile = parsedPathname?.length === 2 + + res.title = isUserProfile + ? `@${username} on Twitter` + : `Tweet by @${username}` + + return res +} diff --git a/src/lib/link-meta/youtube.ts b/src/lib/link-meta/youtube.ts new file mode 100644 index 000000000..42eed51e8 --- /dev/null +++ b/src/lib/link-meta/youtube.ts @@ -0,0 +1,31 @@ +export const extractYoutubeMeta = (html: string): Record => { + const res: Record = {} + const youtubeTitleRegex = /"videoDetails":.*"title":"([^"]*)"/i + 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]) + } + if (youtubeDescriptionMatch && youtubeDescriptionMatch.length >= 1) { + res.description = decodeURI(youtubeDescriptionMatch[1]).replace( + /\\n/g, + '\n', + ) + } + 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 { + const status = await check(perm) + return isntANo(status) +} + +export async function requestAccessIfNeeded( + perm: Permission, +): Promise { + 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 { + return true +} + +export async function requestAccessIfNeeded(_perm: any): Promise { + 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 { + 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 { + try { + await AsyncStorage.setItem(key, value) + return true + } catch { + return false + } +} + +export async function load(key: string): Promise { + 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 { + try { + await AsyncStorage.setItem(key, JSON.stringify(value)) + return true + } catch { + return false + } +} + +export async function remove(key: string): Promise { + try { + await AsyncStorage.removeItem(key) + } catch {} +} + +export async function clear(): Promise { + 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, -): 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]+)|((?[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|\()(?[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, +): 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]+)|((?[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|\()(?[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( + base: StyleProp, + addedStyle: StyleProp, +): StyleProp { + 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 { + return !!v && typeof v === 'object' +} + +export function hasProp( + data: object, + prop: K, +): data is Record { + return prop in data +} + +export function isStrArray(v: unknown): v is string[] { + return Array.isArray(v) && v.every(item => typeof item === 'string') +} -- cgit 1.4.1