From 282db85c069841ef57bb3a0a7c57a93d7d31939a Mon Sep 17 00:00:00 2001 From: dan Date: Sat, 5 Oct 2024 03:39:33 +0900 Subject: [PATCH 1/7] Track links and embeds in the composer reducer (#5593) * Scaffold embed draft types These don't map 1:1 to the record structure. Rather, we select data from the draft on posting. * Prefill initial quote * Implement the reducer --- src/view/com/composer/Composer.tsx | 17 ++- src/view/com/composer/state/composer.ts | 151 +++++++++++++++++++++--- 2 files changed, 151 insertions(+), 17 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index f4e290ca8d..b5f2d84d4f 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -190,7 +190,7 @@ export const ComposePost = ({ // TODO: Move more state here. const [composerState, dispatch] = useReducer( composerReducer, - {initImageUris}, + {initImageUris, initQuoteUri: initQuote?.uri}, createComposerState, ) @@ -337,6 +337,7 @@ export const ComposePost = ({ const onNewLink = useCallback( (uri: string) => { + dispatch({type: 'embed_add_uri', uri}) if (extLink != null) return setExtLink({uri, isLoading: true}) }, @@ -582,6 +583,7 @@ export const ComposePost = ({ const onSelectGif = useCallback( (gif: Gif) => { + dispatch({type: 'embed_add_gif', gif}) setExtLink({ uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`, isLoading: true, @@ -600,6 +602,7 @@ export const ComposePost = ({ const handleChangeGifAltText = useCallback( (altText: string) => { + dispatch({type: 'embed_update_gif', alt: altText}) setExtLink(ext => ext && ext.meta ? { @@ -770,6 +773,11 @@ export const ComposePost = ({ link={extLink} gif={extGif} onRemove={() => { + if (extGif) { + dispatch({type: 'embed_remove_gif'}) + } else { + dispatch({type: 'embed_remove_link'}) + } setExtLink(undefined) setExtGif(undefined) }} @@ -818,7 +826,12 @@ export const ComposePost = ({ {quote.uri !== initQuote?.uri && ( - setQuote(undefined)} /> + { + dispatch({type: 'embed_remove_quote'}) + setQuote(undefined) + }} + /> )} ) : null} diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index a23a5d8c86..769a0521d4 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -1,17 +1,14 @@ import {ImagePickerAsset} from 'expo-image-picker' +import {isBskyPostUrl} from '#/lib/strings/url-helpers' import {ComposerImage, createInitialImages} from '#/state/gallery' +import {Gif} from '#/state/queries/tenor' import {ComposerOpts} from '#/state/shell/composer' import {createVideoState, VideoAction, videoReducer, VideoState} from './video' -type PostRecord = { - uri: string -} - type ImagesMedia = { type: 'images' images: ComposerImage[] - labels: string[] } type VideoMedia = { @@ -19,16 +16,30 @@ type VideoMedia = { video: VideoState } -type ComposerEmbed = { - // TODO: Other record types. - record: PostRecord | undefined - // TODO: Other media types. - media: ImagesMedia | VideoMedia | undefined +type GifMedia = { + type: 'gif' + gif: Gif + alt: string +} + +type Link = { + type: 'link' + uri: string +} + +// This structure doesn't exactly correspond to the data model. +// Instead, it maps to how the UI is organized, and how we present a post. +type EmbedDraft = { + // We'll always submit quote and actual media (images, video, gifs) chosen by the user. + quote: Link | undefined + media: ImagesMedia | VideoMedia | GifMedia | undefined + // This field may end up ignored if we have more important things to display than a link card: + link: Link | undefined } export type ComposerState = { // TODO: Other draft data. - embed: ComposerEmbed + embed: EmbedDraft } export type ComposerAction = @@ -42,6 +53,12 @@ export type ComposerAction = } | {type: 'embed_remove_video'} | {type: 'embed_update_video'; videoAction: VideoAction} + | {type: 'embed_add_uri'; uri: string} + | {type: 'embed_remove_quote'} + | {type: 'embed_remove_link'} + | {type: 'embed_add_gif'; gif: Gif} + | {type: 'embed_update_gif'; alt: string} + | {type: 'embed_remove_gif'} const MAX_IMAGES = 4 @@ -60,7 +77,6 @@ export function composerReducer( nextMedia = { type: 'images', images: action.images.slice(0, MAX_IMAGES), - labels: [], } } else if (prevMedia.type === 'images') { nextMedia = { @@ -171,6 +187,102 @@ export function composerReducer( }, } } + case 'embed_add_uri': { + const prevQuote = state.embed.quote + const prevLink = state.embed.link + let nextQuote = prevQuote + let nextLink = prevLink + if (isBskyPostUrl(action.uri)) { + if (!prevQuote) { + nextQuote = { + type: 'link', + uri: action.uri, + } + } + } else { + if (!prevLink) { + nextLink = { + type: 'link', + uri: action.uri, + } + } + } + return { + ...state, + embed: { + ...state.embed, + quote: nextQuote, + link: nextLink, + }, + } + } + case 'embed_remove_link': { + return { + ...state, + embed: { + ...state.embed, + link: undefined, + }, + } + } + case 'embed_remove_quote': { + return { + ...state, + embed: { + ...state.embed, + quote: undefined, + }, + } + } + case 'embed_add_gif': { + const prevMedia = state.embed.media + let nextMedia = prevMedia + if (!prevMedia) { + nextMedia = { + type: 'gif', + gif: action.gif, + alt: '', + } + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } + case 'embed_update_gif': { + const prevMedia = state.embed.media + let nextMedia = prevMedia + if (prevMedia?.type === 'gif') { + nextMedia = { + ...prevMedia, + alt: action.alt, + } + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } + case 'embed_remove_gif': { + const prevMedia = state.embed.media + let nextMedia = prevMedia + if (prevMedia?.type === 'gif') { + nextMedia = undefined + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } default: return state } @@ -178,22 +290,31 @@ export function composerReducer( export function createComposerState({ initImageUris, + initQuoteUri, }: { initImageUris: ComposerOpts['imageUris'] + initQuoteUri: string | undefined }): ComposerState { let media: ImagesMedia | undefined if (initImageUris?.length) { media = { type: 'images', images: createInitialImages(initImageUris), - labels: [], } } - // TODO: initial video. + let quote: Link | undefined + if (initQuoteUri) { + quote = { + type: 'link', + uri: initQuoteUri, + } + } + // TODO: Other initial content. return { embed: { - record: undefined, + quote, media, + link: undefined, }, } } From 09caf327d9809311f78e7f0d5c2f7213924e53ae Mon Sep 17 00:00:00 2001 From: dan Date: Sat, 5 Oct 2024 03:45:34 +0900 Subject: [PATCH 2/7] Make composer reducer source of truth for images/video when publishing (#5595) * Move caption and altText state into video reducer * Make composer state source of truth for images and video publish --- src/lib/api/index.ts | 32 ++++++------- src/view/com/composer/Composer.tsx | 47 +++++++++--------- src/view/com/composer/state/video.ts | 48 ++++++++++++++++++- .../com/composer/videos/SubtitleDialog.tsx | 12 ++--- 4 files changed, 89 insertions(+), 50 deletions(-) diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 8b79250042..c7608ae557 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,5 +1,4 @@ import { - AppBskyEmbedDefs, AppBskyEmbedExternal, AppBskyEmbedImages, AppBskyEmbedRecord, @@ -7,7 +6,6 @@ import { AppBskyEmbedVideo, AppBskyFeedPostgate, AtUri, - BlobRef, BskyAgent, ComAtprotoLabelDefs, RichText, @@ -46,14 +44,7 @@ interface PostOpts { uri: string cid: string } - video?: { - blobRef: BlobRef - altText: string - captions: {lang: string; file: File}[] - aspectRatio?: AppBskyEmbedDefs.AspectRatio - } extLink?: ExternalEmbedDraft - images?: ComposerImage[] labels?: string[] threadgate: ThreadgateAllowUISetting[] postgate: AppBskyFeedPostgate.Record @@ -230,13 +221,15 @@ async function resolveMedia( | AppBskyEmbedVideo.Main | undefined > { - if (opts.images?.length) { + const state = opts.composerState + const media = state.embed.media + if (media?.type === 'images') { logger.debug(`Uploading images`, { - count: opts.images.length, + count: media.images.length, }) opts.onStateChange?.(`Uploading images...`) const images: AppBskyEmbedImages.Image[] = await Promise.all( - opts.images.map(async (image, i) => { + media.images.map(async (image, i) => { logger.debug(`Compressing image #${i}`) const {path, width, height, mime} = await compressImage(image) logger.debug(`Uploading image #${i}`) @@ -253,9 +246,10 @@ async function resolveMedia( images, } } - if (opts.video) { + if (media?.type === 'video' && media.video.status === 'done') { + const video = media.video const captions = await Promise.all( - opts.video.captions + video.captions .filter(caption => caption.lang !== '') .map(async caption => { const {data} = await agent.uploadBlob(caption.file, { @@ -266,13 +260,17 @@ async function resolveMedia( ) return { $type: 'app.bsky.embed.video', - video: opts.video.blobRef, - alt: opts.video.altText || undefined, + video: video.pendingPublish.blobRef, + alt: video.altText || undefined, captions: captions.length === 0 ? undefined : captions, - aspectRatio: opts.video.aspectRatio, + aspectRatio: { + width: video.asset.width, + height: video.asset.height, + }, } } if (opts.extLink) { + // TODO: Read this from composer state as well. if (opts.extLink.embed) { return undefined } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index b5f2d84d4f..2279649070 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -184,9 +184,6 @@ export const ComposePost = ({ initQuote, ) - const [videoAltText, setVideoAltText] = useState('') - const [captions, setCaptions] = useState<{lang: string; file: File}[]>([]) - // TODO: Move more state here. const [composerState, dispatch] = useReducer( composerReducer, @@ -422,10 +419,9 @@ export const ComposePost = ({ try { postUri = ( await apilib.post(agent, { - composerState, // TODO: not used yet. + composerState, // TODO: move more state here. rawText: richtext.text, replyTo: replyTo?.uri, - images, quote, extLink, labels, @@ -433,18 +429,6 @@ export const ComposePost = ({ postgate, onStateChange: setProcessingState, langs: toPostLanguages(langPrefs.postLanguage), - video: - videoState.status === 'done' - ? { - blobRef: videoState.pendingPublish.blobRef, - altText: videoAltText, - captions: captions, - aspectRatio: { - width: videoState.asset.width, - height: videoState.asset.height, - }, - } - : undefined, }) ).uri try { @@ -522,7 +506,6 @@ export const ComposePost = ({ [ _, agent, - captions, composerState, extLink, images, @@ -541,9 +524,7 @@ export const ComposePost = ({ setExtLink, setLangPrefs, threadgateAllowUISettings, - videoAltText, videoState.asset, - videoState.pendingPublish, videoState.status, ], ) @@ -811,10 +792,28 @@ export const ComposePost = ({ /> ) : null)} + dispatch({ + type: 'embed_update_video', + videoAction: { + type: 'update_alt_text', + altText, + signal: videoState.abortController.signal, + }, + }) + } + captions={videoState.captions} + setCaptions={updater => { + dispatch({ + type: 'embed_update_video', + videoAction: { + type: 'update_captions', + updater, + signal: videoState.abortController.signal, + }, + }) + }} /> )} diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts index 2695056579..e29687200f 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -4,8 +4,6 @@ import {JobStatus} from '@atproto/api/dist/client/types/app/bsky/video/defs' import {I18n} from '@lingui/core' import {msg} from '@lingui/macro' -import {createVideoAgent} from '#/lib/media/video/util' -import {uploadVideo} from '#/lib/media/video/upload' import {AbortError} from '#/lib/async/cancelable' import {compressVideo} from '#/lib/media/video/compress' import { @@ -14,8 +12,12 @@ import { VideoTooLargeError, } from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' +import {uploadVideo} from '#/lib/media/video/upload' +import {createVideoAgent} from '#/lib/media/video/util' import {logger} from '#/logger' +type CaptionsTrack = {lang: string; file: File} + export type VideoAction = | { type: 'compressing_to_uploading' @@ -40,6 +42,16 @@ export type VideoAction = height: number signal: AbortSignal } + | { + type: 'update_alt_text' + altText: string + signal: AbortSignal + } + | { + type: 'update_captions' + updater: (prev: CaptionsTrack[]) => CaptionsTrack[] + signal: AbortSignal + } | { type: 'update_job_status' jobStatus: AppBskyVideoDefs.JobStatus @@ -57,6 +69,8 @@ export const NO_VIDEO = Object.freeze({ video: undefined, jobId: undefined, pendingPublish: undefined, + altText: '', + captions: [], }) export type NoVideoState = typeof NO_VIDEO @@ -70,6 +84,8 @@ type ErrorState = { jobId: string | null error: string pendingPublish?: undefined + altText: string + captions: CaptionsTrack[] } type CompressingState = { @@ -80,6 +96,8 @@ type CompressingState = { video?: undefined jobId?: undefined pendingPublish?: undefined + altText: string + captions: CaptionsTrack[] } type UploadingState = { @@ -90,6 +108,8 @@ type UploadingState = { video: CompressedVideo jobId?: undefined pendingPublish?: undefined + altText: string + captions: CaptionsTrack[] } type ProcessingState = { @@ -101,6 +121,8 @@ type ProcessingState = { jobId: string jobStatus: AppBskyVideoDefs.JobStatus | null pendingPublish?: undefined + altText: string + captions: CaptionsTrack[] } type DoneState = { @@ -111,6 +133,8 @@ type DoneState = { video: CompressedVideo jobId?: undefined pendingPublish: {blobRef: BlobRef; mutableProcessed: boolean} + altText: string + captions: CaptionsTrack[] } export type VideoState = @@ -129,6 +153,8 @@ export function createVideoState( progress: 0, abortController, asset, + altText: '', + captions: [], } } @@ -149,6 +175,8 @@ export function videoReducer( asset: state.asset ?? null, video: state.video ?? null, jobId: state.jobId ?? null, + altText: state.altText, + captions: state.captions, } } else if (action.type === 'update_progress') { if (state.status === 'compressing' || state.status === 'uploading') { @@ -164,6 +192,16 @@ export function videoReducer( asset: {...state.asset, width: action.width, height: action.height}, } } + } else if (action.type === 'update_alt_text') { + return { + ...state, + altText: action.altText, + } + } else if (action.type === 'update_captions') { + return { + ...state, + captions: action.updater(state.captions), + } } else if (action.type === 'compressing_to_uploading') { if (state.status === 'compressing') { return { @@ -172,6 +210,8 @@ export function videoReducer( abortController: state.abortController, asset: state.asset, video: action.video, + altText: state.altText, + captions: state.captions, } } return state @@ -185,6 +225,8 @@ export function videoReducer( video: state.video, jobId: action.jobId, jobStatus: null, + altText: state.altText, + captions: state.captions, } } } else if (action.type === 'update_job_status') { @@ -210,6 +252,8 @@ export function videoReducer( blobRef: action.blobRef, mutableProcessed: false, }, + altText: state.altText, + captions: state.captions, } } } diff --git a/src/view/com/composer/videos/SubtitleDialog.tsx b/src/view/com/composer/videos/SubtitleDialog.tsx index c07fdfc562..f66684d4ea 100644 --- a/src/view/com/composer/videos/SubtitleDialog.tsx +++ b/src/view/com/composer/videos/SubtitleDialog.tsx @@ -22,13 +22,13 @@ import {SubtitleFilePicker} from './SubtitleFilePicker' const MAX_NUM_CAPTIONS = 1 +type CaptionsTrack = {lang: string; file: File} + interface Props { defaultAltText: string - captions: {lang: string; file: File}[] + captions: CaptionsTrack[] saveAltText: (altText: string) => void - setCaptions: React.Dispatch< - React.SetStateAction<{lang: string; file: File}[]> - > + setCaptions: (updater: (prev: CaptionsTrack[]) => CaptionsTrack[]) => void } export function SubtitleDialogBtn(props: Props) { @@ -198,9 +198,7 @@ function SubtitleFileRow({ language: string file: File otherLanguages: {code2: string; code3: string; name: string}[] - setCaptions: React.Dispatch< - React.SetStateAction<{lang: string; file: File}[]> - > + setCaptions: (updater: (prev: CaptionsTrack[]) => CaptionsTrack[]) => void style: StyleProp }) { const {_} = useLingui() From 9802ebe20d32dc1867a069dc377b3d4c43ce45f0 Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 4 Oct 2024 13:17:57 -0700 Subject: [PATCH 3/7] Properly patch React Native to fix iOS `RefreshControl` bug (#5605) --- patches/react-native+0.74.1.patch | 80 ++++++++++++++++++++++++------- 1 file changed, 63 insertions(+), 17 deletions(-) diff --git a/patches/react-native+0.74.1.patch b/patches/react-native+0.74.1.patch index aee3da1ecc..ea6161e2ff 100644 --- a/patches/react-native+0.74.1.patch +++ b/patches/react-native+0.74.1.patch @@ -38,37 +38,65 @@ index b0d71dc..41b9a0e 100644 - (void)reactBlur diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h -index e9b330f..1ecdf0a 100644 +index e9b330f..ec5f58c 100644 --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h +++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h -@@ -16,4 +16,6 @@ +@@ -15,5 +15,8 @@ + @property (nonatomic, copy) NSString *title; @property (nonatomic, copy) RCTDirectEventBlock onRefresh; @property (nonatomic, weak) UIScrollView *scrollView; - -+- (void)forwarderBeginRefreshing; ++@property (nonatomic, copy) UIColor *customTintColor; + ++- (void)forwarderBeginRefreshing; + @end diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m -index b09e653..f93cb46 100644 +index b09e653..288e60c 100644 --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m +++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m -@@ -198,9 +198,53 @@ - (void)refreshControlValueChanged - [self setCurrentRefreshingState:super.refreshing]; - _refreshingProgrammatically = NO; +@@ -22,6 +22,7 @@ @implementation RCTRefreshControl { + NSString *_title; + UIColor *_titleColor; + CGFloat _progressViewOffset; ++ UIColor *_customTintColor; + } -+ if (@available(iOS 17.4, *)) { -+ if (_currentRefreshingState) { -+ UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight]; -+ [feedbackGenerator prepare]; -+ [feedbackGenerator impactOccurred]; -+ } -+ } + - (instancetype)init +@@ -56,6 +57,12 @@ - (void)layoutSubviews + _isInitialRender = false; + } + ++- (void)didMoveToSuperview ++{ ++ [super didMoveToSuperview]; ++ [self setTintColor:_customTintColor]; ++} + - if (_onRefresh) { - _onRefresh(nil); + - (void)beginRefreshingProgrammatically + { + UInt64 beginRefreshingTimestamp = _currentRefreshingStateTimestamp; +@@ -203,4 +210,58 @@ - (void)refreshControlValueChanged } } ++- (void)setCustomTintColor:(UIColor *)customTintColor ++{ ++ _customTintColor = customTintColor; ++ [self setTintColor:customTintColor]; ++} ++ ++// Fix for https://github.com/facebook/react-native/issues/43388 ++// A bug in iOS 17.4 causes the haptic to not play when refreshing if the tintColor ++// is set before the refresh control gets added to the scrollview. We'll call this ++// function whenever the superview changes. We'll also call it if the value of customTintColor ++// changes. ++- (void)setTintColor:(UIColor *)tintColor ++{ ++ if ([self.superview isKindOfClass:[UIScrollView class]] && self.tintColor != tintColor) { ++ [super setTintColor:tintColor]; ++ } ++} ++ +/* + This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native + libraries to perform a refresh of a scrollview and access the refresh control's onRefresh @@ -106,6 +134,24 @@ index b09e653..f93cb46 100644 +} + @end +diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m +index 40aaf9c..1c60164 100644 +--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m ++++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m +@@ -22,11 +22,12 @@ - (UIView *)view + + RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock) + RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL) +-RCT_EXPORT_VIEW_PROPERTY(tintColor, UIColor) + RCT_EXPORT_VIEW_PROPERTY(title, NSString) + RCT_EXPORT_VIEW_PROPERTY(titleColor, UIColor) + RCT_EXPORT_VIEW_PROPERTY(progressViewOffset, CGFloat) + ++RCT_REMAP_VIEW_PROPERTY(tintColor, customTintColor, UIColor) ++ + RCT_EXPORT_METHOD(setNativeRefreshing : (nonnull NSNumber *)viewTag toRefreshing : (BOOL)refreshing) + { + [self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary *viewRegistry) { diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java index 5f5e1ab..aac00b6 100644 --- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java From 00486e94991f344353ffb083dd631283a84c3ad3 Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 4 Oct 2024 13:24:12 -0700 Subject: [PATCH 4/7] [Sheets] [Pt. 1] Root PR (#5557) Co-authored-by: Samuel Newman Co-authored-by: Eric Bailey Co-authored-by: dan Co-authored-by: Hailey --- jest/jestSetup.js | 5 + modules/bottom-sheet/android/build.gradle | 49 ++ .../android/src/main/AndroidManifest.xml | 2 + .../modules/bottomsheet/BottomSheetModule.kt | 53 ++ .../modules/bottomsheet/BottomSheetView.kt | 339 +++++++++++ .../bottomsheet/DialogRootViewGroup.kt | 171 ++++++ .../expo/modules/bottomsheet/SheetManager.kt | 28 + modules/bottom-sheet/expo-module.config.json | 9 + modules/bottom-sheet/index.ts | 13 + modules/bottom-sheet/ios/BottomSheet.podspec | 21 + .../bottom-sheet/ios/BottomSheetModule.swift | 47 ++ modules/bottom-sheet/ios/SheetManager.swift | 28 + modules/bottom-sheet/ios/SheetView.swift | 189 ++++++ .../ios/SheetViewController.swift | 76 +++ modules/bottom-sheet/ios/Util.swift | 18 + modules/bottom-sheet/src/BottomSheet.tsx | 100 +++ modules/bottom-sheet/src/BottomSheet.types.ts | 35 ++ modules/bottom-sheet/src/BottomSheet.web.tsx | 5 + package.json | 5 +- src/App.native.tsx | 4 +- src/alf/util/useColorModeTheme.ts | 6 +- src/components/Button.tsx | 7 +- src/components/Dialog/context.ts | 5 + src/components/Dialog/index.tsx | 380 ++++++------ src/components/Dialog/index.web.tsx | 12 +- src/components/Dialog/sheet-wrapper.ts | 20 + src/components/Dialog/types.ts | 13 +- .../KeyboardControllerPadding.android.tsx | 31 - src/components/KeyboardControllerPadding.tsx | 7 - src/components/LikesDialog.tsx | 16 +- src/components/Link.tsx | 6 +- src/components/Menu/index.tsx | 21 +- src/components/NewskieDialog.tsx | 6 +- src/components/Portal.tsx | 2 + src/components/Prompt.tsx | 18 +- .../ReportDialog/SelectLabelerView.tsx | 1 - src/components/ReportDialog/SubmitView.tsx | 3 + src/components/ReportDialog/index.tsx | 9 +- src/components/StarterPack/QrCodeDialog.tsx | 1 - src/components/StarterPack/ShareDialog.tsx | 16 +- .../Wizard/WizardEditListDialog.tsx | 26 +- src/components/TagMenu/index.tsx | 1 - src/components/dialogs/BirthDateSettings.tsx | 1 - src/components/dialogs/EmbedConsent.tsx | 1 - src/components/dialogs/GifSelect.ios.tsx | 255 -------- src/components/dialogs/GifSelect.shared.tsx | 53 -- src/components/dialogs/GifSelect.tsx | 75 ++- src/components/dialogs/MutedWords.tsx | 528 ++++++++-------- .../dialogs/PostInteractionSettingsDialog.tsx | 7 +- src/components/dialogs/SwitchAccount.tsx | 1 - .../dialogs/nuxs/NeueTypography.tsx | 1 - src/components/dms/ConvoMenu.tsx | 6 +- src/components/dms/MessageMenu.tsx | 10 +- src/components/dms/ReportDialog.tsx | 7 +- src/components/dms/dialogs/NewChatDialog.tsx | 8 +- .../dms/dialogs/SearchablePeopleList.tsx | 101 ++-- .../dms/dialogs/ShareViaChatDialog.tsx | 8 +- src/components/forms/Toggle.tsx | 2 +- .../moderation/LabelsOnMeDialog.tsx | 71 +-- .../moderation/ModerationDetailsDialog.tsx | 33 +- src/lib/media/video/upload.ts | 2 +- src/lib/media/video/upload.web.ts | 2 +- src/screens/Onboarding/StepProfile/index.tsx | 37 +- src/screens/StarterPack/StarterPackScreen.tsx | 44 +- src/state/dialogs/index.tsx | 87 +-- src/state/preferences/in-app-browser.tsx | 12 +- src/view/com/auth/server-input/index.tsx | 6 +- src/view/com/composer/Composer.tsx | 570 +++++++++--------- src/view/com/composer/GifAltText.tsx | 9 +- .../composer/photos/EditImageDialog.web.tsx | 1 + src/view/com/composer/photos/Gallery.tsx | 13 +- .../composer/photos/ImageAltTextDialog.tsx | 9 +- src/view/com/composer/photos/SelectGifBtn.tsx | 5 +- .../com/composer/photos/SelectPhotoBtn.tsx | 14 +- .../com/composer/threadgate/ThreadgateBtn.tsx | 5 + .../com/composer/videos/SubtitleDialog.tsx | 8 +- src/view/com/util/UserAvatar.tsx | 12 +- src/view/com/util/UserBanner.tsx | 6 +- src/view/com/util/forms/PostDropdownBtn.tsx | 16 +- src/view/com/util/post-ctrls/RepostButton.tsx | 5 +- .../Settings/DisableEmail2FADialog.tsx | 1 - src/view/screens/Settings/ExportCarDialog.tsx | 1 - src/view/screens/Storybook/Dialogs.tsx | 14 +- src/view/shell/Composer.ios.tsx | 77 +-- src/view/shell/index.tsx | 37 +- yarn.lock | 25 +- 86 files changed, 2461 insertions(+), 1529 deletions(-) create mode 100644 modules/bottom-sheet/android/build.gradle create mode 100644 modules/bottom-sheet/android/src/main/AndroidManifest.xml create mode 100644 modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt create mode 100644 modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt create mode 100644 modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt create mode 100644 modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt create mode 100644 modules/bottom-sheet/expo-module.config.json create mode 100644 modules/bottom-sheet/index.ts create mode 100644 modules/bottom-sheet/ios/BottomSheet.podspec create mode 100644 modules/bottom-sheet/ios/BottomSheetModule.swift create mode 100644 modules/bottom-sheet/ios/SheetManager.swift create mode 100644 modules/bottom-sheet/ios/SheetView.swift create mode 100644 modules/bottom-sheet/ios/SheetViewController.swift create mode 100644 modules/bottom-sheet/ios/Util.swift create mode 100644 modules/bottom-sheet/src/BottomSheet.tsx create mode 100644 modules/bottom-sheet/src/BottomSheet.types.ts create mode 100644 modules/bottom-sheet/src/BottomSheet.web.tsx create mode 100644 src/components/Dialog/sheet-wrapper.ts delete mode 100644 src/components/KeyboardControllerPadding.android.tsx delete mode 100644 src/components/KeyboardControllerPadding.tsx delete mode 100644 src/components/dialogs/GifSelect.ios.tsx delete mode 100644 src/components/dialogs/GifSelect.shared.tsx diff --git a/jest/jestSetup.js b/jest/jestSetup.js index 4653490f3c..5564d81f15 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -96,6 +96,11 @@ jest.mock('expo-modules-core', () => ({ getIsReducedMotionEnabled: () => false, } } + if (moduleName === 'BottomSheet') { + return { + dismissAll: () => {}, + } + } }), requireNativeViewManager: jest.fn().mockImplementation(moduleName => { return () => null diff --git a/modules/bottom-sheet/android/build.gradle b/modules/bottom-sheet/android/build.gradle new file mode 100644 index 0000000000..a1d423044b --- /dev/null +++ b/modules/bottom-sheet/android/build.gradle @@ -0,0 +1,49 @@ +apply plugin: 'com.android.library' + +group = 'expo.modules.bottomsheet' +version = '0.1.0' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() + +// If you want to use the managed Android SDK versions from expo-modules-core, set this to true. +// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. +// Most of the time, you may like to manage the Android SDK versions yourself. +def useManagedAndroidSdkVersions = false +if (useManagedAndroidSdkVersions) { + useDefaultAndroidSdkVersions() +} else { + buildscript { + // Simple helper that allows the root project to override versions declared by this library. + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback + } + } + project.android { + compileSdkVersion safeExtGet("compileSdkVersion", 34) + defaultConfig { + minSdkVersion safeExtGet("minSdkVersion", 21) + targetSdkVersion safeExtGet("targetSdkVersion", 34) + } + } +} + +android { + namespace "expo.modules.bottomsheet" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } + lintOptions { + abortOnError false + } +} + +dependencies { + implementation project(':expo-modules-core') + implementation 'com.google.android.material:material:1.12.0' + implementation "com.facebook.react:react-native:+" +} diff --git a/modules/bottom-sheet/android/src/main/AndroidManifest.xml b/modules/bottom-sheet/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..bdae66c8f5 --- /dev/null +++ b/modules/bottom-sheet/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt new file mode 100644 index 0000000000..057e6ed2e2 --- /dev/null +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt @@ -0,0 +1,53 @@ +package expo.modules.bottomsheet + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class BottomSheetModule : Module() { + override fun definition() = + ModuleDefinition { + Name("BottomSheet") + + AsyncFunction("dismissAll") { + SheetManager.dismissAll() + } + + View(BottomSheetView::class) { + Events( + arrayOf( + "onAttemptDismiss", + "onSnapPointChange", + "onStateChange", + ), + ) + + AsyncFunction("dismiss") { view: BottomSheetView -> + view.dismiss() + } + + AsyncFunction("updateLayout") { view: BottomSheetView -> + view.updateLayout() + } + + Prop("disableDrag") { view: BottomSheetView, prop: Boolean -> + view.disableDrag = prop + } + + Prop("minHeight") { view: BottomSheetView, prop: Float -> + view.minHeight = prop + } + + Prop("maxHeight") { view: BottomSheetView, prop: Float -> + view.maxHeight = prop + } + + Prop("preventDismiss") { view: BottomSheetView, prop: Boolean -> + view.preventDismiss = prop + } + + Prop("preventExpansion") { view: BottomSheetView, prop: Boolean -> + view.preventExpansion = prop + } + } + } +} diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt new file mode 100644 index 0000000000..a5a84ec3d9 --- /dev/null +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt @@ -0,0 +1,339 @@ +package expo.modules.bottomsheet + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.view.ViewStructure +import android.view.accessibility.AccessibilityEvent +import android.widget.FrameLayout +import androidx.core.view.allViews +import com.facebook.react.bridge.LifecycleEventListener +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.EventDispatcher +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView +import java.util.ArrayList + +class BottomSheetView( + context: Context, + appContext: AppContext, +) : ExpoView(context, appContext), + LifecycleEventListener { + private var innerView: View? = null + private var dialog: BottomSheetDialog? = null + + private lateinit var dialogRootViewGroup: DialogRootViewGroup + private var eventDispatcher: EventDispatcher? = null + + private val screenHeight = + context.resources.displayMetrics.heightPixels + .toFloat() + + private val onAttemptDismiss by EventDispatcher() + private val onSnapPointChange by EventDispatcher() + private val onStateChange by EventDispatcher() + + // Props + var disableDrag = false + set (value) { + field = value + this.setDraggable(!value) + } + + var preventDismiss = false + set(value) { + field = value + this.dialog?.setCancelable(!value) + } + var preventExpansion = false + + var minHeight = 0f + set(value) { + field = + if (value < 0) { + 0f + } else { + value + } + } + + var maxHeight = this.screenHeight + set(value) { + field = + if (value > this.screenHeight) { + this.screenHeight.toFloat() + } else { + value + } + } + + private var isOpen: Boolean = false + set(value) { + field = value + onStateChange( + mapOf( + "state" to if (value) "open" else "closed", + ), + ) + } + + private var isOpening: Boolean = false + set(value) { + field = value + if (value) { + onStateChange( + mapOf( + "state" to "opening", + ), + ) + } + } + + private var isClosing: Boolean = false + set(value) { + field = value + if (value) { + onStateChange( + mapOf( + "state" to "closing", + ), + ) + } + } + + private var selectedSnapPoint = 0 + set(value) { + if (field == value) return + + field = value + onSnapPointChange( + mapOf( + "snapPoint" to value, + ), + ) + } + + // Lifecycle + + init { + (appContext.reactContext as? ReactContext)?.let { + it.addLifecycleEventListener(this) + this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id) + + this.dialogRootViewGroup = DialogRootViewGroup(context) + this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher + } + SheetManager.add(this) + } + + override fun onLayout( + changed: Boolean, + l: Int, + t: Int, + r: Int, + b: Int, + ) { + this.present() + } + + private fun destroy() { + this.isClosing = false + this.isOpen = false + this.dialog = null + this.innerView = null + SheetManager.remove(this) + } + + // Presentation + + private fun present() { + if (this.isOpen || this.isOpening || this.isClosing) return + + val contentHeight = this.getContentHeight() + + val dialog = BottomSheetDialog(context) + dialog.setContentView(dialogRootViewGroup) + dialog.setCancelable(!preventDismiss) + dialog.setOnShowListener { + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.let { + // Let the outside view handle the background color on its own, the default for this is + // white and we don't want that. + it.setBackgroundColor(0) + + val behavior = BottomSheetBehavior.from(it) + + behavior.isFitToContents = true + behavior.halfExpandedRatio = this.clampRatio(this.getTargetHeight() / this.screenHeight) + if (contentHeight > this.screenHeight) { + behavior.state = BottomSheetBehavior.STATE_EXPANDED + this.selectedSnapPoint = 2 + } else { + behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED + this.selectedSnapPoint = 1 + } + behavior.skipCollapsed = true + behavior.isDraggable = true + behavior.isHideable = true + + behavior.addBottomSheetCallback( + object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged( + bottomSheet: View, + newState: Int, + ) { + when (newState) { + BottomSheetBehavior.STATE_EXPANDED -> { + selectedSnapPoint = 2 + } + BottomSheetBehavior.STATE_COLLAPSED -> { + selectedSnapPoint = 1 + } + BottomSheetBehavior.STATE_HALF_EXPANDED -> { + selectedSnapPoint = 1 + } + BottomSheetBehavior.STATE_HIDDEN -> { + selectedSnapPoint = 0 + } + } + } + + override fun onSlide( + bottomSheet: View, + slideOffset: Float, + ) { } + }, + ) + } + } + dialog.setOnDismissListener { + this.isClosing = true + this.destroy() + } + + this.isOpening = true + dialog.show() + this.dialog = dialog + } + + fun updateLayout() { + val dialog = this.dialog ?: return + val contentHeight = this.getContentHeight() + + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.let { + val behavior = BottomSheetBehavior.from(it) + + behavior.halfExpandedRatio = this.clampRatio(this.getTargetHeight() / this.screenHeight) + + if (contentHeight > this.screenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) { + behavior.state = BottomSheetBehavior.STATE_EXPANDED + } else if (contentHeight < this.screenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) { + behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED + } + } + } + + fun dismiss() { + this.dialog?.dismiss() + } + + // Util + + private fun getContentHeight(): Float { + val innerView = this.innerView ?: return 0f + var index = 0 + innerView.allViews.forEach { + if (index == 1) { + return it.height.toFloat() + } + index++ + } + return 0f + } + + private fun getTargetHeight(): Float { + val contentHeight = this.getContentHeight() + val height = + if (contentHeight > maxHeight) { + maxHeight + } else if (contentHeight < minHeight) { + minHeight + } else { + contentHeight + } + return height + } + + private fun clampRatio(ratio: Float): Float { + if (ratio < 0.01) { + return 0.01f + } else if (ratio > 0.99) { + return 0.99f + } + return ratio + } + + private fun setDraggable(draggable: Boolean) { + val dialog = this.dialog ?: return + val bottomSheet = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet) + bottomSheet?.let { + val behavior = BottomSheetBehavior.from(it) + behavior.isDraggable = draggable + } + } + + override fun onHostResume() { } + + override fun onHostPause() { } + + override fun onHostDestroy() { + (appContext.reactContext as? ReactContext)?.let { + it.removeLifecycleEventListener(this) + this.destroy() + } + } + + // View overrides to pass to DialogRootViewGroup instead + + override fun dispatchProvideStructure(structure: ViewStructure?) { + dialogRootViewGroup.dispatchProvideStructure(structure) + } + + override fun setId(id: Int) { + super.setId(id) + dialogRootViewGroup.id = id + } + + override fun addView( + child: View?, + index: Int, + ) { + this.innerView = child + (child as ViewGroup).let { + dialogRootViewGroup.addView(child, index) + } + } + + override fun removeView(view: View?) { + UiThreadUtil.assertOnUiThread() + if (view != null) { + dialogRootViewGroup.removeView(view) + } + } + + override fun removeViewAt(index: Int) { + UiThreadUtil.assertOnUiThread() + val child = getChildAt(index) + dialogRootViewGroup.removeView(child) + } + + override fun addChildrenForAccessibility(outChildren: ArrayList?) { } + + override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent?): Boolean = false +} diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt new file mode 100644 index 0000000000..c022924e99 --- /dev/null +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt @@ -0,0 +1,171 @@ +package expo.modules.bottomsheet + +import android.annotation.SuppressLint +import android.content.Context +import android.view.MotionEvent +import android.view.View +import com.facebook.react.bridge.GuardedRunnable +import com.facebook.react.config.ReactFeatureFlags +import com.facebook.react.uimanager.JSPointerDispatcher +import com.facebook.react.uimanager.JSTouchDispatcher +import com.facebook.react.uimanager.RootView +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerModule +import com.facebook.react.uimanager.events.EventDispatcher +import com.facebook.react.views.view.ReactViewGroup + +// SEE https://github.com/facebook/react-native/blob/309cdea337101cfe2212cfb6abebf1e783e43282/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt#L378 + +/** + * DialogRootViewGroup is the ViewGroup which contains all the children of a Modal. It gets all + * child information forwarded from [ReactModalHostView] and uses that to create children. It is + * also responsible for acting as a RootView and handling touch events. It does this the same way + * as ReactRootView. + * + * To get layout to work properly, we need to layout all the elements within the Modal as if they + * can fill the entire window. To do that, we need to explicitly set the styleWidth and + * styleHeight on the LayoutShadowNode to be the window size. This is done through the + * UIManagerModule, and will then cause the children to layout as if they can fill the window. + */ +class DialogRootViewGroup( + private val context: Context?, +) : ReactViewGroup(context), + RootView { + private var hasAdjustedSize = false + private var viewWidth = 0 + private var viewHeight = 0 + + private val jSTouchDispatcher = JSTouchDispatcher(this) + private var jSPointerDispatcher: JSPointerDispatcher? = null + private var sizeChangeListener: OnSizeChangeListener? = null + + var eventDispatcher: EventDispatcher? = null + + interface OnSizeChangeListener { + fun onSizeChange( + width: Int, + height: Int, + ) + } + + init { + if (ReactFeatureFlags.dispatchPointerEvents) { + jSPointerDispatcher = JSPointerDispatcher(this) + } + } + + override fun onSizeChanged( + w: Int, + h: Int, + oldw: Int, + oldh: Int, + ) { + super.onSizeChanged(w, h, oldw, oldh) + + viewWidth = w + viewHeight = h + updateFirstChildView() + + sizeChangeListener?.onSizeChange(w, h) + } + + fun setOnSizeChangeListener(listener: OnSizeChangeListener) { + sizeChangeListener = listener + } + + private fun updateFirstChildView() { + if (childCount > 0) { + hasAdjustedSize = false + val viewTag = getChildAt(0).id + reactContext.runOnNativeModulesQueueThread( + object : GuardedRunnable(reactContext) { + override fun runGuarded() { + val uiManager: UIManagerModule = + reactContext + .reactApplicationContext + .getNativeModule(UIManagerModule::class.java) ?: return + + uiManager.updateNodeSize(viewTag, viewWidth, viewHeight) + } + }, + ) + } else { + hasAdjustedSize = true + } + } + + override fun addView( + child: View, + index: Int, + params: LayoutParams, + ) { + super.addView(child, index, params) + if (hasAdjustedSize) { + updateFirstChildView() + } + } + + override fun handleException(t: Throwable) { + reactContext.reactApplicationContext.handleException(RuntimeException(t)) + } + + private val reactContext: ThemedReactContext + get() = context as ThemedReactContext + + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + eventDispatcher?.let { jSTouchDispatcher.handleTouchEvent(event, it) } + jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true) + return super.onInterceptTouchEvent(event) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + eventDispatcher?.let { jSTouchDispatcher.handleTouchEvent(event, it) } + jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false) + super.onTouchEvent(event) + + // In case when there is no children interested in handling touch event, we return true from + // the root view in order to receive subsequent events related to that gesture + return true + } + + override fun onInterceptHoverEvent(event: MotionEvent): Boolean { + jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true) + return super.onHoverEvent(event) + } + + override fun onHoverEvent(event: MotionEvent): Boolean { + jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false) + return super.onHoverEvent(event) + } + + @Deprecated("Deprecated in Java") + override fun onChildStartedNativeGesture(ev: MotionEvent?) { + eventDispatcher?.let { + if (ev != null) { + jSTouchDispatcher.onChildStartedNativeGesture(ev, it) + } + } + } + + override fun onChildStartedNativeGesture( + childView: View, + ev: MotionEvent, + ) { + eventDispatcher?.let { jSTouchDispatcher.onChildStartedNativeGesture(ev, it) } + jSPointerDispatcher?.onChildStartedNativeGesture(childView, ev, eventDispatcher) + } + + override fun onChildEndedNativeGesture( + childView: View, + ev: MotionEvent, + ) { + eventDispatcher?.let { jSTouchDispatcher.onChildEndedNativeGesture(ev, it) } + jSPointerDispatcher?.onChildEndedNativeGesture() + } + + override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { + // No-op - override in order to still receive events to onInterceptTouchEvent + // even when some other view disallow that + } +} diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt new file mode 100644 index 0000000000..be78849988 --- /dev/null +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt @@ -0,0 +1,28 @@ +package expo.modules.bottomsheet + +import java.lang.ref.WeakReference + +class SheetManager { + companion object { + private val sheets = mutableSetOf>() + + fun add(view: BottomSheetView) { + sheets.add(WeakReference(view)) + } + + fun remove(view: BottomSheetView) { + sheets.forEach { + if (it.get() == view) { + sheets.remove(it) + return + } + } + } + + fun dismissAll() { + sheets.forEach { + it.get()?.dismiss() + } + } + } +} diff --git a/modules/bottom-sheet/expo-module.config.json b/modules/bottom-sheet/expo-module.config.json new file mode 100644 index 0000000000..81b5b078ed --- /dev/null +++ b/modules/bottom-sheet/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["ios", "android"], + "ios": { + "modules": ["BottomSheetModule"] + }, + "android": { + "modules": ["expo.modules.bottomsheet.BottomSheetModule"] + } +} diff --git a/modules/bottom-sheet/index.ts b/modules/bottom-sheet/index.ts new file mode 100644 index 0000000000..1fe3dac0eb --- /dev/null +++ b/modules/bottom-sheet/index.ts @@ -0,0 +1,13 @@ +import {BottomSheet} from './src/BottomSheet' +import { + BottomSheetSnapPoint, + BottomSheetState, + BottomSheetViewProps, +} from './src/BottomSheet.types' + +export { + BottomSheet, + BottomSheetSnapPoint, + type BottomSheetState, + type BottomSheetViewProps, +} diff --git a/modules/bottom-sheet/ios/BottomSheet.podspec b/modules/bottom-sheet/ios/BottomSheet.podspec new file mode 100644 index 0000000000..a42356f614 --- /dev/null +++ b/modules/bottom-sheet/ios/BottomSheet.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'BottomSheet' + s.version = '1.0.0' + s.summary = 'A bottom sheet for use in Bluesky' + s.description = 'A bottom sheet for use in Bluesky' + s.author = '' + s.homepage = 'https://github.com/bluesky-social/social-app' + s.platforms = { :ios => '15.0', :tvos => '15.0' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + # Swift/Objective-C compatibility + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,swift}" +end diff --git a/modules/bottom-sheet/ios/BottomSheetModule.swift b/modules/bottom-sheet/ios/BottomSheetModule.swift new file mode 100644 index 0000000000..579608e75b --- /dev/null +++ b/modules/bottom-sheet/ios/BottomSheetModule.swift @@ -0,0 +1,47 @@ +import ExpoModulesCore + +public class BottomSheetModule: Module { + public func definition() -> ModuleDefinition { + Name("BottomSheet") + + AsyncFunction("dismissAll") { + SheetManager.shared.dismissAll() + } + + View(SheetView.self) { + Events([ + "onAttemptDismiss", + "onSnapPointChange", + "onStateChange" + ]) + + AsyncFunction("dismiss") { (view: SheetView) in + view.dismiss() + } + + AsyncFunction("updateLayout") { (view: SheetView) in + view.updateLayout() + } + + Prop("cornerRadius") { (view: SheetView, prop: Float) in + view.cornerRadius = CGFloat(prop) + } + + Prop("minHeight") { (view: SheetView, prop: Double) in + view.minHeight = prop + } + + Prop("maxHeight") { (view: SheetView, prop: Double) in + view.maxHeight = prop + } + + Prop("preventDismiss") { (view: SheetView, prop: Bool) in + view.preventDismiss = prop + } + + Prop("preventExpansion") { (view: SheetView, prop: Bool) in + view.preventExpansion = prop + } + } + } +} diff --git a/modules/bottom-sheet/ios/SheetManager.swift b/modules/bottom-sheet/ios/SheetManager.swift new file mode 100644 index 0000000000..e4e843bea5 --- /dev/null +++ b/modules/bottom-sheet/ios/SheetManager.swift @@ -0,0 +1,28 @@ +// +// SheetManager.swift +// Pods +// +// Created by Hailey on 10/1/24. +// + +import ExpoModulesCore + +class SheetManager { + static let shared = SheetManager() + + private var sheetViews = NSHashTable(options: .weakMemory) + + func add(_ view: SheetView) { + sheetViews.add(view) + } + + func remove(_ view: SheetView) { + sheetViews.remove(view) + } + + func dismissAll() { + sheetViews.allObjects.forEach { sheetView in + sheetView.dismiss() + } + } +} diff --git a/modules/bottom-sheet/ios/SheetView.swift b/modules/bottom-sheet/ios/SheetView.swift new file mode 100644 index 0000000000..cf2019c6a1 --- /dev/null +++ b/modules/bottom-sheet/ios/SheetView.swift @@ -0,0 +1,189 @@ +import ExpoModulesCore +import UIKit + +class SheetView: ExpoView, UISheetPresentationControllerDelegate { + // Views + private var sheetVc: SheetViewController? + private var innerView: UIView? + private var touchHandler: RCTTouchHandler? + + // Events + private let onAttemptDismiss = EventDispatcher() + private let onSnapPointChange = EventDispatcher() + private let onStateChange = EventDispatcher() + + // Open event firing + private var isOpen: Bool = false { + didSet { + onStateChange([ + "state": isOpen ? "open" : "closed" + ]) + } + } + + // React view props + var preventDismiss = false + var preventExpansion = false + var cornerRadius: CGFloat? + var minHeight = 0.0 + var maxHeight: CGFloat! { + didSet { + let screenHeight = Util.getScreenHeight() ?? 0 + if maxHeight > screenHeight { + maxHeight = screenHeight + } + } + } + + private var isOpening = false { + didSet { + if isOpening { + onStateChange([ + "state": "opening" + ]) + } + } + } + private var isClosing = false { + didSet { + if isClosing { + onStateChange([ + "state": "closing" + ]) + } + } + } + private var selectedDetentIdentifier: UISheetPresentationController.Detent.Identifier? { + didSet { + if selectedDetentIdentifier == .large { + onSnapPointChange([ + "snapPoint": 2 + ]) + } else { + onSnapPointChange([ + "snapPoint": 1 + ]) + } + } + } + + // MARK: - Lifecycle + + required init (appContext: AppContext? = nil) { + super.init(appContext: appContext) + self.maxHeight = Util.getScreenHeight() + self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge) + SheetManager.shared.add(self) + } + + deinit { + self.destroy() + } + + // We don't want this view to actually get added to the tree, so we'll simply store it for adding + // to the SheetViewController + override func insertReactSubview(_ subview: UIView!, at atIndex: Int) { + self.touchHandler?.attach(to: subview) + self.innerView = subview + } + + // We'll grab the content height from here so we know the initial detent to set + override func layoutSubviews() { + super.layoutSubviews() + + guard let innerView = self.innerView else { + return + } + + if innerView.subviews.count != 1 { + return + } + + self.present() + } + + private func destroy() { + self.isClosing = false + self.isOpen = false + self.sheetVc = nil + self.touchHandler?.detach(from: self.innerView) + self.touchHandler = nil + self.innerView = nil + SheetManager.shared.remove(self) + } + + // MARK: - Presentation + + func present() { + guard !self.isOpen, + !self.isOpening, + !self.isClosing, + let innerView = self.innerView, + let contentHeight = innerView.subviews.first?.frame.height, + let rvc = self.reactViewController() else { + return + } + + let sheetVc = SheetViewController() + sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion) + if let sheet = sheetVc.sheetPresentationController { + sheet.delegate = self + sheet.preferredCornerRadius = self.cornerRadius + self.selectedDetentIdentifier = sheet.selectedDetentIdentifier + } + sheetVc.view.addSubview(innerView) + + self.sheetVc = sheetVc + self.isOpening = true + + rvc.present(sheetVc, animated: true) { [weak self] in + self?.isOpening = false + self?.isOpen = true + } + } + + func updateLayout() { + if let contentHeight = self.innerView?.subviews.first?.frame.size.height { + self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight), + preventExpansion: self.preventExpansion) + self.selectedDetentIdentifier = self.sheetVc?.getCurrentDetentIdentifier() + } + } + + func dismiss() { + self.isClosing = true + self.sheetVc?.dismiss(animated: true) { [weak self] in + self?.destroy() + } + } + + // MARK: - Utils + + private func clampHeight(_ height: CGFloat) -> CGFloat { + if height < self.minHeight { + return self.minHeight + } else if height > self.maxHeight { + return self.maxHeight + } + return height + } + + // MARK: - UISheetPresentationControllerDelegate + + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + self.onAttemptDismiss() + return !self.preventDismiss + } + + func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { + self.isClosing = true + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + self.destroy() + } + + func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) { + self.selectedDetentIdentifier = sheetPresentationController.selectedDetentIdentifier + } +} diff --git a/modules/bottom-sheet/ios/SheetViewController.swift b/modules/bottom-sheet/ios/SheetViewController.swift new file mode 100644 index 0000000000..56473b21cd --- /dev/null +++ b/modules/bottom-sheet/ios/SheetViewController.swift @@ -0,0 +1,76 @@ +// +// SheetViewController.swift +// Pods +// +// Created by Hailey on 9/30/24. +// + +import Foundation +import UIKit + +class SheetViewController: UIViewController { + init() { + super.init(nibName: nil, bundle: nil) + + self.modalPresentationStyle = .formSheet + self.isModalInPresentation = false + + if let sheet = self.sheetPresentationController { + sheet.prefersGrabberVisible = false + } + } + + func setDetents(contentHeight: CGFloat, preventExpansion: Bool) { + guard let sheet = self.sheetPresentationController, + let screenHeight = Util.getScreenHeight() + else { + return + } + + if contentHeight > screenHeight - 100 { + sheet.detents = [ + .large() + ] + sheet.selectedDetentIdentifier = .large + } else { + if #available(iOS 16.0, *) { + sheet.detents = [ + .custom { _ in + return contentHeight + } + ] + } else { + sheet.detents = [ + .medium() + ] + } + + if !preventExpansion { + sheet.detents.append(.large()) + } + sheet.selectedDetentIdentifier = .medium + } + } + + func updateDetents(contentHeight: CGFloat, preventExpansion: Bool) { + if let sheet = self.sheetPresentationController { + sheet.animateChanges { + self.setDetents(contentHeight: contentHeight, preventExpansion: preventExpansion) + if #available(iOS 16.0, *) { + sheet.invalidateDetents() + } + } + } + } + + func getCurrentDetentIdentifier() -> UISheetPresentationController.Detent.Identifier? { + guard let sheet = self.sheetPresentationController else { + return nil + } + return sheet.selectedDetentIdentifier + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/modules/bottom-sheet/ios/Util.swift b/modules/bottom-sheet/ios/Util.swift new file mode 100644 index 0000000000..c654596a74 --- /dev/null +++ b/modules/bottom-sheet/ios/Util.swift @@ -0,0 +1,18 @@ +// +// Util.swift +// Pods +// +// Created by Hailey on 10/2/24. +// + +class Util { + static func getScreenHeight() -> CGFloat? { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first { + let safeAreaInsets = window.safeAreaInsets + let fullScreenHeight = UIScreen.main.bounds.height + return fullScreenHeight - (safeAreaInsets.top + safeAreaInsets.bottom) + } + return nil + } +} diff --git a/modules/bottom-sheet/src/BottomSheet.tsx b/modules/bottom-sheet/src/BottomSheet.tsx new file mode 100644 index 0000000000..9e7d0c2091 --- /dev/null +++ b/modules/bottom-sheet/src/BottomSheet.tsx @@ -0,0 +1,100 @@ +import * as React from 'react' +import { + Dimensions, + NativeSyntheticEvent, + Platform, + StyleProp, + View, + ViewStyle, +} from 'react-native' +import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core' + +import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types' + +const screenHeight = Dimensions.get('screen').height + +const NativeView: React.ComponentType< + BottomSheetViewProps & { + ref: React.RefObject + style: StyleProp + } +> = requireNativeViewManager('BottomSheet') + +const NativeModule = requireNativeModule('BottomSheet') + +export class BottomSheet extends React.Component< + BottomSheetViewProps, + { + open: boolean + } +> { + ref = React.createRef() + + constructor(props: BottomSheetViewProps) { + super(props) + this.state = { + open: false, + } + } + + present() { + this.setState({open: true}) + } + + dismiss() { + this.ref.current?.dismiss() + } + + private onStateChange = ( + event: NativeSyntheticEvent<{state: BottomSheetState}>, + ) => { + const {state} = event.nativeEvent + const isOpen = state !== 'closed' + this.setState({open: isOpen}) + this.props.onStateChange?.(event) + } + + private updateLayout = () => { + this.ref.current?.updateLayout() + } + + static dismissAll = async () => { + await NativeModule.dismissAll() + } + + render() { + const {children, backgroundColor, ...rest} = this.props + const cornerRadius = rest.cornerRadius ?? 0 + + if (!this.state.open) { + return null + } + + return ( + + + {children} + + + ) + } +} diff --git a/modules/bottom-sheet/src/BottomSheet.types.ts b/modules/bottom-sheet/src/BottomSheet.types.ts new file mode 100644 index 0000000000..150932d423 --- /dev/null +++ b/modules/bottom-sheet/src/BottomSheet.types.ts @@ -0,0 +1,35 @@ +import React from 'react' +import {ColorValue, NativeSyntheticEvent} from 'react-native' + +export type BottomSheetState = 'closed' | 'closing' | 'open' | 'opening' + +export enum BottomSheetSnapPoint { + Hidden, + Partial, + Full, +} + +export type BottomSheetAttemptDismissEvent = NativeSyntheticEvent +export type BottomSheetSnapPointChangeEvent = NativeSyntheticEvent<{ + snapPoint: BottomSheetSnapPoint +}> +export type BottomSheetStateChangeEvent = NativeSyntheticEvent<{ + state: BottomSheetState +}> + +export interface BottomSheetViewProps { + children: React.ReactNode + cornerRadius?: number + preventDismiss?: boolean + preventExpansion?: boolean + backgroundColor?: ColorValue + containerBackgroundColor?: ColorValue + disableDrag?: boolean + + minHeight?: number + maxHeight?: number + + onAttemptDismiss?: (event: BottomSheetAttemptDismissEvent) => void + onSnapPointChange?: (event: BottomSheetSnapPointChangeEvent) => void + onStateChange?: (event: BottomSheetStateChangeEvent) => void +} diff --git a/modules/bottom-sheet/src/BottomSheet.web.tsx b/modules/bottom-sheet/src/BottomSheet.web.tsx new file mode 100644 index 0000000000..4573604eb2 --- /dev/null +++ b/modules/bottom-sheet/src/BottomSheet.web.tsx @@ -0,0 +1,5 @@ +import {BottomSheetViewProps} from './BottomSheet.types' + +export function BottomSheet(_: BottomSheetViewProps) { + throw new Error('BottomSheetView is not available on web') +} diff --git a/package.json b/package.json index 9f66545db8..828f11cb76 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,6 @@ "expo-sharing": "^12.0.1", "expo-splash-screen": "~0.27.4", "expo-status-bar": "~1.12.1", - "expo-system-ui": "~3.0.4", "expo-task-manager": "~11.8.1", "expo-updates": "~0.25.14", "expo-web-browser": "~13.0.3", @@ -171,11 +170,11 @@ "react-native-compressor": "^1.8.24", "react-native-date-picker": "^4.4.2", "react-native-drawer-layout": "^4.0.0-alpha.3", - "react-native-gesture-handler": "~2.16.2", + "react-native-gesture-handler": "2.20.0", "react-native-get-random-values": "~1.11.0", "react-native-image-crop-picker": "0.41.2", "react-native-ios-context-menu": "^1.15.3", - "react-native-keyboard-controller": "^1.12.1", + "react-native-keyboard-controller": "^1.14.0", "react-native-mmkv": "^2.12.2", "react-native-pager-view": "6.2.3", "react-native-picker-select": "^9.1.3", diff --git a/src/App.native.tsx b/src/App.native.tsx index e2fcd6d2ec..c6334379f7 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -1,6 +1,6 @@ import 'react-native-url-polyfill/auto' -import 'lib/sentry' // must be near top -import 'view/icons' +import '#/lib/sentry' // must be near top +import '#/view/icons' import React, {useEffect, useState} from 'react' import {GestureHandlerRootView} from 'react-native-gesture-handler' diff --git a/src/alf/util/useColorModeTheme.ts b/src/alf/util/useColorModeTheme.ts index 12840c7062..561a504b2f 100644 --- a/src/alf/util/useColorModeTheme.ts +++ b/src/alf/util/useColorModeTheme.ts @@ -1,9 +1,8 @@ import React from 'react' import {ColorSchemeName, useColorScheme} from 'react-native' -import * as SystemUI from 'expo-system-ui' -import {isWeb} from 'platform/detection' -import {useThemePrefs} from 'state/shell' +import {isWeb} from '#/platform/detection' +import {useThemePrefs} from '#/state/shell' import {dark, dim, light} from '#/alf/themes' import {ThemeName} from '#/alf/types' @@ -12,7 +11,6 @@ export function useColorModeTheme(): ThemeName { React.useLayoutEffect(() => { updateDocument(theme) - SystemUI.setBackgroundColorAsync(getBackgroundColor(theme)) }, [theme]) return theme diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 1c14b48c75..4acb4f1dc4 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -87,6 +87,7 @@ export type ButtonProps = Pick< style?: StyleProp hoverStyle?: StyleProp children: NonTextElements | ((context: ButtonContext) => NonTextElements) + PressableComponent?: React.ComponentType } export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} @@ -114,6 +115,7 @@ export const Button = React.forwardRef( disabled = false, style, hoverStyle: hoverStyleProp, + PressableComponent = Pressable, ...rest }, ref, @@ -449,10 +451,11 @@ export const Button = React.forwardRef( const flattenedBaseStyles = flatten([baseStyles, style]) return ( - ( {typeof children === 'function' ? children(context) : children} - + ) }, ) diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts index 859f8edd77..b479bc7f06 100644 --- a/src/components/Dialog/context.ts +++ b/src/components/Dialog/context.ts @@ -6,9 +6,14 @@ import { DialogControlRefProps, DialogOuterProps, } from '#/components/Dialog/types' +import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types' export const Context = React.createContext({ close: () => {}, + isNativeDialog: false, + nativeSnapPoint: BottomSheetSnapPoint.Hidden, + disableDrag: false, + setDisableDrag: () => {}, }) export function useDialogContext() { diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index d5d92048ad..49b5e10b23 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -1,86 +1,48 @@ import React, {useImperativeHandle} from 'react' import { - Dimensions, - Keyboard, + NativeScrollEvent, + NativeSyntheticEvent, Pressable, + ScrollView, StyleProp, + TextInput, View, ViewStyle, } from 'react-native' -import Animated, {useAnimatedStyle} from 'react-native-reanimated' +import { + KeyboardAwareScrollView, + useKeyboardHandler, +} from 'react-native-keyboard-controller' +import {runOnJS} from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import BottomSheet, { - BottomSheetBackdropProps, - BottomSheetFlatList, - BottomSheetFlatListMethods, - BottomSheetScrollView, - BottomSheetScrollViewMethods, - BottomSheetTextInput, - BottomSheetView, - useBottomSheet, - WINDOW_HEIGHT, -} from '@discord/bottom-sheet/src' -import {BottomSheetFlatListProps} from '@discord/bottom-sheet/src/components/bottomSheetScrollable/types' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {logger} from '#/logger' +import {isAndroid, isIOS} from '#/platform/detection' +import {useA11y} from '#/state/a11y' import {useDialogStateControlContext} from '#/state/dialogs' -import {atoms as a, flatten, useTheme} from '#/alf' -import {Context} from '#/components/Dialog/context' +import {List, ListMethods, ListProps} from '#/view/com/util/List' +import {atoms as a, useTheme} from '#/alf' +import {Context, useDialogContext} from '#/components/Dialog/context' import { DialogControlProps, DialogInnerProps, DialogOuterProps, } from '#/components/Dialog/types' import {createInput} from '#/components/forms/TextField' -import {FullWindowOverlay} from '#/components/FullWindowOverlay' -import {Portal} from '#/components/Portal' +import {Portal as DefaultPortal} from '#/components/Portal' +import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet' +import { + BottomSheetSnapPointChangeEvent, + BottomSheetStateChangeEvent, +} from '../../../modules/bottom-sheet/src/BottomSheet.types' export {useDialogContext, useDialogControl} from '#/components/Dialog/context' export * from '#/components/Dialog/types' export * from '#/components/Dialog/utils' // @ts-ignore -export const Input = createInput(BottomSheetTextInput) - -function Backdrop(props: BottomSheetBackdropProps) { - const t = useTheme() - const bottomSheet = useBottomSheet() - - const animatedStyle = useAnimatedStyle(() => { - const opacity = - (Math.abs(WINDOW_HEIGHT - props.animatedPosition.value) - 50) / 1000 - - return { - opacity: Math.min(Math.max(opacity, 0), 0.55), - } - }) - - const onPress = React.useCallback(() => { - bottomSheet.close() - }, [bottomSheet]) - - return ( - - - - ) -} +export const Input = createInput(TextInput) export function Outer({ children, @@ -88,24 +50,22 @@ export function Outer({ onClose, nativeOptions, testID, + Portal = DefaultPortal, }: React.PropsWithChildren) { const t = useTheme() - const sheet = React.useRef(null) - const sheetOptions = nativeOptions?.sheet || {} - const hasSnapPoints = !!sheetOptions.snapPoints - const insets = useSafeAreaInsets() + const ref = React.useRef(null) const closeCallbacks = React.useRef<(() => void)[]>([]) - const {setDialogIsOpen} = useDialogStateControlContext() + const {setDialogIsOpen, setFullyExpandedCount} = + useDialogStateControlContext() - /* - * Used to manage open/closed, but index is otherwise handled internally by `BottomSheet` - */ - const [openIndex, setOpenIndex] = React.useState(-1) + const prevSnapPoint = React.useRef( + BottomSheetSnapPoint.Hidden, + ) - /* - * `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open. - */ - const isOpen = openIndex > -1 + const [disableDrag, setDisableDrag] = React.useState(false) + const [snapPoint, setSnapPoint] = React.useState( + BottomSheetSnapPoint.Partial, + ) const callQueuedCallbacks = React.useCallback(() => { for (const cb of closeCallbacks.current) { @@ -119,25 +79,19 @@ export function Outer({ closeCallbacks.current = [] }, []) - const open = React.useCallback( - ({index} = {}) => { - // Run any leftover callbacks that might have been queued up before calling `.open()` - callQueuedCallbacks() - - setDialogIsOpen(control.id, true) - // can be set to any index of `snapPoints`, but `0` is the first i.e. "open" - setOpenIndex(index || 0) - sheet.current?.snapToIndex(index || 0) - }, - [setDialogIsOpen, control.id, callQueuedCallbacks], - ) + const open = React.useCallback(() => { + // Run any leftover callbacks that might have been queued up before calling `.open()` + callQueuedCallbacks() + setDialogIsOpen(control.id, true) + ref.current?.present() + }, [setDialogIsOpen, control.id, callQueuedCallbacks]) // This is the function that we call when we want to dismiss the dialog. const close = React.useCallback(cb => { if (typeof cb === 'function') { closeCallbacks.current.push(cb) } - sheet.current?.close() + ref.current?.dismiss() }, []) // This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to @@ -146,12 +100,39 @@ export function Outer({ // This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this // tells us that we need to toggle the accessibility overlay setting setDialogIsOpen(control.id, false) - setOpenIndex(-1) - callQueuedCallbacks() onClose?.() }, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen]) + const onSnapPointChange = (e: BottomSheetSnapPointChangeEvent) => { + const {snapPoint} = e.nativeEvent + setSnapPoint(snapPoint) + + if ( + snapPoint === BottomSheetSnapPoint.Full && + prevSnapPoint.current !== BottomSheetSnapPoint.Full + ) { + setFullyExpandedCount(c => c + 1) + } else if ( + snapPoint !== BottomSheetSnapPoint.Full && + prevSnapPoint.current === BottomSheetSnapPoint.Full + ) { + setFullyExpandedCount(c => c - 1) + } + prevSnapPoint.current = snapPoint + } + + const onStateChange = (e: BottomSheetStateChangeEvent) => { + if (e.nativeEvent.state === 'closed') { + onCloseAnimationComplete() + + if (prevSnapPoint.current === BottomSheetSnapPoint.Full) { + setFullyExpandedCount(c => c - 1) + } + prevSnapPoint.current = BottomSheetSnapPoint.Hidden + } + } + useImperativeHandle( control.ref, () => ({ @@ -161,159 +142,144 @@ export function Outer({ [open, close], ) - React.useEffect(() => { - return () => { - setDialogIsOpen(control.id, false) - } - }, [control.id, setDialogIsOpen]) - - const context = React.useMemo(() => ({close}), [close]) + const context = React.useMemo( + () => ({ + close, + isNativeDialog: true, + nativeSnapPoint: snapPoint, + disableDrag, + setDisableDrag, + }), + [close, snapPoint, disableDrag, setDisableDrag], + ) return ( - isOpen && ( - - - Keyboard.dismiss()}> - - - - {children} - - - - - - ) + + + + {children} + + + ) } export function Inner({children, style}: DialogInnerProps) { const insets = useSafeAreaInsets() return ( - {children} - + ) } -export const ScrollableInner = React.forwardRef< - BottomSheetScrollViewMethods, - DialogInnerProps ->(function ScrollableInner({children, style}, ref) { - const insets = useSafeAreaInsets() - return ( - - {children} - - - ) -}) +export const ScrollableInner = React.forwardRef( + function ScrollableInner({children, style, ...props}, ref) { + const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() + const insets = useSafeAreaInsets() + const [keyboardHeight, setKeyboardHeight] = React.useState(0) + useKeyboardHandler({ + onEnd: e => { + 'worklet' + runOnJS(setKeyboardHeight)(e.height) + }, + }) + + const basePading = + (isIOS ? 30 : 50) + (isIOS ? keyboardHeight / 4 : keyboardHeight) + const fullPaddingBase = insets.bottom + insets.top + basePading + const fullPadding = isIOS ? fullPaddingBase : fullPaddingBase + 50 + + const paddingBottom = + nativeSnapPoint === BottomSheetSnapPoint.Full ? fullPadding : basePading + + const onScroll = (e: NativeSyntheticEvent) => { + const {contentOffset} = e.nativeEvent + if (contentOffset.y > 0 && !disableDrag) { + setDisableDrag(true) + } else if (contentOffset.y <= 1 && disableDrag) { + setDisableDrag(false) + } + } + + return ( + + {children} + + ) + }, +) export const InnerFlatList = React.forwardRef< - BottomSheetFlatListMethods, - BottomSheetFlatListProps & {webInnerStyle?: StyleProp} ->(function InnerFlatList({style, contentContainerStyle, ...props}, ref) { + ListMethods, + ListProps & {webInnerStyle?: StyleProp} +>(function InnerFlatList({style, ...props}, ref) { const insets = useSafeAreaInsets() - + const {nativeSnapPoint} = useDialogContext() return ( - } ref={ref} {...props} - style={[ - a.flex_1, - a.p_xl, - a.pt_0, - a.h_full, - { - marginTop: 40, - }, - flatten(style), - ]} + style={[style]} /> ) }) export function Handle() { const t = useTheme() + const {_} = useLingui() + const {screenReaderEnabled} = useA11y() + const {close} = useDialogContext() return ( - - + + close()} + accessibilityLabel={_(msg`Dismiss`)} + accessibilityHint={_(msg`Double tap to close the dialog`)}> + + ) } diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index bf20bd2956..7b9cfb6931 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -103,6 +103,10 @@ export function Outer({ const context = React.useMemo( () => ({ close, + isNativeDialog: false, + nativeSnapPoint: 0, + disableDrag: false, + setDisableDrag: () => {}, }), [close], ) @@ -229,10 +233,6 @@ export const InnerFlatList = React.forwardRef< ) }) -export function Handle() { - return null -} - export function Close() { const {_} = useLingui() const {close} = React.useContext(Context) @@ -258,3 +258,7 @@ export function Close() { ) } + +export function Handle() { + return null +} diff --git a/src/components/Dialog/sheet-wrapper.ts b/src/components/Dialog/sheet-wrapper.ts new file mode 100644 index 0000000000..37c6633837 --- /dev/null +++ b/src/components/Dialog/sheet-wrapper.ts @@ -0,0 +1,20 @@ +import {useCallback} from 'react' + +import {useDialogStateControlContext} from '#/state/dialogs' + +/** + * If we're calling a system API like the image picker that opens a sheet + * wrap it in this function to make sure the status bar is the correct color. + */ +export function useSheetWrapper() { + const {setFullyExpandedCount} = useDialogStateControlContext() + return useCallback( + async (promise: Promise): Promise => { + setFullyExpandedCount(c => c + 1) + const res = await promise + setFullyExpandedCount(c => c - 1) + return res + }, + [setFullyExpandedCount], + ) +} diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts index 1ddab02eea..caa787535b 100644 --- a/src/components/Dialog/types.ts +++ b/src/components/Dialog/types.ts @@ -4,9 +4,11 @@ import type { GestureResponderEvent, ScrollViewProps, } from 'react-native' -import {BottomSheetProps} from '@discord/bottom-sheet/src' import {ViewStyleProp} from '#/alf' +import {PortalComponent} from '#/components/Portal' +import {BottomSheetViewProps} from '../../../modules/bottom-sheet' +import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types' type A11yProps = Required @@ -37,6 +39,10 @@ export type DialogControlProps = DialogControlRefProps & { export type DialogContextProps = { close: DialogControlProps['close'] + isNativeDialog: boolean + nativeSnapPoint: BottomSheetSnapPoint + disableDrag: boolean + setDisableDrag: React.Dispatch> } export type DialogControlOpenOptions = { @@ -52,11 +58,10 @@ export type DialogControlOpenOptions = { export type DialogOuterProps = { control: DialogControlProps onClose?: () => void - nativeOptions?: { - sheet?: Omit - } + nativeOptions?: Omit webOptions?: {} testID?: string + Portal?: PortalComponent } type DialogInnerPropsBase = React.PropsWithChildren & T diff --git a/src/components/KeyboardControllerPadding.android.tsx b/src/components/KeyboardControllerPadding.android.tsx deleted file mode 100644 index 92ef1b0b05..0000000000 --- a/src/components/KeyboardControllerPadding.android.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react' -import {useKeyboardHandler} from 'react-native-keyboard-controller' -import Animated, { - useAnimatedStyle, - useSharedValue, -} from 'react-native-reanimated' - -export function KeyboardControllerPadding({maxHeight}: {maxHeight?: number}) { - const keyboardHeight = useSharedValue(0) - - useKeyboardHandler( - { - onMove: e => { - 'worklet' - - if (maxHeight && e.height > maxHeight) { - keyboardHeight.value = maxHeight - } else { - keyboardHeight.value = e.height - } - }, - }, - [maxHeight], - ) - - const animatedStyle = useAnimatedStyle(() => ({ - height: keyboardHeight.value, - })) - - return -} diff --git a/src/components/KeyboardControllerPadding.tsx b/src/components/KeyboardControllerPadding.tsx deleted file mode 100644 index f3163d87cf..0000000000 --- a/src/components/KeyboardControllerPadding.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export function KeyboardControllerPadding({ - maxHeight: _, -}: { - maxHeight?: number -}) { - return null -} diff --git a/src/components/LikesDialog.tsx b/src/components/LikesDialog.tsx index 94a3f27e26..4c68596f73 100644 --- a/src/components/LikesDialog.tsx +++ b/src/components/LikesDialog.tsx @@ -1,20 +1,19 @@ -import React, {useMemo, useCallback} from 'react' +import React, {useCallback, useMemo} from 'react' import {ActivityIndicator, FlatList, View} from 'react-native' +import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' -import {useResolveUriQuery} from '#/state/queries/resolve-uri' -import {useLikedByQuery} from '#/state/queries/post-liked-by' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' - +import {useLikedByQuery} from '#/state/queries/post-liked-by' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {atoms as a, useTheme} from '#/alf' -import {Text} from '#/components/Typography' import * as Dialog from '#/components/Dialog' -import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' -import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' interface LikesDialogProps { control: Dialog.DialogOuterProps['control'] @@ -25,7 +24,6 @@ export function LikesDialog(props: LikesDialogProps) { return ( - ) diff --git a/src/components/Link.tsx b/src/components/Link.tsx index c80b9f3707..447833a239 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -103,17 +103,17 @@ export function useLink({ linkRequiresWarning(href, displayText), ) - if (requiresWarning) { + if (isWeb) { e.preventDefault() + } + if (requiresWarning) { openModal({ name: 'link-warning', text: displayText, href: href, }) } else { - e.preventDefault() - if (isExternal) { openLink(href) } else { diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index a0a21a50f9..a22f43cf8d 100644 --- a/src/components/Menu/index.tsx +++ b/src/components/Menu/index.tsx @@ -4,7 +4,7 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import flattenReactChildren from 'react-keyed-flatten-children' -import {isNative} from 'platform/detection' +import {isNative} from '#/platform/detection' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' @@ -82,19 +82,21 @@ export function Outer({ style?: StyleProp }>) { const context = React.useContext(Context) + const {_} = useLingui() return ( - + - {/* Re-wrap with context since Dialogs are portal-ed to root */} - + {children} {isNative && showCancel && } + - @@ -116,15 +118,14 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) { {...rest} accessibilityHint="" accessibilityLabel={label} - onPress={e => { - onPress(e) - + onFocus={onFocus} + onBlur={onBlur} + onPress={async e => { + await onPress(e) if (!e.defaultPrevented) { control?.close() } }} - onFocus={onFocus} - onBlur={onBlur} onPressIn={e => { onPressIn() rest.onPressIn?.(e) diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx index 1a523a839d..0e35206586 100644 --- a/src/components/NewskieDialog.tsx +++ b/src/components/NewskieDialog.tsx @@ -5,12 +5,12 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {differenceInSeconds} from 'date-fns' +import {HITSLOP_10} from '#/lib/constants' import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' +import {sanitizeDisplayName} from '#/lib/strings/display-names' import {isNative} from '#/platform/detection' import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {HITSLOP_10} from 'lib/constants' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {useSession} from 'state/session' +import {useSession} from '#/state/session' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' diff --git a/src/components/Portal.tsx b/src/components/Portal.tsx index 03b397b2b8..7441df005c 100644 --- a/src/components/Portal.tsx +++ b/src/components/Portal.tsx @@ -12,6 +12,8 @@ type ComponentMap = { [id: string]: Component } +export type PortalComponent = ({children}: {children?: React.ReactNode}) => null + export function createPortalGroup() { const Context = React.createContext({ outlet: null, diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 8765cdee31..fc6919af89 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -4,8 +4,9 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button' +import {Button, ButtonColor, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' +import {PortalComponent} from '#/components/Portal' import {Text} from '#/components/Typography' export { @@ -25,9 +26,11 @@ export function Outer({ children, control, testID, + Portal, }: React.PropsWithChildren<{ control: Dialog.DialogControlProps testID?: string + Portal?: PortalComponent }>) { const {gtMobile} = useBreakpoints() const titleId = React.useId() @@ -39,10 +42,9 @@ export function Outer({ ) return ( - + + - - void color?: ButtonColor /** * Optional i18n string. If undefined, it will default to "Confirm". @@ -181,6 +183,7 @@ export function Basic({ onConfirm, confirmButtonColor, showCancel = true, + Portal, }: React.PropsWithChildren<{ control: Dialog.DialogOuterProps['control'] title: string @@ -194,12 +197,13 @@ export function Basic({ * Note: The dialog will close automatically when the action is pressed, you * should NOT close the dialog as a side effect of this method. */ - onConfirm: ButtonProps['onPress'] + onConfirm: (e: GestureResponderEvent) => void confirmButtonColor?: ButtonColor showCancel?: boolean + Portal?: PortalComponent }>) { return ( - + {title} {description} diff --git a/src/components/ReportDialog/SelectLabelerView.tsx b/src/components/ReportDialog/SelectLabelerView.tsx index f7a8139ea5..039bbf123f 100644 --- a/src/components/ReportDialog/SelectLabelerView.tsx +++ b/src/components/ReportDialog/SelectLabelerView.tsx @@ -4,7 +4,6 @@ import {AppBskyLabelerDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -export {useDialogControl as useReportDialogControl} from '#/components/Dialog' import {getLabelingServiceTitle} from '#/lib/moderation' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, useButtonContext} from '#/components/Button' diff --git a/src/components/ReportDialog/SubmitView.tsx b/src/components/ReportDialog/SubmitView.tsx index e323d15042..ef4a9b7fbc 100644 --- a/src/components/ReportDialog/SubmitView.tsx +++ b/src/components/ReportDialog/SubmitView.tsx @@ -6,6 +6,7 @@ import {useLingui} from '@lingui/react' import {getLabelingServiceTitle} from '#/lib/moderation' import {ReportOption} from '#/lib/moderation/useReportOptions' +import {isAndroid} from '#/platform/detection' import {useAgent} from '#/state/session' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import * as Toast from '#/view/com/util/Toast' @@ -225,6 +226,8 @@ export function SubmitView({ {submitting && } + {/* Maybe fix this later -h */} + {isAndroid ? : null} ) } diff --git a/src/components/ReportDialog/index.tsx b/src/components/ReportDialog/index.tsx index c87d32f9ec..5bf8aa5b4b 100644 --- a/src/components/ReportDialog/index.tsx +++ b/src/components/ReportDialog/index.tsx @@ -1,5 +1,6 @@ import React from 'react' import {Pressable, View} from 'react-native' +import {ScrollView} from 'react-native-gesture-handler' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -8,12 +9,10 @@ import {useMyLabelersQuery} from '#/state/queries/preferences' export {useDialogControl as useReportDialogControl} from '#/components/Dialog' import {AppBskyLabelerDefs} from '@atproto/api' -import {BottomSheetScrollViewMethods} from '@discord/bottom-sheet/src' import {atoms as a} from '#/alf' import * as Dialog from '#/components/Dialog' import {useDelayedLoading} from '#/components/hooks/useDelayedLoading' -import {useOnKeyboardDidShow} from '#/components/hooks/useOnKeyboard' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import {SelectLabelerView} from './SelectLabelerView' @@ -25,7 +24,6 @@ export function ReportDialog(props: ReportDialogProps) { return ( - ) @@ -40,10 +38,7 @@ function ReportDialogInner(props: ReportDialogProps) { } = useMyLabelersQuery() const isLoading = useDelayedLoading(500, isLabelerLoading) - const ref = React.useRef(null) - useOnKeyboardDidShow(() => { - ref.current?.scrollToEnd({animated: true}) - }) + const ref = React.useRef(null) return ( diff --git a/src/components/StarterPack/QrCodeDialog.tsx b/src/components/StarterPack/QrCodeDialog.tsx index b2af8ff73a..2feea0973a 100644 --- a/src/components/StarterPack/QrCodeDialog.tsx +++ b/src/components/StarterPack/QrCodeDialog.tsx @@ -149,7 +149,6 @@ export function QrCodeDialog({ return ( - diff --git a/src/components/StarterPack/ShareDialog.tsx b/src/components/StarterPack/ShareDialog.tsx index 9851b08567..997c6479c5 100644 --- a/src/components/StarterPack/ShareDialog.tsx +++ b/src/components/StarterPack/ShareDialog.tsx @@ -6,14 +6,14 @@ import {AppBskyGraphDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {saveImageToMediaLibrary} from '#/lib/media/manip' +import {shareUrl} from '#/lib/sharing' +import {logEvent} from '#/lib/statsig/statsig' +import {getStarterPackOgCard} from '#/lib/strings/starter-pack' import {logger} from '#/logger' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {saveImageToMediaLibrary} from 'lib/media/manip' -import {shareUrl} from 'lib/sharing' -import {logEvent} from 'lib/statsig/statsig' -import {getStarterPackOgCard} from 'lib/strings/starter-pack' -import {isNative, isWeb} from 'platform/detection' -import * as Toast from 'view/com/util/Toast' +import {isNative, isWeb} from '#/platform/detection' +import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {DialogControlProps} from '#/components/Dialog' @@ -32,6 +32,7 @@ interface Props { export function ShareDialog(props: Props) { return ( + ) @@ -84,7 +85,6 @@ function ShareDialogInner({ return ( <> - {!imageLoaded || !link ? ( diff --git a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx index f7b0aba344..1e9f1c52d4 100644 --- a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx +++ b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx @@ -3,13 +3,13 @@ import type {ListRenderItemInfo} from 'react-native' import {View} from 'react-native' import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' -import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' -import {isWeb} from 'platform/detection' -import {useSession} from 'state/session' +import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import {isWeb} from '#/platform/detection' +import {useSession} from '#/state/session' +import {ListMethods} from '#/view/com/util/List' import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State' import {atoms as a, native, useTheme, web} from '#/alf' import {Button, ButtonText} from '#/components/Button' @@ -45,7 +45,7 @@ export function WizardEditListDialog({ const {currentAccount} = useSession() const initialNumToRender = useInitialNumToRender() - const listRef = useRef(null) + const listRef = useRef(null) const getData = () => { if (state.currentStep === 'Feeds') return state.feeds @@ -76,10 +76,7 @@ export function WizardEditListDialog({ ) return ( - + @@ -143,8 +135,6 @@ export function WizardEditListDialog({ paddingHorizontal: 0, marginTop: 0, paddingTop: 0, - borderTopLeftRadius: 40, - borderTopRightRadius: 40, }), ]} webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx index 2c6a0b674c..917624a036 100644 --- a/src/components/TagMenu/index.tsx +++ b/src/components/TagMenu/index.tsx @@ -85,7 +85,6 @@ export function TagMenu({ - {isPreferencesLoading ? ( diff --git a/src/components/dialogs/BirthDateSettings.tsx b/src/components/dialogs/BirthDateSettings.tsx index 08608f9d88..81d0c6740e 100644 --- a/src/components/dialogs/BirthDateSettings.tsx +++ b/src/components/dialogs/BirthDateSettings.tsx @@ -31,7 +31,6 @@ export function BirthDateSettingsDialog({ return ( - diff --git a/src/components/dialogs/EmbedConsent.tsx b/src/components/dialogs/EmbedConsent.tsx index 765b8adc7e..824155d8bb 100644 --- a/src/components/dialogs/EmbedConsent.tsx +++ b/src/components/dialogs/EmbedConsent.tsx @@ -50,7 +50,6 @@ export function EmbedConsentDialog({ return ( - diff --git a/src/components/dialogs/GifSelect.ios.tsx b/src/components/dialogs/GifSelect.ios.tsx deleted file mode 100644 index 2f867e8657..0000000000 --- a/src/components/dialogs/GifSelect.ios.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import React, { - useCallback, - useImperativeHandle, - useMemo, - useRef, - useState, -} from 'react' -import {Modal, ScrollView, TextInput, View} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {cleanError} from '#/lib/strings/errors' -import { - Gif, - useFeaturedGifsQuery, - useGifSearchQuery, -} from '#/state/queries/tenor' -import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' -import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' -import {FlatList_INTERNAL} from '#/view/com/util/Views' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import * as TextField from '#/components/forms/TextField' -import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' -import {Button, ButtonText} from '../Button' -import {Handle} from '../Dialog' -import {useThrottledValue} from '../hooks/useThrottledValue' -import {ListFooter, ListMaybePlaceholder} from '../Lists' -import {GifPreview} from './GifSelect.shared' - -export function GifSelectDialog({ - controlRef, - onClose, - onSelectGif: onSelectGifProp, -}: { - controlRef: React.RefObject<{open: () => void}> - onClose: () => void - onSelectGif: (gif: Gif) => void -}) { - const t = useTheme() - const [open, setOpen] = useState(false) - - useImperativeHandle(controlRef, () => ({ - open: () => setOpen(true), - })) - - const close = useCallback(() => { - setOpen(false) - onClose() - }, [onClose]) - - const onSelectGif = useCallback( - (gif: Gif) => { - onSelectGifProp(gif) - close() - }, - [onSelectGifProp, close], - ) - - const renderErrorBoundary = useCallback( - (error: any) => , - [close], - ) - - return ( - - - - - - - - - ) -} - -function GifList({ - onSelectGif, -}: { - close: () => void - onSelectGif: (gif: Gif) => void -}) { - const {_} = useLingui() - const t = useTheme() - const {gtMobile} = useBreakpoints() - const textInputRef = useRef(null) - const listRef = useRef(null) - const [undeferredSearch, setSearch] = useState('') - const search = useThrottledValue(undeferredSearch, 500) - - const isSearching = search.length > 0 - - const trendingQuery = useFeaturedGifsQuery() - const searchQuery = useGifSearchQuery(search) - - const { - data, - fetchNextPage, - isFetchingNextPage, - hasNextPage, - error, - isLoading, - isError, - refetch, - } = isSearching ? searchQuery : trendingQuery - - const flattenedData = useMemo(() => { - return data?.pages.flatMap(page => page.results) || [] - }, [data]) - - const renderItem = useCallback( - ({item}: {item: Gif}) => { - return - }, - [onSelectGif], - ) - - const onEndReached = React.useCallback(() => { - if (isFetchingNextPage || !hasNextPage || error) return - fetchNextPage() - }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) - - const hasData = flattenedData.length > 0 - - const onGoBack = useCallback(() => { - if (isSearching) { - // clear the input and reset the state - textInputRef.current?.clear() - setSearch('') - } else { - close() - } - }, [isSearching]) - - const listHeader = useMemo(() => { - return ( - - {/* cover top corners */} - - - - - { - setSearch(text) - listRef.current?.scrollToOffset({offset: 0, animated: false}) - }} - returnKeyType="search" - clearButtonMode="while-editing" - inputRef={textInputRef} - maxLength={50} - /> - - - ) - }, [t.atoms.bg, _]) - - return ( - - {listHeader} - {!hasData && ( - - )} - - } - stickyHeaderIndices={[0]} - onEndReached={onEndReached} - onEndReachedThreshold={4} - keyExtractor={(item: Gif) => item.id} - keyboardDismissMode="on-drag" - ListFooterComponent={ - hasData ? ( - - ) : null - } - /> - ) -} - -function ModalError({details, close}: {details?: string; close: () => void}) { - const {_} = useLingui() - - return ( - - - - - ) -} diff --git a/src/components/dialogs/GifSelect.shared.tsx b/src/components/dialogs/GifSelect.shared.tsx deleted file mode 100644 index 90b2abaa83..0000000000 --- a/src/components/dialogs/GifSelect.shared.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React, {useCallback} from 'react' -import {Image} from 'expo-image' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {logEvent} from '#/lib/statsig/statsig' -import {Gif} from '#/state/queries/tenor' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {Button} from '../Button' - -export function GifPreview({ - gif, - onSelectGif, -}: { - gif: Gif - onSelectGif: (gif: Gif) => void -}) { - const {gtTablet} = useBreakpoints() - const {_} = useLingui() - const t = useTheme() - - const onPress = useCallback(() => { - logEvent('composer:gif:select', {}) - onSelectGif(gif) - }, [onSelectGif, gif]) - - return ( - - ) -} diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx index 1afc588dad..6023b5808a 100644 --- a/src/components/dialogs/GifSelect.tsx +++ b/src/components/dialogs/GifSelect.tsx @@ -6,10 +6,12 @@ import React, { useState, } from 'react' import {TextInput, View} from 'react-native' -import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' +import {useWindowDimensions} from 'react-native' +import {Image} from 'expo-image' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {logEvent} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {isWeb} from '#/platform/detection' import { @@ -19,7 +21,8 @@ import { } from '#/state/queries/tenor' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {ListMethods} from '#/view/com/util/List' +import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' import * as TextField from '#/components/forms/TextField' import {useThrottledValue} from '#/components/hooks/useThrottledValue' @@ -27,16 +30,18 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arr import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' import {Button, ButtonIcon, ButtonText} from '../Button' import {ListFooter, ListMaybePlaceholder} from '../Lists' -import {GifPreview} from './GifSelect.shared' +import {PortalComponent} from '../Portal' export function GifSelectDialog({ controlRef, onClose, onSelectGif: onSelectGifProp, + Portal, }: { controlRef: React.RefObject<{open: () => void}> onClose: () => void onSelectGif: (gif: Gif) => void + Portal?: PortalComponent }) { const control = Dialog.useDialogControl() @@ -59,8 +64,13 @@ export function GifSelectDialog({ return ( + onClose={onClose} + Portal={Portal} + nativeOptions={{ + bottomInset: 0, + // use system corner radius on iOS + ...ios({cornerRadius: undefined}), + }}> @@ -80,9 +90,10 @@ function GifList({ const t = useTheme() const {gtMobile} = useBreakpoints() const textInputRef = useRef(null) - const listRef = useRef(null) + const listRef = useRef(null) const [undeferredSearch, setSearch] = useState('') const search = useThrottledValue(undeferredSearch, 500) + const {height} = useWindowDimensions() const isSearching = search.length > 0 @@ -95,7 +106,7 @@ function GifList({ isFetchingNextPage, hasNextPage, error, - isLoading, + isPending, isError, refetch, } = isSearching ? searchQuery : trendingQuery @@ -132,6 +143,7 @@ function GifList({ return ( {listHeader} {!hasData && ( ) } + +export function GifPreview({ + gif, + onSelectGif, +}: { + gif: Gif + onSelectGif: (gif: Gif) => void +}) { + const {gtTablet} = useBreakpoints() + const {_} = useLingui() + const t = useTheme() + + const onPress = useCallback(() => { + logEvent('composer:gif:select', {}) + onSelectGif(gif) + }, [onSelectGif, gif]) + + return ( + + ) +} diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx index 81a6141038..c3aae8f0df 100644 --- a/src/components/dialogs/MutedWords.tsx +++ b/src/components/dialogs/MutedWords.tsx @@ -30,11 +30,14 @@ import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/P import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {Loader} from '#/components/Loader' +import {createPortalGroup} from '#/components/Portal' import * as Prompt from '#/components/Prompt' import {Text} from '#/components/Typography' const ONE_DAY = 24 * 60 * 60 * 1000 +const Portal = createPortalGroup() + export function MutedWordsDialog() { const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext() return ( @@ -105,307 +108,349 @@ function MutedWordsInner() { }, [_, field, targets, addMutedWord, setField, durations, excludeFollowing]) return ( - - - - Add muted words and tags - - - - Posts can be muted based on their text, their tags, or both. We - recommend avoiding common words that appear in many posts, since it - can result in no posts being shown. - - - - - { - if (error) { - setError('') - } - setField(value) - }} - onSubmitEditing={submit} - /> - + + + + + Add muted words and tags + + + + Posts can be muted based on their text, their tags, or both. We + recommend avoiding common words that appear in many posts, since + it can result in no posts being shown. + + - - - - Duration: - + + { + if (error) { + setError('') + } + setField(value) + }} + onSubmitEditing={submit} + /> + + + + + + Duration: + - - - - - - - Forever - - - - + + + + + + + Forever + + + + + + + + + + + 24 hours + + + + + - - - - - - 24 hours - - - - + + + + + + + 7 days + + + + + + + + + + + 30 days + + + + + + - + + Mute in: + + + - 7 days + Text & tags + - 30 days + Tags only + - - + - - - Mute in: - - - + + + Options: + + label={_(msg`Do not apply this mute word to users you follow`)} + name="exclude_following" + style={[a.flex_row, a.justify_between]} + value={excludeFollowing} + onChange={setExcludeFollowing}> - + - Text & tags + Exclude users you follow - + - - - - - - Tags only - - - - - + + - - + {error && ( + + + {error} + + + )} + + + + + - Options: + Your muted words - - - - - - Exclude users you follow - - - - - - - - - - {error && ( - - + ) : preferencesError || !preferences ? ( + - {error} - - - )} - - - - - - - Your muted words - + + + We're sorry, but we weren't able to load your muted words at + this time. Please try again. + + + + ) : preferences.moderationPrefs.mutedWords.length ? ( + [...preferences.moderationPrefs.mutedWords] + .reverse() + .map((word, i) => ( + + )) + ) : ( + + + You haven't muted any words or tags yet + + + )} + - {isPreferencesLoading ? ( - - ) : preferencesError || !preferences ? ( - - - - We're sorry, but we weren't able to load your muted words at - this time. Please try again. - - - - ) : preferences.moderationPrefs.mutedWords.length ? ( - [...preferences.moderationPrefs.mutedWords] - .reverse() - .map((word, i) => ( - - )) - ) : ( - - - You haven't muted any words or tags yet - - - )} + {isNative && } - {isNative && } - + + - - + + ) } @@ -437,6 +482,7 @@ function MutedWordRow({ onConfirm={remove} confirmButtonCta={_(msg`Remove`)} confirmButtonColor="negative" + Portal={Portal.Portal} /> + - diff --git a/src/components/dialogs/nuxs/NeueTypography.tsx b/src/components/dialogs/nuxs/NeueTypography.tsx index f160c87743..f29dc356dd 100644 --- a/src/components/dialogs/nuxs/NeueTypography.tsx +++ b/src/components/dialogs/nuxs/NeueTypography.tsx @@ -44,7 +44,6 @@ export function NeueTypography() { return ( - diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx index a4fa625fae..affc292c18 100644 --- a/src/components/dms/ConvoMenu.tsx +++ b/src/components/dms/ConvoMenu.tsx @@ -136,7 +136,7 @@ let ConvoMenu = ({ + onPress={() => leaveConvoControl.open()}> Leave conversation @@ -195,7 +195,7 @@ let ConvoMenu = ({ + onPress={() => reportControl.open()}> Report conversation @@ -206,7 +206,7 @@ let ConvoMenu = ({ + onPress={() => leaveConvoControl.open()}> Leave conversation diff --git a/src/components/dms/MessageMenu.tsx b/src/components/dms/MessageMenu.tsx index 2978d2b220..8680a68bfb 100644 --- a/src/components/dms/MessageMenu.tsx +++ b/src/components/dms/MessageMenu.tsx @@ -7,11 +7,11 @@ import {useLingui} from '@lingui/react' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {getTranslatorLink} from '#/locale/helpers' +import {isWeb} from '#/platform/detection' +import {useConvoActive} from '#/state/messages/convo' import {useLanguagePrefs} from '#/state/preferences' import {useOpenLink} from '#/state/preferences/in-app-browser' -import {isWeb} from 'platform/detection' -import {useConvoActive} from 'state/messages/convo' -import {useSession} from 'state/session' +import {useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' import {ReportDialog} from '#/components/dms/ReportDialog' @@ -120,7 +120,7 @@ export let MessageMenu = ({ + onPress={() => deleteControl.open()}> {_(msg`Delete for me`)} @@ -128,7 +128,7 @@ export let MessageMenu = ({ + onPress={() => reportControl.open()}> {_(msg`Report`)} diff --git a/src/components/dms/ReportDialog.tsx b/src/components/dms/ReportDialog.tsx index 2dcd778545..06d69ff4be 100644 --- a/src/components/dms/ReportDialog.tsx +++ b/src/components/dms/ReportDialog.tsx @@ -10,13 +10,11 @@ import {useLingui} from '@lingui/react' import {useMutation} from '@tanstack/react-query' import {ReportOption} from '#/lib/moderation/useReportOptions' -import {isAndroid} from '#/platform/detection' import {useAgent} from '#/state/session' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' -import {KeyboardControllerPadding} from '#/components/KeyboardControllerPadding' import {Button, ButtonIcon, ButtonText} from '../Button' import {Divider} from '../Divider' import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '../icons/Chevron' @@ -41,14 +39,11 @@ let ReportDialog = ({ }): React.ReactNode => { const {_} = useLingui() return ( - + - ) diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index 19f6eb6dfc..e80fef2d7e 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -2,9 +2,9 @@ import React, {useCallback} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' -import {logEvent} from 'lib/statsig/statsig' import {FAB} from '#/view/com/util/fab/FAB' import * as Toast from '#/view/com/util/Toast' import {useTheme} from '#/alf' @@ -55,10 +55,8 @@ export function NewChat({ accessibilityHint="" /> - + + (null) + const listRef = useRef(null) const {currentAccount} = useSession() - const inputRef = useRef(null) + const inputRef = useRef(null) const [searchText, setSearchText] = useState('') @@ -101,15 +98,15 @@ export function SearchablePeopleList({ }) } - _items = _items.sort(a => { + _items = _items.sort(item => { // @ts-ignore - return a.enabled ? -1 : 1 + return item.enabled ? -1 : 1 }) } } else { const placeholders: Item[] = Array(10) .fill(0) - .map((_, i) => ({ + .map((__, i) => ({ type: 'placeholder', key: i + '', })) @@ -155,9 +152,9 @@ export function SearchablePeopleList({ } // only sort follows - followsItems = followsItems.sort(a => { + followsItems = followsItems.sort(item => { // @ts-ignore - return a.enabled ? -1 : 1 + return item.enabled ? -1 : 1 }) // then append @@ -177,9 +174,9 @@ export function SearchablePeopleList({ } } - _items = _items.sort(a => { + _items = _items.sort(item => { // @ts-ignore - return a.enabled ? -1 : 1 + return item.enabled ? -1 : 1 }) } else { _items.push(...placeholders) @@ -242,57 +239,46 @@ export function SearchablePeopleList({ - - + {title} + {isWeb ? ( + + ) : null} - + item.key} style={[ web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), - native({ - height: '100%', - paddingHorizontal: 0, - marginTop: 0, - paddingTop: 0, - borderTopLeftRadius: 40, - borderTopRightRadius: 40, - }), + native({height: '100%'}), ]} webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} keyboardDismissMode="on-drag" @@ -396,7 +374,8 @@ function ProfileCard({ + numberOfLines={1} + emoji> {displayName} void onEscape: () => void - inputRef: React.RefObject + inputRef: React.RefObject }) { const t = useTheme() const {_} = useLingui() diff --git a/src/components/dms/dialogs/ShareViaChatDialog.tsx b/src/components/dms/dialogs/ShareViaChatDialog.tsx index 01906a430c..38b5583432 100644 --- a/src/components/dms/dialogs/ShareViaChatDialog.tsx +++ b/src/components/dms/dialogs/ShareViaChatDialog.tsx @@ -2,9 +2,9 @@ import React, {useCallback} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' -import {logEvent} from 'lib/statsig/statsig' import * as Toast from '#/view/com/util/Toast' import * as Dialog from '#/components/Dialog' import {SearchablePeopleList} from './SearchablePeopleList' @@ -17,10 +17,8 @@ export function SendViaChatDialog({ onSelectChat: (chatId: string) => void }) { return ( - + + ) diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx index 6dc387b239..4e3695bbf2 100644 --- a/src/components/forms/Toggle.tsx +++ b/src/components/forms/Toggle.tsx @@ -2,8 +2,8 @@ import React from 'react' import {Pressable, View, ViewStyle} from 'react-native' import Animated, {LinearTransition} from 'react-native-reanimated' +import {HITSLOP_10} from '#/lib/constants' import {isNative} from '#/platform/detection' -import {HITSLOP_10} from 'lib/constants' import { atoms as a, flatten, diff --git a/src/components/moderation/LabelsOnMeDialog.tsx b/src/components/moderation/LabelsOnMeDialog.tsx index e63cea93b2..bf0d1905e0 100644 --- a/src/components/moderation/LabelsOnMeDialog.tsx +++ b/src/components/moderation/LabelsOnMeDialog.tsx @@ -32,7 +32,6 @@ export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) { return ( - ) @@ -158,23 +157,25 @@ function Label({ - - {isSelfLabel ? ( + {isSelfLabel ? ( + This label was applied by you. - ) : ( - - Source:{' '} - control.close()}> - {sourceName} - - - )} - + + ) : ( + + + Source: {' '} + + control.close()}> + {sourceName} + + + )} ) @@ -236,24 +237,24 @@ function AppealForm({ return ( <> - - Appeal "{strings.name}" label - - - - This appeal will be sent to{' '} - control.close()} - style={[a.text_md, a.leading_snug]}> - {sourceName} - - . - - + + + Appeal "{strings.name}" label + + + This appeal will be sent to{' '} + + control.close()} + style={[a.text_md, a.leading_snug]}> + {sourceName} + + . + - - {modcause.source.type === 'user' ? ( + {modcause.source.type === 'user' ? ( + This label was applied by the author. - ) : ( - - This label was applied by{' '} - control.close()} - style={a.text_md}> - {desc.source || _(msg`an unknown labeler`)} - - . - - )} - + + ) : ( + <> + + This label was applied by + + control.close()} + style={a.text_md}> + {desc.source || _(msg`an unknown labeler`)} + + + )} )} diff --git a/src/lib/media/video/upload.ts b/src/lib/media/video/upload.ts index 3330370b3e..720283a8da 100644 --- a/src/lib/media/video/upload.ts +++ b/src/lib/media/video/upload.ts @@ -7,8 +7,8 @@ import {nanoid} from 'nanoid/non-secure' import {AbortError} from '#/lib/async/cancelable' import {ServerError} from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' -import {createVideoEndpointUrl, mimeToExt} from './util' import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' +import {createVideoEndpointUrl, mimeToExt} from './util' export async function uploadVideo({ video, diff --git a/src/lib/media/video/upload.web.ts b/src/lib/media/video/upload.web.ts index ec65f96c97..d1b441a369 100644 --- a/src/lib/media/video/upload.web.ts +++ b/src/lib/media/video/upload.web.ts @@ -7,8 +7,8 @@ import {nanoid} from 'nanoid/non-secure' import {AbortError} from '#/lib/async/cancelable' import {ServerError} from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' -import {createVideoEndpointUrl, mimeToExt} from './util' import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' +import {createVideoEndpointUrl, mimeToExt} from './util' export async function uploadVideo({ video, diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index 663418f220..73472ec332 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -32,6 +32,7 @@ import { import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' +import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import {IconCircle} from '#/components/IconCircle' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo' @@ -89,15 +90,18 @@ export function StepProfile() { requestNotificationsPermission('StartOnboarding') }, [gate, requestNotificationsPermission]) + const sheetWrapper = useSheetWrapper() const openPicker = React.useCallback( async (opts?: ImagePickerOptions) => { - const response = await launchImageLibraryAsync({ - exif: false, - mediaTypes: MediaTypeOptions.Images, - quality: 1, - ...opts, - legacy: true, - }) + const response = await sheetWrapper( + launchImageLibraryAsync({ + exif: false, + mediaTypes: MediaTypeOptions.Images, + quality: 1, + ...opts, + legacy: true, + }), + ) return (response.assets ?? []) .slice(0, 1) @@ -121,7 +125,7 @@ export function StepProfile() { size: getDataUriSize(image.uri), })) }, - [_, setError], + [_, setError, sheetWrapper], ) const onContinue = React.useCallback(async () => { @@ -168,9 +172,11 @@ export function StepProfile() { setError('') - const items = await openPicker({ - aspect: [1, 1], - }) + const items = await sheetWrapper( + openPicker({ + aspect: [1, 1], + }), + ) let image = items[0] if (!image) return @@ -196,7 +202,13 @@ export function StepProfile() { image, useCreatedAvatar: false, })) - }, [requestPhotoAccessIfNeeded, setAvatar, openPicker, setError]) + }, [ + requestPhotoAccessIfNeeded, + setAvatar, + openPicker, + setError, + sheetWrapper, + ]) const onSecondaryPress = React.useCallback(() => { if (avatar.useCreatedAvatar) { @@ -286,7 +298,6 @@ export function StepProfile() { - + onPress={() => reportDialogControl.open()}> Report starter pack diff --git a/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx index 26bb6792fd..80893190fc 100644 --- a/src/state/dialogs/index.tsx +++ b/src/state/dialogs/index.tsx @@ -1,8 +1,9 @@ import React from 'react' -import {SharedValue, useSharedValue} from 'react-native-reanimated' +import {isWeb} from '#/platform/detection' import {DialogControlRefProps} from '#/components/Dialog' import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context' +import {BottomSheet} from '../../../modules/bottom-sheet' interface IDialogContext { /** @@ -16,25 +17,24 @@ interface IDialogContext { * `useId`. */ openDialogs: React.MutableRefObject> +} + +interface IDialogControlContext { + closeAllDialogs(): boolean + setDialogIsOpen(id: string, isOpen: boolean): void /** - * The counterpart to `accessibilityViewIsModal` for Android. This property - * applies to the parent of all non-modal views, and prevents TalkBack from - * navigating within content beneath an open dialog. - * - * @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android + * The number of dialogs that are fully expanded. This is used to determine the backgground color of the status bar + * on iOS. */ - importantForAccessibility: SharedValue<'auto' | 'no-hide-descendants'> + fullyExpandedCount: number + setFullyExpandedCount: React.Dispatch> } const DialogContext = React.createContext({} as IDialogContext) -const DialogControlContext = React.createContext<{ - closeAllDialogs(): boolean - setDialogIsOpen(id: string, isOpen: boolean): void -}>({ - closeAllDialogs: () => false, - setDialogIsOpen: () => {}, -}) +const DialogControlContext = React.createContext( + {} as IDialogControlContext, +) export function useDialogStateContext() { return React.useContext(DialogContext) @@ -45,48 +45,55 @@ export function useDialogStateControlContext() { } export function Provider({children}: React.PropsWithChildren<{}>) { + const [fullyExpandedCount, setFullyExpandedCount] = React.useState(0) + const activeDialogs = React.useRef< Map> >(new Map()) const openDialogs = React.useRef>(new Set()) - const importantForAccessibility = useSharedValue< - 'auto' | 'no-hide-descendants' - >('auto') const closeAllDialogs = React.useCallback(() => { - openDialogs.current.forEach(id => { - const dialog = activeDialogs.current.get(id) - if (dialog) dialog.current.close() - }) - return openDialogs.current.size > 0 + if (isWeb) { + openDialogs.current.forEach(id => { + const dialog = activeDialogs.current.get(id) + if (dialog) dialog.current.close() + }) + + return openDialogs.current.size > 0 + } else { + BottomSheet.dismissAll() + return false + } }, []) - const setDialogIsOpen = React.useCallback( - (id: string, isOpen: boolean) => { - if (isOpen) { - openDialogs.current.add(id) - importantForAccessibility.value = 'no-hide-descendants' - } else { - openDialogs.current.delete(id) - if (openDialogs.current.size < 1) { - importantForAccessibility.value = 'auto' - } - } - }, - [importantForAccessibility], - ) + const setDialogIsOpen = React.useCallback((id: string, isOpen: boolean) => { + if (isOpen) { + openDialogs.current.add(id) + } else { + openDialogs.current.delete(id) + } + }, []) const context = React.useMemo( () => ({ activeDialogs, openDialogs, - importantForAccessibility, }), - [importantForAccessibility, activeDialogs, openDialogs], + [activeDialogs, openDialogs], ) const controls = React.useMemo( - () => ({closeAllDialogs, setDialogIsOpen}), - [closeAllDialogs, setDialogIsOpen], + () => ({ + closeAllDialogs, + setDialogIsOpen, + fullyExpandedCount, + setFullyExpandedCount, + }), + [ + closeAllDialogs, + setDialogIsOpen, + fullyExpandedCount, + setFullyExpandedCount, + ], ) return ( diff --git a/src/state/preferences/in-app-browser.tsx b/src/state/preferences/in-app-browser.tsx index 76c854105e..1494fa4e8a 100644 --- a/src/state/preferences/in-app-browser.tsx +++ b/src/state/preferences/in-app-browser.tsx @@ -2,14 +2,14 @@ import React from 'react' import {Linking} from 'react-native' import * as WebBrowser from 'expo-web-browser' -import {isNative} from '#/platform/detection' -import * as persisted from '#/state/persisted' -import {usePalette} from 'lib/hooks/usePalette' +import {usePalette} from '#/lib/hooks/usePalette' import { createBskyAppAbsoluteUrl, isBskyRSSUrl, isRelativeUrl, -} from 'lib/strings/url-helpers' +} from '#/lib/strings/url-helpers' +import {isNative} from '#/platform/detection' +import * as persisted from '#/state/persisted' import {useModalControls} from '../modals' type StateContext = persisted.Schema['useInAppBrowser'] @@ -62,7 +62,7 @@ export function useOpenLink() { const pal = usePalette('default') const openLink = React.useCallback( - (url: string, override?: boolean) => { + async (url: string, override?: boolean) => { if (isBskyRSSUrl(url) && isRelativeUrl(url)) { url = createBskyAppAbsoluteUrl(url) } @@ -75,7 +75,7 @@ export function useOpenLink() { }) return } else if (override ?? enabled) { - WebBrowser.openBrowserAsync(url, { + await WebBrowser.openBrowserAsync(url, { presentationStyle: WebBrowser.WebBrowserPresentationStyle.FULL_SCREEN, toolbarColor: pal.colors.backgroundLight, diff --git a/src/view/com/auth/server-input/index.tsx b/src/view/com/auth/server-input/index.tsx index fb69e1d9c7..74b0d23155 100644 --- a/src/view/com/auth/server-input/index.tsx +++ b/src/view/com/auth/server-input/index.tsx @@ -66,12 +66,8 @@ export function ServerInputDialog({ ]) return ( - + - diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 2279649070..e03c64a422 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -114,11 +114,14 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {createPortalGroup} from '#/components/Portal' import * as Prompt from '#/components/Prompt' import {Text as NewText} from '#/components/Typography' import {composerReducer, createComposerState} from './state/composer' import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video' +const Portal = createPortalGroup() + const MAX_IMAGES = 4 type CancelRef = { @@ -613,296 +616,313 @@ export const ComposePost = ({ const keyboardVerticalOffset = useKeyboardVerticalOffset() return ( - - - - - - - {isProcessing ? ( - <> - {processingState} - - - - - ) : ( - - - {canPost ? ( - - ) : ( - - - Post - + + + + + + + + {isProcessing ? ( + <> + {processingState} + + - )} - - )} - - - {isAltTextRequiredAndMissing && ( - - - - - - One or more images is missing alt text. - + + ) : ( + + + {canPost ? ( + + ) : ( + + + Post + + + )} + + )} - )} - setError('')} - clearVideo={clearVideo} - /> - - - {replyTo ? : undefined} - - - onPressPublish()} - onNewLink={onNewLink} - onError={setError} - accessible={true} - accessibilityLabel={_(msg`Write post`)} - accessibilityHint={_( - msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`, - )} + {isAltTextRequiredAndMissing && ( + + + + + + One or more images is missing alt text. + + + )} + setError('')} + clearVideo={clearVideo} /> - - - - {images.length === 0 && extLink && ( - - { - if (extGif) { - dispatch({type: 'embed_remove_gif'}) - } else { - dispatch({type: 'embed_remove_link'}) - } - setExtLink(undefined) - setExtGif(undefined) - }} + + + {replyTo ? : undefined} + + + - onPressPublish()} + onNewLink={onNewLink} + onError={setError} + accessible={true} + accessibilityLabel={_(msg`Write post`)} + accessibilityHint={_( + msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`, + )} /> - )} - - {hasVideo && ( - - {videoState.asset && - (videoState.status === 'compressing' ? ( - - ) : videoState.video ? ( - - ) : null)} - - dispatch({ - type: 'embed_update_video', - videoAction: { - type: 'update_alt_text', - altText, - signal: videoState.abortController.signal, - }, - }) - } - captions={videoState.captions} - setCaptions={updater => { - dispatch({ - type: 'embed_update_video', - videoAction: { - type: 'update_captions', - updater, - signal: videoState.abortController.signal, - }, - }) + + + {images.length === 0 && extLink && ( + + { + if (extGif) { + dispatch({type: 'embed_remove_gif'}) + } else { + dispatch({type: 'embed_remove_link'}) + } + setExtLink(undefined) + setExtGif(undefined) }} /> - + + )} - - - {quote ? ( - - - - - {quote.uri !== initQuote?.uri && ( - { - dispatch({type: 'embed_remove_quote'}) - setQuote(undefined) + + {hasVideo && ( + + {videoState.asset && + (videoState.status === 'compressing' ? ( + + ) : videoState.video ? ( + + ) : null)} + + dispatch({ + type: 'embed_update_video', + videoAction: { + type: 'update_alt_text', + altText, + signal: videoState.abortController.signal, + }, + }) + } + captions={videoState.captions} + setCaptions={updater => { + dispatch({ + type: 'embed_update_video', + videoAction: { + type: 'update_captions', + updater, + signal: videoState.abortController.signal, + }, + }) }} + Portal={Portal.Portal} /> - )} - - ) : null} - - - - - {replyTo ? null : ( - - )} - - {videoState.status !== 'idle' && videoState.status !== 'done' ? ( - - ) : ( - - - 0} - setError={setError} - /> - - - {!isMobile ? ( - + + )} + + + {quote ? ( + + + + + {quote.uri !== initQuote?.uri && ( + { + dispatch({type: 'embed_remove_quote'}) + setQuote(undefined) + }} + /> + )} + ) : null} - + + + + + {replyTo ? null : ( + )} - - - + + {videoState.status !== 'idle' && videoState.status !== 'done' ? ( + + ) : ( + + + 0} + setError={setError} + /> + + + {!isMobile ? ( + + ) : null} + + )} + + + + - - - + + + + ) } diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx index a05607c76c..3479fb973c 100644 --- a/src/view/com/composer/GifAltText.tsx +++ b/src/view/com/composer/GifAltText.tsx @@ -20,6 +20,7 @@ import * as Dialog from '#/components/Dialog' import * as TextField from '#/components/forms/TextField' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {PortalComponent} from '#/components/Portal' import {Text} from '#/components/Typography' import {GifEmbed} from '../util/post-embeds/GifEmbed' import {AltTextReminder} from './photos/Gallery' @@ -28,10 +29,12 @@ export function GifAltText({ link: linkProp, gif, onSubmit, + Portal, }: { link: ExternalEmbedDraft gif?: Gif onSubmit: (alt: string) => void + Portal: PortalComponent }) { const control = Dialog.useDialogControl() const {_} = useLingui() @@ -96,9 +99,7 @@ export function GifAltText({ - + + {/* Maybe fix this later -h */} + {isAndroid ? : null} ) } diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx index 0afb83ed96..ebe528abc0 100644 --- a/src/view/com/composer/photos/EditImageDialog.web.tsx +++ b/src/view/com/composer/photos/EditImageDialog.web.tsx @@ -20,6 +20,7 @@ import {EditImageDialogProps} from './EditImageDialog' export const EditImageDialog = (props: EditImageDialogProps) => { return ( + ) diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 5ff7042bc1..3958a85c0d 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -21,6 +21,7 @@ import {ComposerImage, cropImage} from '#/state/gallery' import {Text} from '#/view/com/util/text/Text' import {useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' +import {PortalComponent} from '#/components/Portal' import {ComposerAction} from '../state/composer' import {EditImageDialog} from './EditImageDialog' import {ImageAltTextDialog} from './ImageAltTextDialog' @@ -30,6 +31,7 @@ const IMAGE_GAP = 8 interface GalleryProps { images: ComposerImage[] dispatch: (action: ComposerAction) => void + Portal: PortalComponent } export let Gallery = (props: GalleryProps): React.ReactNode => { @@ -57,7 +59,12 @@ interface GalleryInnerProps extends GalleryProps { containerInfo: Dimensions } -const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => { +const GalleryInner = ({ + images, + containerInfo, + dispatch, + Portal, +}: GalleryInnerProps) => { const {isMobile} = useWebMediaQueries() const {altTextControlStyle, imageControlsStyle, imageStyle} = @@ -111,6 +118,7 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => { onRemove={() => { dispatch({type: 'embed_remove_image', image}) }} + Portal={Portal} /> ) })} @@ -127,6 +135,7 @@ type GalleryItemProps = { imageStyle?: ViewStyle onChange: (next: ComposerImage) => void onRemove: () => void + Portal: PortalComponent } const GalleryItem = ({ @@ -136,6 +145,7 @@ const GalleryItem = ({ imageStyle, onChange, onRemove, + Portal, }: GalleryItemProps): React.ReactNode => { const {_} = useLingui() const t = useTheme() @@ -230,6 +240,7 @@ const GalleryItem = ({ control={altTextControl} image={image} onChange={onChange} + Portal={Portal} /> void + Portal: PortalComponent } export const ImageAltTextDialog = (props: Props): React.ReactNode => { return ( - + - ) @@ -116,6 +117,8 @@ const ImageAltTextInner = ({ + {/* Maybe fix this later -h */} + {isAndroid ? : null} ) } diff --git a/src/view/com/composer/photos/SelectGifBtn.tsx b/src/view/com/composer/photos/SelectGifBtn.tsx index d13df0a110..d482e07837 100644 --- a/src/view/com/composer/photos/SelectGifBtn.tsx +++ b/src/view/com/composer/photos/SelectGifBtn.tsx @@ -9,14 +9,16 @@ import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {GifSelectDialog} from '#/components/dialogs/GifSelect' import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif' +import {PortalComponent} from '#/components/Portal' type Props = { onClose: () => void onSelectGif: (gif: Gif) => void disabled?: boolean + Portal?: PortalComponent } -export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) { +export function SelectGifBtn({onClose, onSelectGif, disabled, Portal}: Props) { const {_} = useLingui() const ref = useRef<{open: () => void}>(null) const t = useTheme() @@ -46,6 +48,7 @@ export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) { controlRef={ref} onClose={onClose} onSelectGif={onSelectGif} + Portal={Portal} /> ) diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx index 34ead3d9a9..37bfbafe60 100644 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -9,6 +9,7 @@ import {isNative} from '#/platform/detection' import {ComposerImage, createComposerImage} from '#/state/gallery' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' +import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image' type Props = { @@ -21,23 +22,26 @@ export function SelectPhotoBtn({size, disabled, onAdd}: Props) { const {_} = useLingui() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const t = useTheme() + const sheetWrapper = useSheetWrapper() const onPressSelectPhotos = useCallback(async () => { if (isNative && !(await requestPhotoAccessIfNeeded())) { return } - const images = await openPicker({ - selectionLimit: 4 - size, - allowsMultipleSelection: true, - }) + const images = await sheetWrapper( + openPicker({ + selectionLimit: 4 - size, + allowsMultipleSelection: true, + }), + ) const results = await Promise.all( images.map(img => createComposerImage(img)), ) onAdd(results) - }, [requestPhotoAccessIfNeeded, size, onAdd]) + }, [requestPhotoAccessIfNeeded, size, onAdd, sheetWrapper]) return ( - + diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 2b4376b698..43555ccb47 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -20,6 +20,7 @@ import {isAndroid, isNative, isWeb} from '#/platform/detection' import {precacheProfile} from '#/state/queries/profile' import {HighPriorityImage} from '#/view/com/util/images/Image' import {tokens, useTheme} from '#/alf' +import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import { Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, Camera_Stroke2_Corner0_Rounded as Camera, @@ -271,6 +272,7 @@ let EditableUserAvatar = ({ const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() + const sheetWrapper = useSheetWrapper() const aviStyle = useMemo(() => { if (type === 'algo' || type === 'list') { @@ -306,9 +308,11 @@ let EditableUserAvatar = ({ return } - const items = await openPicker({ - aspect: [1, 1], - }) + const items = await sheetWrapper( + openPicker({ + aspect: [1, 1], + }), + ) const item = items[0] if (!item) { return @@ -332,7 +336,7 @@ let EditableUserAvatar = ({ logger.error('Failed to crop banner', {error: e}) } } - }, [onSelectNewAvatar, requestPhotoAccessIfNeeded]) + }, [onSelectNewAvatar, requestPhotoAccessIfNeeded, sheetWrapper]) const onRemoveAvatar = React.useCallback(() => { onSelectNewAvatar(null) diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 13f4081fce..622cb2129d 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -17,6 +17,7 @@ import {logger} from '#/logger' import {isAndroid, isNative} from '#/platform/detection' import {EventStopper} from '#/view/com/util/EventStopper' import {tokens, useTheme as useAlfTheme} from '#/alf' +import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import { Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, Camera_Stroke2_Corner0_Rounded as Camera, @@ -43,6 +44,7 @@ export function UserBanner({ const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() + const sheetWrapper = useSheetWrapper() const onOpenCamera = React.useCallback(async () => { if (!(await requestCameraAccessIfNeeded())) { @@ -60,7 +62,7 @@ export function UserBanner({ if (!(await requestPhotoAccessIfNeeded())) { return } - const items = await openPicker() + const items = await sheetWrapper(openPicker()) if (!items[0]) { return } @@ -80,7 +82,7 @@ export function UserBanner({ logger.error('Failed to crop banner', {error: e}) } } - }, [onSelectNewBanner, requestPhotoAccessIfNeeded]) + }, [onSelectNewBanner, requestPhotoAccessIfNeeded, sheetWrapper]) const onRemoveBanner = React.useCallback(() => { onSelectNewBanner?.(null) diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 33287564a7..cd1f2d3de6 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -240,8 +240,8 @@ let PostDropdownBtn = ({ Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') }, [_, richText]) - const onPressTranslate = React.useCallback(() => { - openLink(translatorUrl) + const onPressTranslate = React.useCallback(async () => { + await openLink(translatorUrl) }, [openLink, translatorUrl]) const onHidePost = React.useCallback(() => { @@ -439,7 +439,7 @@ let PostDropdownBtn = ({ + onPress={() => sendViaChatControl.open()}> Send via direct message @@ -467,7 +467,7 @@ let PostDropdownBtn = ({ + onPress={() => embedPostControl.open()}> {_(msg`Embed post`)} @@ -542,7 +542,7 @@ let PostDropdownBtn = ({ ? _(msg`Hide reply for me`) : _(msg`Hide post for me`) } - onPress={hidePromptControl.open}> + onPress={() => hidePromptControl.open()}> {isReply ? _(msg`Hide reply for me`) @@ -630,7 +630,9 @@ let PostDropdownBtn = ({ + postInteractionSettingsDialogControl.open() + } {...(isAuthor ? Platform.select({ web: { @@ -649,7 +651,7 @@ let PostDropdownBtn = ({ + onPress={() => deletePromptControl.open()}> {_(msg`Delete post`)} diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index 0ecdf25b93..9be72ae23e 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -86,7 +86,9 @@ let RepostButton = ({ ) : undefined} - + @@ -155,7 +157,6 @@ let RepostButton = ({ ) } - -const styles = StyleSheet.create({ - btnOuter: { - marginLeft: 'auto', - }, - btn: { - flexDirection: 'row', - borderRadius: 50, - paddingVertical: 8, - paddingHorizontal: 14, - }, -}) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index ead9df1161..4701f225c4 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -14,14 +14,12 @@ import {useLingui} from '@lingui/react' import {MAX_POST_LINES} from '#/lib/constants' import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {countLines} from '#/lib/strings/helpers' import {niceDate} from '#/lib/strings/time' import {s} from '#/lib/styles' -import {isWeb} from '#/platform/detection' import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' import {useLanguagePrefs} from '#/state/preferences' import {useOpenLink} from '#/state/preferences/in-app-browser' @@ -30,9 +28,10 @@ import {useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {AppModerationCause} from '#/components/Pills' import {RichText} from '#/components/RichText' +import {Text as NewText} from '#/components/Typography' import {ContentHider} from '../../../components/moderation/ContentHider' import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' import {PostAlerts} from '../../../components/moderation/PostAlerts' @@ -180,6 +179,7 @@ let PostThreadItemLoaded = ({ hideTopBorder?: boolean threadgateRecord?: AppBskyFeedThreadgate.Record }): React.ReactNode => { + const t = useTheme() const pal = usePalette('default') const {_, i18n} = useLingui() const langPrefs = useLanguagePrefs() @@ -268,8 +268,14 @@ let PostThreadItemLoaded = ({ return ( <> {rootUri !== post.uri && ( - - + + - - - - - - - - - {sanitizeDisplayName( - post.author.displayName || - sanitizeHandle(post.author.handle), - moderation.ui('displayName'), - )} - - - - - - - {sanitizeHandle(post.author.handle, '@')} - - - + a.px_lg, + t.atoms.border_contrast_low, + // root post styles + rootUri === post.uri && [a.pt_lg], + ]}> + + + + + + {sanitizeDisplayName( + post.author.displayName || + sanitizeHandle(post.author.handle), + moderation.ui('displayName'), + )} + + + + + {sanitizeHandle(post.author.handle, '@')} + + {currentAccount?.did !== post.author.did && ( - + + + )} - - + + + childContainerStyle={[a.pt_sm]}> {richText?.text ? ( - - - + ) : undefined} {post.embed && ( - + + {post.repostCount != null && post.repostCount !== 0 ? ( - - + - + style={[a.text_md, t.atoms.text_contrast_medium]}> + {formatCount(i18n, post.repostCount)} - {' '} + {' '} - + ) : null} {post.quoteCount != null && post.quoteCount !== 0 && !post.viewer?.embeddingDisabled ? ( - - + - + style={[a.text_md, t.atoms.text_contrast_medium]}> + {formatCount(i18n, post.quoteCount)} - {' '} + {' '} - + ) : null} {post.likeCount != null && post.likeCount !== 0 ? ( - - + - + style={[a.text_md, t.atoms.text_contrast_medium]}> + {formatCount(i18n, post.likeCount)} - {' '} + {' '} - + ) : null} ) : null} - + - + {!isThreadedChild && showParentReplyLine && ( {/* If we are in threaded mode, the avatar is rendered in PostMeta */} {!isThreadedChild && ( - + )} - + - + {richText?.text ? ( - + ) { - const {isMobile} = useWebMediaQueries() - const pal = usePalette('default') + const t = useTheme() if (treeView && depth > 0) { return ( {Array.from(Array(depth - 1)).map((_, n: number) => ( ))} {children} @@ -691,8 +682,9 @@ function PostOuterWrapper({ return ( - + + {niceDate(i18n, post.indexedAt)} - + {isRootPost && ( )} {needsTranslation && ( <> - · + + · + - Translate - + )} @@ -773,31 +760,9 @@ const styles = StyleSheet.create({ borderTopWidth: StyleSheet.hairlineWidth, paddingLeft: 8, }, - outerHighlighted: { - borderTopWidth: 0, - paddingTop: 4, - paddingLeft: 8, - paddingRight: 8, - }, - outerHighlightedRoot: { - borderTopWidth: StyleSheet.hairlineWidth, - paddingTop: 16, - }, noTopBorder: { borderTopWidth: 0, }, - layout: { - flexDirection: 'row', - paddingHorizontal: 8, - }, - layoutAvi: {}, - layoutContent: { - flex: 1, - marginLeft: 10, - }, - layoutContentThreaded: { - flex: 1, - }, meta: { flexDirection: 'row', paddingVertical: 2, @@ -805,42 +770,6 @@ const styles = StyleSheet.create({ metaExpandedLine1: { paddingVertical: 0, }, - alert: { - marginBottom: 6, - }, - postTextContainer: { - flexDirection: 'row', - alignItems: 'center', - flexWrap: 'wrap', - paddingBottom: 4, - paddingRight: 10, - overflow: 'hidden', - }, - postTextLargeContainer: { - paddingHorizontal: 0, - paddingRight: 0, - paddingBottom: 10, - }, - translateLink: { - marginBottom: 6, - }, - contentHider: { - marginBottom: 6, - }, - contentHiderChild: { - marginTop: 6, - }, - expandedInfo: { - flexDirection: 'row', - padding: 10, - borderTopWidth: StyleSheet.hairlineWidth, - borderBottomWidth: StyleSheet.hairlineWidth, - marginTop: 5, - marginBottom: 10, - }, - expandedInfoItem: { - marginRight: 10, - }, loadMore: { flexDirection: 'row', alignItems: 'center', From 3fb14d184a84cb892c751338c9aea625f5767a47 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 4 Oct 2024 16:28:05 -0500 Subject: [PATCH 7/7] Move font-face defs into HTML for CDN (#5612) --- bskyweb/templates/base.html | 14 ++++++++++++++ src/alf/fonts.ts | 2 ++ src/style.css | 15 --------------- web/index.html | 14 ++++++++++++++ 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html index 885d13d2a7..dd2474c824 100644 --- a/bskyweb/templates/base.html +++ b/bskyweb/templates/base.html @@ -24,6 +24,20 @@ * * THIS NEEDS TO BE DUPLICATED IN `bskyweb/templates/base.html` */ + @font-face { + font-family: 'InterVariable'; + src: url(/static/media/InterVariable.c9f788f6e7ebaec75d7c.ttf) format('truetype'); + font-weight: 300 1000; + font-style: normal; + font-display: swap; + } + @font-face { + font-family: 'InterVariableItalic'; + src: url(/static/media/InterVariable-Italic.55d6a3f35e9b605ba6f4.ttf) format('truetype'); + font-weight: 300 1000; + font-style: italic; + font-display: swap; + } html { background-color: white; scrollbar-gutter: stable both-edges; diff --git a/src/alf/fonts.ts b/src/alf/fonts.ts index 54fe7a34e0..b46faed1cc 100644 --- a/src/alf/fonts.ts +++ b/src/alf/fonts.ts @@ -70,6 +70,8 @@ export function applyFonts( * IMPORTANT: This is unused. Expo statically extracts these fonts. * * All used fonts MUST be configured here. Unused fonts can be commented out. + * + * This is used for both web fonts and native fonts. */ export function DO_NOT_USE() { return useFonts({ diff --git a/src/style.css b/src/style.css index 72e1ce2a2b..a4c501cc14 100644 --- a/src/style.css +++ b/src/style.css @@ -6,21 +6,6 @@ * may need to touch all three. Ask Eric if you aren't sure. */ -@font-face { - font-family: 'InterVariable'; - src: url(/assets/fonts/inter/InterVariable.ttf) format('truetype'); - font-weight: 300 1000; - font-style: normal; - font-display: swap; -} -@font-face { - font-family: 'InterVariableItalic'; - src: url(/assets/fonts/inter/InterVariable-Italic.ttf) format('truetype'); - font-weight: 300 1000; - font-style: italic; - font-display: swap; -} - /** * BEGIN STYLES * diff --git a/web/index.html b/web/index.html index 8efc2cb68f..2a406429c7 100644 --- a/web/index.html +++ b/web/index.html @@ -29,6 +29,20 @@ * * THIS NEEDS TO BE DUPLICATED IN `bskyweb/templates/base.html` */ + @font-face { + font-family: 'InterVariable'; + src: url(/static/media/InterVariable.c9f788f6e7ebaec75d7c.ttf) format('truetype'); + font-weight: 300 1000; + font-style: normal; + font-display: swap; + } + @font-face { + font-family: 'InterVariableItalic'; + src: url(/static/media/InterVariable-Italic.55d6a3f35e9b605ba6f4.ttf) format('truetype'); + font-weight: 300 1000; + font-style: italic; + font-display: swap; + } html { background-color: white; scrollbar-gutter: stable both-edges;