diff options
author | Jaz <ericvolp12@gmail.com> | 2023-05-30 18:25:29 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-05-30 18:25:29 -0700 |
commit | 09ade363fdcfadb03433385e0c5510bc58438a65 (patch) | |
tree | 710af28d1eb7f70acf81f86acb44759439e164fc /src/lib | |
parent | 7f76c2d67e62ba2d10e8b17673a7bbcf7248564f (diff) | |
parent | e224569a11b82361d782324a63bdfc19d44a3201 (diff) | |
download | voidsky-09ade363fdcfadb03433385e0c5510bc58438a65.tar.zst |
Merge branch 'main' into inherit_system_theme
Diffstat (limited to 'src/lib')
27 files changed, 542 insertions, 197 deletions
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 7f31fb292..035b36096 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -143,9 +143,6 @@ export class FeedTuner { } } - // sort by slice roots' timestamps - slices.sort((a, b) => b.ts.localeCompare(a.ts)) - for (const slice of slices) { for (const item of slice.items) { this.seenUris.add(item.post.uri) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 3877b3ef7..6235ca343 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -18,6 +18,7 @@ export interface ExternalEmbedDraft { uri: string isLoading: boolean meta?: LinkMeta + embed?: AppBskyEmbedRecord.Main localThumb?: ImageModel } @@ -109,6 +110,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { const images: AppBskyEmbedImages.Image[] = [] for (const image of opts.images) { opts.onStateChange?.(`Uploading image #${images.length + 1}...`) + await image.compress() const path = image.compressed?.path ?? image.path const res = await uploadBlob(store, path, 'image/jpeg') images.push({ @@ -135,40 +137,54 @@ export async function post(store: RootStoreModel, opts: PostOpts) { } if (opts.extLink && !opts.images?.length) { - let thumb - if (opts.extLink.localThumb) { - opts.onStateChange?.('Uploading link thumbnail...') - let encoding - if (opts.extLink.localThumb.mime) { - encoding = opts.extLink.localThumb.mime - } else if (opts.extLink.localThumb.path.endsWith('.png')) { - encoding = 'image/png' - } else if ( - opts.extLink.localThumb.path.endsWith('.jpeg') || - opts.extLink.localThumb.path.endsWith('.jpg') - ) { - encoding = 'image/jpeg' - } else { - store.log.warn( - 'Unexpected image format for thumbnail, skipping', - opts.extLink.localThumb.path, - ) - } - if (encoding) { - const thumbUploadRes = await uploadBlob( - store, - opts.extLink.localThumb.path, - encoding, - ) - thumb = thumbUploadRes.data.blob + if (opts.extLink.embed) { + embed = opts.extLink.embed + } else { + let thumb + if (opts.extLink.localThumb) { + opts.onStateChange?.('Uploading link thumbnail...') + let encoding + if (opts.extLink.localThumb.mime) { + encoding = opts.extLink.localThumb.mime + } else if (opts.extLink.localThumb.path.endsWith('.png')) { + encoding = 'image/png' + } else if ( + opts.extLink.localThumb.path.endsWith('.jpeg') || + opts.extLink.localThumb.path.endsWith('.jpg') + ) { + encoding = 'image/jpeg' + } else { + store.log.warn( + 'Unexpected image format for thumbnail, skipping', + opts.extLink.localThumb.path, + ) + } + if (encoding) { + const thumbUploadRes = await uploadBlob( + store, + opts.extLink.localThumb.path, + encoding, + ) + thumb = thumbUploadRes.data.blob + } } - } - if (opts.quote) { - embed = { - $type: 'app.bsky.embed.recordWithMedia', - record: embed, - media: { + if (opts.quote) { + embed = { + $type: 'app.bsky.embed.recordWithMedia', + record: embed, + media: { + $type: 'app.bsky.embed.external', + external: { + uri: opts.extLink.uri, + title: opts.extLink.meta?.title || '', + description: opts.extLink.meta?.description || '', + thumb, + }, + } as AppBskyEmbedExternal.Main, + } as AppBskyEmbedRecordWithMedia.Main + } else { + embed = { $type: 'app.bsky.embed.external', external: { uri: opts.extLink.uri, @@ -176,18 +192,8 @@ export async function post(store: RootStoreModel, opts: PostOpts) { description: opts.extLink.meta?.description || '', thumb, }, - } as AppBskyEmbedExternal.Main, - } as AppBskyEmbedRecordWithMedia.Main - } else { - embed = { - $type: 'app.bsky.embed.external', - external: { - uri: opts.extLink.uri, - title: opts.extLink.meta?.title || '', - description: opts.extLink.meta?.description || '', - thumb, - }, - } as AppBskyEmbedExternal.Main + } as AppBskyEmbedExternal.Main + } } } diff --git a/src/lib/app-info.ts b/src/lib/app-info.ts index 1ced274e7..a365e7e9f 100644 --- a/src/lib/app-info.ts +++ b/src/lib/app-info.ts @@ -1,4 +1,2 @@ import VersionNumber from 'react-native-version-number' - -export const appVersion = VersionNumber.appVersion -export const buildVersion = VersionNumber.buildVersion +export const appVersion = `${VersionNumber.appVersion} (${VersionNumber.buildVersion})` diff --git a/src/lib/app-info.web.ts b/src/lib/app-info.web.ts index a2b6858da..5739b8783 100644 --- a/src/lib/app-info.web.ts +++ b/src/lib/app-info.web.ts @@ -1,3 +1,2 @@ -// TODO -export const appVersion = 'TODO' -export const buildVersion = 'TODO' +import {version} from '../../package.json' +export const appVersion = version diff --git a/src/lib/async/revertible.ts b/src/lib/async/revertible.ts index 3c8e3e8f9..43383b61e 100644 --- a/src/lib/async/revertible.ts +++ b/src/lib/async/revertible.ts @@ -4,6 +4,22 @@ import set from 'lodash.set' const ongoingActions = new Set<any>() +/** + * This is a TypeScript function that optimistically updates data on the client-side before sending a + * request to the server and rolling back changes if the request fails. + * @param {T} model - The object or record that needs to be updated optimistically. + * @param preUpdate - `preUpdate` is a function that is called before the server update is executed. It + * can be used to perform any necessary actions or updates on the model or UI before the server update + * is initiated. + * @param serverUpdate - `serverUpdate` is a function that returns a Promise representing the server + * update operation. This function is called after the previous state of the model has been recorded + * and the `preUpdate` function has been executed. If the server update is successful, the `postUpdate` + * function is called with the result + * @param [postUpdate] - `postUpdate` is an optional callback function that will be called after the + * server update is successful. It takes in the response from the server update as its parameter. If + * this parameter is not provided, nothing will happen after the server update. + * @returns A Promise that resolves to `void`. + */ export const updateDataOptimistically = async < T extends Record<string, any>, U, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 6d0d4797b..170fe640f 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -4,6 +4,8 @@ export const FEEDBACK_FORM_URL = export const MAX_DISPLAY_NAME = 64 export const MAX_DESCRIPTION = 256 +export const MAX_GRAPHEME_LENGTH = 300 + // Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html // but increasing limit per user feedback export const MAX_ALT_TEXT = 1000 @@ -94,8 +96,66 @@ export function SUGGESTED_FOLLOWS(serviceUrl: string) { } } +export const STAGING_DEFAULT_FEED = (rkey: string) => + `at://did:plc:wqzurwm3kmaig6e6hnc2gqwo/app.bsky.feed.generator/${rkey}` +export const PROD_DEFAULT_FEED = (rkey: string) => + `at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}` +export async function DEFAULT_FEEDS( + serviceUrl: string, + resolveHandle: (name: string) => Promise<string>, +) { + if (serviceUrl.includes('localhost')) { + // local dev + const aliceDid = await resolveHandle('alice.test') + return { + pinned: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`], + saved: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`], + } + } else if (serviceUrl.includes('staging')) { + // staging + return { + pinned: [STAGING_DEFAULT_FEED('whats-hot')], + saved: [ + STAGING_DEFAULT_FEED('bsky-team'), + STAGING_DEFAULT_FEED('with-friends'), + STAGING_DEFAULT_FEED('whats-hot'), + STAGING_DEFAULT_FEED('hot-classic'), + ], + } + } else { + // production + return { + pinned: [ + PROD_DEFAULT_FEED('whats-hot'), + PROD_DEFAULT_FEED('with-friends'), + ], + saved: [ + PROD_DEFAULT_FEED('bsky-team'), + PROD_DEFAULT_FEED('with-friends'), + PROD_DEFAULT_FEED('whats-hot'), + PROD_DEFAULT_FEED('hot-classic'), + ], + } + } +} + export const POST_IMG_MAX = { width: 2000, height: 2000, size: 1000000, } + +export const STAGING_LINK_META_PROXY = + 'https://cardyb.staging.bsky.dev/v1/extract?url=' + +export const PROD_LINK_META_PROXY = 'https://cardyb.bsky.app/v1/extract?url=' + +export function LINK_META_PROXY(serviceUrl: string) { + if (serviceUrl.includes('localhost')) { + return STAGING_LINK_META_PROXY + } else if (serviceUrl.includes('staging')) { + return STAGING_LINK_META_PROXY + } else { + return PROD_LINK_META_PROXY + } +} diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts new file mode 100644 index 000000000..516940c1c --- /dev/null +++ b/src/lib/haptics.ts @@ -0,0 +1,40 @@ +import {isIOS, isWeb} from 'platform/detection' +import ReactNativeHapticFeedback, { + HapticFeedbackTypes, +} from 'react-native-haptic-feedback' + +const hapticImpact: HapticFeedbackTypes = isIOS ? 'impactMedium' : 'impactLight' // Users said the medium impact was too strong on Android; see APP-537s + +export class Haptics { + static default() { + if (isWeb) { + return + } + ReactNativeHapticFeedback.trigger(hapticImpact) + } + static impact(type: HapticFeedbackTypes = hapticImpact) { + if (isWeb) { + return + } + ReactNativeHapticFeedback.trigger(type) + } + static selection() { + if (isWeb) { + return + } + ReactNativeHapticFeedback.trigger('selection') + } + static notification = (type: 'success' | 'warning' | 'error') => { + if (isWeb) { + return + } + switch (type) { + case 'success': + return ReactNativeHapticFeedback.trigger('notificationSuccess') + case 'warning': + return ReactNativeHapticFeedback.trigger('notificationWarning') + case 'error': + return ReactNativeHapticFeedback.trigger('notificationError') + } + } +} diff --git a/src/lib/hooks/useCustomFeed.ts b/src/lib/hooks/useCustomFeed.ts new file mode 100644 index 000000000..d7a27050d --- /dev/null +++ b/src/lib/hooks/useCustomFeed.ts @@ -0,0 +1,27 @@ +import {useEffect, useState} from 'react' +import {useStores} from 'state/index' +import {CustomFeedModel} from 'state/models/feeds/custom-feed' + +export function useCustomFeed(uri: string): CustomFeedModel | undefined { + const store = useStores() + const [item, setItem] = useState<CustomFeedModel | undefined>() + useEffect(() => { + async function fetchView() { + const res = await store.agent.app.bsky.feed.getFeedGenerator({ + feed: uri, + }) + const view = res.data.view + return view + } + async function buildFeedItem() { + const view = await fetchView() + if (view) { + const temp = new CustomFeedModel(store, view) + setItem(temp) + } + } + buildFeedItem() + }, [store, uri]) + + return item +} diff --git a/src/lib/hooks/useDraggableScrollView.ts b/src/lib/hooks/useDraggableScrollView.ts new file mode 100644 index 000000000..b0f7465d7 --- /dev/null +++ b/src/lib/hooks/useDraggableScrollView.ts @@ -0,0 +1,84 @@ +import {useEffect, useRef, useMemo, ForwardedRef} from 'react' +import {Platform, findNodeHandle} from 'react-native' +import type {ScrollView} from 'react-native' +import {mergeRefs} from 'lib/merge-refs' + +type Props<Scrollable extends ScrollView = ScrollView> = { + cursor?: string + outerRef?: ForwardedRef<Scrollable> +} + +export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({ + outerRef, + cursor = 'grab', +}: Props<Scrollable> = {}) { + const ref = useRef<Scrollable>(null) + + useEffect(() => { + if (Platform.OS !== 'web' || !ref.current) { + return + } + const slider = findNodeHandle(ref.current) as unknown as HTMLDivElement + if (!slider) { + return + } + let isDragging = false + let isMouseDown = false + let startX = 0 + let scrollLeft = 0 + + const mouseDown = (e: MouseEvent) => { + isMouseDown = true + startX = e.pageX - slider.offsetLeft + scrollLeft = slider.scrollLeft + + slider.style.cursor = cursor + } + + const mouseUp = () => { + if (isDragging) { + slider.addEventListener('click', e => e.stopPropagation(), {once: true}) + } + + isMouseDown = false + isDragging = false + slider.style.cursor = 'default' + } + + const mouseMove = (e: MouseEvent) => { + if (!isMouseDown) { + return + } + + // Require n pixels momement before start of drag (3 in this case ) + const x = e.pageX - slider.offsetLeft + if (Math.abs(x - startX) < 3) { + return + } + + isDragging = true + e.preventDefault() + const walk = x - startX + slider.scrollLeft = scrollLeft - walk + } + + slider.addEventListener('mousedown', mouseDown) + window.addEventListener('mouseup', mouseUp) + window.addEventListener('mousemove', mouseMove) + + return () => { + slider.removeEventListener('mousedown', mouseDown) + window.removeEventListener('mouseup', mouseUp) + window.removeEventListener('mousemove', mouseMove) + } + }, [cursor]) + + const refs = useMemo( + () => mergeRefs(outerRef ? [ref, outerRef] : [ref]), + [ref, outerRef], + ) + + return { + refs, + } +} diff --git a/src/lib/hooks/useNavigationTabState.ts b/src/lib/hooks/useNavigationTabState.ts index fb3662152..3a05fe524 100644 --- a/src/lib/hooks/useNavigationTabState.ts +++ b/src/lib/hooks/useNavigationTabState.ts @@ -6,14 +6,16 @@ export function useNavigationTabState() { const res = { isAtHome: getTabState(state, 'Home') !== TabState.Outside, isAtSearch: getTabState(state, 'Search') !== TabState.Outside, + isAtFeeds: getTabState(state, 'Feeds') !== TabState.Outside, isAtNotifications: getTabState(state, 'Notifications') !== TabState.Outside, isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside, } if ( !res.isAtHome && - !res.isAtNotifications && !res.isAtSearch && + !res.isAtFeeds && + !res.isAtNotifications && !res.isAtMyProfile ) { // HACK for some reason useNavigationState will give us pre-hydration results diff --git a/src/lib/hooks/useOnMainScroll.ts b/src/lib/hooks/useOnMainScroll.ts index 41b35dd4f..12e42aca5 100644 --- a/src/lib/hooks/useOnMainScroll.ts +++ b/src/lib/hooks/useOnMainScroll.ts @@ -1,25 +1,56 @@ -import {useState} from 'react' +import {useState, useCallback, useRef} from 'react' import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' import {RootStoreModel} from 'state/index' +import {s} from 'lib/styles' +import {isDesktopWeb} from 'platform/detection' + +const DY_LIMIT = isDesktopWeb ? 30 : 10 export type OnScrollCb = ( event: NativeSyntheticEvent<NativeScrollEvent>, ) => void +export type ResetCb = () => void + +export function useOnMainScroll( + store: RootStoreModel, +): [OnScrollCb, boolean, ResetCb] { + let lastY = useRef(0) + let [isScrolledDown, setIsScrolledDown] = useState(false) + return [ + useCallback( + (event: NativeSyntheticEvent<NativeScrollEvent>) => { + const y = event.nativeEvent.contentOffset.y + const dy = y - (lastY.current || 0) + lastY.current = y -export function useOnMainScroll(store: RootStoreModel) { - let [lastY, setLastY] = useState(0) - let isMinimal = store.shell.minimalShellMode - return function onMainScroll(event: NativeSyntheticEvent<NativeScrollEvent>) { - const y = event.nativeEvent.contentOffset.y - const dy = y - (lastY || 0) - setLastY(y) + if (!store.shell.minimalShellMode && y > 10 && dy > DY_LIMIT) { + store.shell.setMinimalShellMode(true) + } else if ( + store.shell.minimalShellMode && + (y <= 10 || dy < DY_LIMIT * -1) + ) { + store.shell.setMinimalShellMode(false) + } - if (!isMinimal && y > 10 && dy > 10) { - store.shell.setMinimalShellMode(true) - isMinimal = true - } else if (isMinimal && (y <= 10 || dy < -10)) { + if ( + !isScrolledDown && + event.nativeEvent.contentOffset.y > s.window.height + ) { + setIsScrolledDown(true) + } else if ( + isScrolledDown && + event.nativeEvent.contentOffset.y < s.window.height + ) { + setIsScrolledDown(false) + } + }, + [store, isScrolledDown], + ), + isScrolledDown, + useCallback(() => { + setIsScrolledDown(false) store.shell.setMinimalShellMode(false) - isMinimal = false - } - } + lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf + }, [store, setIsScrolledDown]), + ] } diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index 06f195011..8fa8e11d5 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -1,6 +1,6 @@ import React from 'react' import {StyleProp, TextStyle, ViewStyle} from 'react-native' -import Svg, {Path, Rect, Line, Ellipse} from 'react-native-svg' +import Svg, {Path, Rect, Line, Ellipse, Circle} from 'react-native-svg' export function GridIcon({ style, @@ -88,7 +88,7 @@ export function HomeIconSolid({ <Path fill="currentColor" strokeWidth={strokeWidth} - d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z" + d="m 23.951,2 c -0.32,0.011 -0.628,0.124 -0.879,0.322 L 8.859,13.52 C 7.055,14.941 6,17.114 6,19.41 V 38.5 C 6,39.864 7.136,41 8.5,41 h 8 c 1.364,0 2.5,-1.136 2.5,-2.5 v -12 C 19,26.205 19.205,26 19.5,26 h 9 c 0.295,0 0.5,0.205 0.5,0.5 v 12 c 0,1.364 1.136,2.5 2.5,2.5 h 8 C 40.864,41 42,39.864 42,38.5 V 19.41 c 0,-2.296 -1.055,-4.469 -2.859,-5.89 L 24.928,2.322 C 24.65,2.103 24.304,1.989 23.951,2 Z" /> </Svg> ) @@ -472,7 +472,7 @@ export function HeartIcon({ size = 24, strokeWidth = 1.5, }: { - style?: StyleProp<ViewStyle> + style?: StyleProp<TextStyle> size?: string | number strokeWidth: number }) { @@ -493,7 +493,7 @@ export function HeartIconSolid({ style, size = 24, }: { - style?: StyleProp<ViewStyle> + style?: StyleProp<TextStyle> size?: string | number }) { return ( @@ -883,3 +883,77 @@ export function HandIcon({ </Svg> ) } + +export function SatelliteDishIconSolid({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp<ViewStyle> + size?: string | number + strokeWidth?: number +}) { + return ( + <Svg + width={size || 24} + height={size || 24} + viewBox="0 0 22 22" + style={style} + fill="none" + stroke="none"> + <Path + d="M16 19.6622C14.5291 20.513 12.8214 21 11 21C5.47715 21 1 16.5229 1 11C1 9.17858 1.48697 7.47088 2.33782 6.00002C3.18867 4.52915 6 7.66219 6 7.66219L14.5 16.1622C14.5 16.1622 17.4709 18.8113 16 19.6622Z" + fill="currentColor" + /> + <Path + d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14" + stroke="currentColor" + strokeWidth={strokeWidth} + strokeLinecap="round" + /> + <Path + d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13" + stroke="currentColor" + strokeWidth={strokeWidth} + strokeLinecap="round" + /> + <Circle cx="10" cy="12" r="2" fill="currentColor" /> + </Svg> + ) +} + +export function SatelliteDishIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp<TextStyle> + size?: string | number + strokeWidth?: number +}) { + return ( + <Svg + fill="none" + viewBox="0 0 22 22" + strokeWidth={strokeWidth} + stroke="currentColor" + width={size} + height={size} + style={style}> + <Path d="M5.25593 8.3303L5.25609 8.33047L5.25616 8.33056L5.25621 8.33061L5.27377 8.35018L5.29289 8.3693L13.7929 16.8693L13.8131 16.8895L13.8338 16.908L13.834 16.9081L13.8342 16.9083L13.8342 16.9083L13.8345 16.9086L13.8381 16.9118L13.8574 16.9294C13.8752 16.9458 13.9026 16.9711 13.9377 17.0043C14.0081 17.0708 14.1088 17.1683 14.2258 17.2881C14.4635 17.5315 14.7526 17.8509 14.9928 18.1812C15.2067 18.4755 15.3299 18.7087 15.3817 18.8634C14.0859 19.5872 12.5926 20 11 20C6.02944 20 2 15.9706 2 11C2 9.4151 2.40883 7.9285 3.12619 6.63699C3.304 6.69748 3.56745 6.84213 3.89275 7.08309C4.24679 7.34534 4.58866 7.65673 4.84827 7.9106C4.97633 8.03583 5.08062 8.14337 5.152 8.21863C5.18763 8.25619 5.21487 8.28551 5.23257 8.30473L5.25178 8.32572L5.25571 8.33006L5.25593 8.3303ZM3.00217 6.60712C3.00217 6.6071 3.00267 6.6071 3.00372 6.60715C3.00271 6.60716 3.00218 6.60714 3.00217 6.60712Z" /> + <Path + d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14" + stroke-linecap="round" + /> + <Path + d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13" + stroke-linecap="round" + /> + <Path + d="M12 12C12 12.7403 11.5978 13.3866 11 13.7324L8.26756 11C8.61337 10.4022 9.25972 10 10 10C11.1046 10 12 10.8954 12 12Z" + fill="currentColor" + stroke="none" + /> + </Svg> + ) +} diff --git a/src/lib/labeling/const.ts b/src/lib/labeling/const.ts index b26388123..e406d71ad 100644 --- a/src/lib/labeling/const.ts +++ b/src/lib/labeling/const.ts @@ -62,7 +62,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record< title: 'Violent / Bloody', subtitle: 'Gore, self-harm, torture', warning: 'Violence', - values: ['gore', 'self-harm', 'torture', 'nsfl'], + values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'], isAdultImagery: true, }, hate: { diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts index f4a96a22f..cf43feca8 100644 --- a/src/lib/link-meta/bsky.ts +++ b/src/lib/link-meta/bsky.ts @@ -1,3 +1,4 @@ +import * as apilib from 'lib/api/index' import {LikelyType, LinkMeta} from './link-meta' // import {match as matchRoute} from 'view/routes' import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers' @@ -128,3 +129,29 @@ export async function getPostAsQuote( }, } } + +export async function getFeedAsEmbed( + store: RootStoreModel, + url: string, +): Promise<apilib.ExternalEmbedDraft> { + url = convertBskyAppUrlIfNeeded(url) + const [_0, user, _1, rkey] = url.split('/').filter(Boolean) + const feed = makeRecordUri(user, 'app.bsky.feed.generator', rkey) + const res = await store.agent.app.bsky.feed.getFeedGenerator({feed}) + return { + isLoading: false, + uri: feed, + meta: { + url: feed, + likelyType: LikelyType.AtpData, + title: res.data.view.displayName, + }, + embed: { + $type: 'app.bsky.embed.record', + record: { + uri: res.data.view.uri, + cid: res.data.view.cid, + }, + }, + } +} diff --git a/src/lib/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts index 6c4ad5384..6863798b4 100644 --- a/src/lib/link-meta/link-meta.ts +++ b/src/lib/link-meta/link-meta.ts @@ -1,8 +1,7 @@ -import he from 'he' import {isBskyAppUrl} from '../strings/url-helpers' import {RootStoreModel} from 'state/index' import {extractBskyMeta} from './bsky' -import {extractHtmlMeta} from './html' +import {LINK_META_PROXY} from 'lib/constants' export enum LikelyType { HTML, @@ -54,26 +53,29 @@ export async function getLinkMeta( 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() + + const response = await fetch( + `${LINK_META_PROXY( + store.session.currentSession?.service || '', + )}${encodeURIComponent(url)}`, + ) + + const body = await response.json() 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 + + const {description, error, image, title} = body + + if (error !== '') { + throw new Error(error) + } + + meta.description = description + meta.image = image + meta.title = title } catch (e) { // failed console.error(e) - meta.error = 'Failed to fetch link' + meta.error = e instanceof Error ? e.toString() : 'Failed to fetch link' } return meta diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 4491010e8..c35953703 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -6,52 +6,8 @@ import * as RNFS from 'react-native-fs' import uuid from 'react-native-uuid' import * as Sharing from 'expo-sharing' import {Dimensions} from './types' -import {POST_IMG_MAX} from 'lib/constants' import {isAndroid, isIOS} from 'platform/detection' -export async function compressAndResizeImageForPost( - image: Image, -): Promise<Image> { - const uri = `file://${image.path}` - let resized: Omit<Image, 'mime'> - - for (let i = 0; i < 9; i++) { - const quality = 100 - i * 10 - - try { - resized = await ImageResizer.createResizedImage( - uri, - POST_IMG_MAX.width, - POST_IMG_MAX.height, - 'JPEG', - quality, - undefined, - undefined, - undefined, - {mode: 'cover'}, - ) - } catch (err) { - throw new Error(`Failed to resize: ${err}`) - } - - if (resized.size < POST_IMG_MAX.size) { - const path = await moveToPermanentPath(resized.path) - - return { - path, - mime: 'image/jpeg', - size: resized.size, - height: resized.height, - width: resized.width, - } - } - } - - throw new Error( - `This image is too big! We couldn't compress it down to ${POST_IMG_MAX.size} bytes`, - ) -} - export async function compressIfNeeded( img: Image, maxSize: number = 1000000, diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts index 85f6b6138..464802c32 100644 --- a/src/lib/media/manip.web.ts +++ b/src/lib/media/manip.web.ts @@ -1,25 +1,6 @@ import {Dimensions} from './types' import {Image as RNImage} from 'react-native-image-crop-picker' import {getDataUriSize, blobToDataUri} from './util' -import {POST_IMG_MAX} from 'lib/constants' - -export async function compressAndResizeImageForPost({ - path, - width, - height, -}: { - path: string - width: number - height: number -}): Promise<RNImage> { - // Compression is handled in `doResize` via `quality` - return await doResize(path, { - width, - height, - maxSize: POST_IMG_MAX.size, - mode: 'stretch', - }) -} export async function compressIfNeeded( img: RNImage, diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx index e53dc42be..9805c3464 100644 --- a/src/lib/media/picker.e2e.tsx +++ b/src/lib/media/picker.e2e.tsx @@ -2,7 +2,7 @@ import {RootStoreModel} from 'state/index' import {Image as RNImage} from 'react-native-image-crop-picker' import RNFS from 'react-native-fs' import {CropperOptions} from './types' -import {compressAndResizeImageForPost} from './manip' +import {compressIfNeeded} from './manip' let _imageCounter = 0 async function getFile() { @@ -13,7 +13,7 @@ async function getFile() { .join('/'), ) const file = files[_imageCounter++ % files.length] - return await compressAndResizeImageForPost({ + return await compressIfNeeded({ path: file.path, mime: 'image/jpeg', size: file.size, diff --git a/src/lib/media/util.ts b/src/lib/media/util.ts index 75915de6b..73f974874 100644 --- a/src/lib/media/util.ts +++ b/src/lib/media/util.ts @@ -1,5 +1,3 @@ -import {Dimensions} from './types' - export function extractDataUriMime(uri: string): string { return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';')) } @@ -10,21 +8,6 @@ export function getDataUriSize(uri: string): number { return Math.round((uri.length * 3) / 4) } -export function scaleDownDimensions( - dim: Dimensions, - max: Dimensions, -): Dimensions { - if (dim.width < max.width && dim.height < max.height) { - return dim - } - const wScale = dim.width > max.width ? max.width / dim.width : 1 - const hScale = dim.height > max.height ? max.height / dim.height : 1 - if (wScale < hScale) { - return {width: dim.width * wScale, height: dim.height * wScale} - } - return {width: dim.width * hScale, height: dim.height * hScale} -} - export function isUriImage(uri: string) { return /\.(jpg|jpeg|png).*$/.test(uri) } diff --git a/src/lib/merge-refs.ts b/src/lib/merge-refs.ts new file mode 100644 index 000000000..4617b5260 --- /dev/null +++ b/src/lib/merge-refs.ts @@ -0,0 +1,27 @@ +/** + * This TypeScript function merges multiple React refs into a single ref callback. + * When developing low level UI components, it is common to have to use a local ref + * but also support an external one using React.forwardRef. + * Natively, React does not offer a way to set two refs inside the ref property. This is the goal of this small utility. + * Today a ref can be a function or an object, tomorrow it could be another thing, who knows. + * This utility handles compatibility for you. + * This function is inspired by https://github.com/gregberge/react-merge-refs + * @param refs - An array of React refs, which can be either `React.MutableRefObject<T>` or + * `React.LegacyRef<T>`. These refs are used to store references to DOM elements or React components. + * The `mergeRefs` function takes in an array of these refs and returns a callback function that + * @returns The function `mergeRefs` is being returned. It takes an array of mutable or legacy refs and + * returns a ref callback function that can be used to merge multiple refs into a single ref. + */ +export function mergeRefs<T = any>( + refs: Array<React.MutableRefObject<T> | React.LegacyRef<T>>, +): React.RefCallback<T> { + return value => { + refs.forEach(ref => { + if (typeof ref === 'function') { + ref(value) + } else if (ref != null) { + ;(ref as React.MutableRefObject<T | null>).current = value + } + }) + } +} diff --git a/src/lib/notifee.ts b/src/lib/notifee.ts index 866319031..42feb01c6 100644 --- a/src/lib/notifee.ts +++ b/src/lib/notifee.ts @@ -41,26 +41,26 @@ export function displayNotification( } export function displayNotificationFromModel( - notif: NotificationsFeedItemModel, + notification: NotificationsFeedItemModel, ) { let author = sanitizeDisplayName( - notif.author.displayName || notif.author.handle, + notification.author.displayName || notification.author.handle, ) let title: string let body: string = '' - if (notif.isLike) { + if (notification.isLike) { title = `${author} liked your post` - body = notif.additionalPost?.thread?.postRecord?.text || '' - } else if (notif.isRepost) { + body = notification.additionalPost?.thread?.postRecord?.text || '' + } else if (notification.isRepost) { title = `${author} reposted your post` - body = notif.additionalPost?.thread?.postRecord?.text || '' - } else if (notif.isMention) { + body = notification.additionalPost?.thread?.postRecord?.text || '' + } else if (notification.isMention) { title = `${author} mentioned you` - body = notif.additionalPost?.thread?.postRecord?.text || '' - } else if (notif.isReply) { + body = notification.additionalPost?.thread?.postRecord?.text || '' + } else if (notification.isReply) { title = `${author} replied to your post` - body = notif.additionalPost?.thread?.postRecord?.text || '' - } else if (notif.isFollow) { + body = notification.additionalPost?.thread?.postRecord?.text || '' + } else if (notification.isFollow) { title = 'New follower!' body = `${author} has followed you` } else { @@ -68,10 +68,12 @@ export function displayNotificationFromModel( } let image if ( - AppBskyEmbedImages.isView(notif.additionalPost?.thread?.post.embed) && - notif.additionalPost?.thread?.post.embed.images[0]?.thumb + AppBskyEmbedImages.isView( + notification.additionalPost?.thread?.post.embed, + ) && + notification.additionalPost?.thread?.post.embed.images[0]?.thumb ) { - image = notif.additionalPost.thread.post.embed.images[0].thumb + image = notification.additionalPost.thread.post.embed.images[0].thumb } return displayNotification(title, body, image) } diff --git a/src/lib/routes/helpers.ts b/src/lib/routes/helpers.ts index cfa6ae53b..071e1ae9b 100644 --- a/src/lib/routes/helpers.ts +++ b/src/lib/routes/helpers.ts @@ -11,7 +11,7 @@ export function getCurrentRoute(state: State) { export function isStateAtTabRoot(state: State | undefined) { if (!state) { // NOTE - // if state is not defined it's because init is occuring + // if state is not defined it's because init is occurring // and therefore we can safely assume we're at root // -prf return true diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 56775deee..4eb5e29d2 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -9,6 +9,7 @@ export type CommonNavigatorParams = { ModerationMuteLists: undefined ModerationMutedAccounts: undefined ModerationBlockedAccounts: undefined + DiscoverFeeds: undefined Settings: undefined Profile: {name: string; hideBackButton?: boolean} ProfileFollowers: {name: string} @@ -17,6 +18,8 @@ export type CommonNavigatorParams = { PostThread: {name: string; rkey: string} PostLikedBy: {name: string; rkey: string} PostRepostedBy: {name: string; rkey: string} + CustomFeed: {name: string; rkey: string} + CustomFeedLikedBy: {name: string; rkey: string} Debug: undefined Log: undefined Support: undefined @@ -25,11 +28,13 @@ export type CommonNavigatorParams = { CommunityGuidelines: undefined CopyrightPolicy: undefined AppPasswords: undefined + SavedFeeds: undefined } export type BottomTabNavigatorParams = CommonNavigatorParams & { HomeTab: undefined SearchTab: undefined + FeedsTab: undefined NotificationsTab: undefined MyProfileTab: undefined } @@ -42,6 +47,10 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & { Search: {q?: string} } +export type FeedsTabNavigatorParams = CommonNavigatorParams & { + Feeds: undefined +} + export type NotificationsTabNavigatorParams = CommonNavigatorParams & { Notifications: undefined } @@ -53,6 +62,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & { export type FlatNavigatorParams = CommonNavigatorParams & { Home: undefined Search: {q?: string} + Feeds: undefined Notifications: undefined } @@ -61,6 +71,8 @@ export type AllNavigatorParams = CommonNavigatorParams & { Home: undefined SearchTab: undefined Search: {q?: string} + FeedsTab: undefined + Feeds: undefined NotificationsTab: undefined Notifications: undefined MyProfileTab: undefined diff --git a/src/lib/strings/rich-text-detection.ts b/src/lib/strings/rich-text-detection.ts index 51d09ec5d..931617cd1 100644 --- a/src/lib/strings/rich-text-detection.ts +++ b/src/lib/strings/rich-text-detection.ts @@ -27,7 +27,7 @@ export function detectLinkables(text: string): DetectedLinkable[] { matchValue = matchValue.slice(1) } - // strip ending puncuation + // strip ending punctuation if (/[.,;!?]$/.test(matchValue)) { matchValue = matchValue.slice(0, -1) } diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts index 588b84459..3f2847558 100644 --- a/src/lib/strings/time.ts +++ b/src/lib/strings/time.ts @@ -1,8 +1,8 @@ const MINUTE = 60 const HOUR = MINUTE * 60 const DAY = HOUR * 24 -const MONTH = DAY * 28 -const YEAR = DAY * 365 +const WEEK = DAY * 7 + export function ago(date: number | string | Date): string { let ts: number if (typeof date === 'string') { @@ -19,12 +19,14 @@ export function ago(date: number | string | Date): string { return `${Math.floor(diffSeconds / MINUTE)}m` } else if (diffSeconds < DAY) { return `${Math.floor(diffSeconds / HOUR)}h` - } else if (diffSeconds < MONTH) { + } else if (diffSeconds < WEEK) { return `${Math.floor(diffSeconds / DAY)}d` - } else if (diffSeconds < YEAR) { - return `${Math.floor(diffSeconds / MONTH)}mo` } else { - return new Date(ts).toLocaleDateString() + return new Date(ts).toLocaleDateString('en-us', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) } } diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index a5412920e..d6d43b89d 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -82,6 +82,18 @@ export function isBskyPostUrl(url: string): boolean { return false } +export function isBskyCustomFeedUrl(url: string): boolean { + if (isBskyAppUrl(url)) { + try { + const urlp = new URL(url) + return /profile\/(?<name>[^/]+)\/feed\/(?<rkey>[^/]+)/i.test( + urlp.pathname, + ) + } catch {} + } + return false +} + export function convertBskyAppUrlIfNeeded(url: string): string { if (isBskyAppUrl(url)) { try { diff --git a/src/lib/styles.ts b/src/lib/styles.ts index 00a8638f9..fb631c0bf 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -1,4 +1,4 @@ -import {StyleProp, StyleSheet, TextStyle} from 'react-native' +import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native' import {Theme, TypographyVariant} from './ThemeContext' import {isMobileWeb} from 'platform/detection' @@ -52,6 +52,7 @@ export const colors = { green5: '#082b03', unreadNotifBg: '#ebf6ff', + brandBlue: '#0066FF', } export const gradients = { @@ -169,6 +170,10 @@ export const s = StyleSheet.create({ w100pct: {width: '100%'}, h100pct: {height: '100%'}, hContentRegion: isMobileWeb ? {flex: 1} : {height: '100%'}, + window: { + width: Dimensions.get('window').width, + height: Dimensions.get('window').height, + }, // text align textLeft: {textAlign: 'left'}, @@ -214,6 +219,8 @@ export const s = StyleSheet.create({ green3: {color: colors.green3}, green4: {color: colors.green4}, green5: {color: colors.green5}, + + brandBlue: {color: colors.brandBlue}, }) export function lh( |