Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/native-comment-attachments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@baseapp-frontend/components': major
'@baseapp-frontend/design-system': minor
---

Comments on mobile can carry file attachments, like on web: attach photos from the library or PDFs
from the document picker, watch them upload above the composer, and see (and open) a comment's
files under it.

**Breaking:** `FileUploadProgress.file` is now typed `UploadSource` instead of `File`, because a
native upload has no `File`. A web `File` passed in is wrapped automatically and its `name`, `size`
and `type` read the same; code that read `File`-only members off `fileProgress.file` (`slice`,
`lastModified`, passing it to `URL.createObjectURL`) has to keep its own reference to the original
file instead.

The upload pipeline is now shared across platforms instead of being web-only:

- **`UploadSource`**: every upload entry point (`uploadFile`, `addFile`, `handleFilesSelected`)
accepts a web `File` or an `UploadSource` (`name`, `size`, `type`, `readChunk(start, end)`,
optional `dispose()`). Chunks are read lazily inside the concurrency slot, so a native upload holds
at most `MAX_CONCURRENT_CHUNKS` chunks in memory, and a retried chunk reuses the bytes it already
read. `dispose` runs when an upload leaves the store.
- A chunk whose upload was paused or removed during a retry backoff no longer starts a new request,
and `clearScope` now aborts the uploads it removes.
- **`filterSelectedFiles`** / **`describeRejectedFiles`**: the size/count/type rules both pickers
apply. The web file input now also drops files whose type is outside `acceptedFileTypes` (the
`accept` attribute alone can be bypassed from the OS picker).
- **`COMMENT_FILE_ATTACHMENT_LIMITS`** (`comments/common`): the comment attachment limits, shared by
web and mobile.
- **`files/native`**: `useNativeFilePicker`, `createNativeUploadSource`, `UploadingFilesList`,
`AttachedFilesList`, `FileChip`.
- Native `SocialInput` takes `SocialUpsertActionsProps`, and `SocialInputDrawer` takes a `Footer`
that the sheet grows to fit (`Placeholder` gets the matching `footerHeight`).
- `@baseapp-frontend/design-system`: native `AttachmentIcon`.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const SocialInput = forwardRef<NativeTextInput, SocialInputProps>(
SocialTextInput = DefaultSocialTextInput,
SocialTextInputProps = {},
SocialUpsertActions = DefaultSocialUpsertActions,
SocialUpsertActionsProps = {},
SubmitActions = DefaultSubmitActions,
SubmitActionsProps = {},
form,
Expand All @@ -41,7 +42,10 @@ const SocialInput = forwardRef<NativeTextInput, SocialInputProps>(
replyVariables={replyVariables}
{...SocialTextInputProps}
>
<SocialUpsertActions />
<SocialUpsertActions
shouldUseBottomSheetSafeComponents={shouldUseBottomSheetSafeComponents}
{...SocialUpsertActionsProps}
/>
<SubmitActions
disabled={submissionDisabled}
handleSubmit={submit}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { SubmitActionsProps } from './SubmitActions/types'
export interface SocialInputProps {
SocialTextInput?: FC<SocialTextInputProps>
SocialTextInputProps?: Partial<SocialTextInputProps>
SocialUpsertActions?: FC
SocialUpsertActions?: FC<any>
SocialUpsertActionsProps?: Record<string, any>
SubmitActions?: FC<SubmitActionsProps>
SubmitActionsProps?: Partial<SubmitActionsProps>
form: UseFormReturn<SocialUpsertForm, any, SocialUpsertForm>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { forwardRef, useCallback, useRef } from 'react'
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'

import { View } from '@baseapp-frontend/design-system/components/native/views'
import { useTheme } from '@baseapp-frontend/design-system/providers/native'

import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet'
import { TextInput as NativeTextInput } from 'react-native'
import { LayoutChangeEvent, TextInput as NativeTextInput } from 'react-native'

import DefaultSocialInput from '../../SocialInput'
import DefaultDrawerHandle from './DrawerHandle'
Expand All @@ -17,6 +17,9 @@ const Drawer = forwardRef<NativeTextInput, DrawerProps>(
DrawerHandle = DefaultDrawerHandle,
SocialInput = DefaultSocialInput,
SocialInputProps = {},
Footer,
FooterProps = {},
onFooterHeightChange,
form,
isLoading,
keyboardHeight = 0,
Expand All @@ -32,6 +35,22 @@ const Drawer = forwardRef<NativeTextInput, DrawerProps>(
) => {
const bottomSheetRef = useRef<BottomSheet>(null)
const theme = useTheme()
const [footerHeight, setFooterHeight] = useState(0)

const handleFooterLayout = useCallback(
({ nativeEvent }: LayoutChangeEvent) => {
const height = Math.ceil(nativeEvent.layout.height)
setFooterHeight(height)
onFooterHeightChange?.(height)
},
[onFooterHeightChange],
)
// A removed footer fires no layout event, so its last height must not linger.
const activeFooterHeight = Footer ? footerHeight : 0
const hasFooter = !!Footer
useEffect(() => {
if (!hasFooter) onFooterHeightChange?.(0)
}, [hasFooter, onFooterHeightChange])

const handleSheetChange = useCallback(
(index: number) => {
Expand Down Expand Up @@ -66,13 +85,21 @@ const Drawer = forwardRef<NativeTextInput, DrawerProps>(
<BottomSheet
ref={bottomSheetRef}
index={1}
snapPoints={[80 + keyboardHeight, 200 + keyboardHeight]}
snapPoints={[
80 + keyboardHeight + activeFooterHeight,
200 + keyboardHeight + activeFooterHeight,
]}
onChange={handleSheetChange}
onAnimate={handleAnimate}
handleComponent={showHandle ? DrawerHandle : null}
backgroundStyle={styles.background}
>
<BottomSheetView style={[styles.bottomSheetContainer, style]}>
{Footer ? (
<View onLayout={handleFooterLayout}>
<Footer {...FooterProps} />
</View>
) : null}
<SocialInput
form={form}
isLoading={isLoading}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export interface DrawerProps {
DrawerHandle?: FC
SocialInput?: FC<SocialInputProps>
SocialInputProps?: Partial<SocialInputProps>
/** Rendered above the input inside the sheet (e.g. attachment chips); the sheet grows to fit it. */
Footer?: FC<any>
FooterProps?: Record<string, any>
/** Reports the footer's height so a `Placeholder` can reserve the same space. */
onFooterHeightChange?: (height: number) => void
form: UseFormReturn<SocialUpsertForm, any, SocialUpsertForm>
isLoading: boolean
keyboardHeight?: number
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ const Placeholder: FC<PlaceholderProps> = ({
handleHeight = 38,
keyboardHeight = 0,
layoutOverheadHeight = 88,
footerHeight = 0,
showHandle,
textHeight = 0,
}) => (
<View
style={{
height: textHeight + (showHandle ? handleHeight : 0) + layoutOverheadHeight + keyboardHeight,
height:
textHeight +
(showHandle ? handleHeight : 0) +
layoutOverheadHeight +
keyboardHeight +
footerHeight,
}}
/>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export interface PlaceholderProps {
handleHeight?: number
keyboardHeight?: number
layoutOverheadHeight?: number
/** Height of the Drawer's footer (see `onFooterHeightChange`). */
footerHeight?: number
showHandle: boolean
textHeight?: number
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { IconButton } from '@baseapp-frontend/design-system/components/native/bu
import { EmojiIcon } from '@baseapp-frontend/design-system/components/native/icons'
import { useTheme } from '@baseapp-frontend/design-system/providers/native'

const SocialUpsertActions: FC = () => {
import { SocialUpsertActionsProps } from './types'

// The placeholder emoji button ignores shouldUseBottomSheetSafeComponents; it has no action yet.
const SocialUpsertActions: FC<SocialUpsertActionsProps> = () => {
const theme = useTheme()

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface SocialUpsertActionsProps {
shouldUseBottomSheetSafeComponents?: boolean
}
10 changes: 10 additions & 0 deletions packages/components/modules/comments/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,13 @@ export const COMMENTS_LIST_CONNECTION_KEY = 'CommentsList_comments'
export const DEFAULT_COMMENTS_ORDER_BY = '-is_pinned,-created'

export const DEFAULT_MAX_THREAD_DEPTH = 5

// One source for both platforms, so web and mobile refuse the same attachments.
export const COMMENT_FILE_ATTACHMENT_LIMITS = {
maxFiles: 5,
maxFileSize: 100 * 1024 * 1024, // 100MB
acceptedFileTypes: {
'image/*': ['.png', '.jpg'],
'application/pdf': ['.pdf'],
},
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { FC, useCallback, useEffect, useRef, useTransition } from 'react'
import { FC, useCallback, useEffect, useRef, useState, useTransition } from 'react'

import { View } from '@baseapp-frontend/design-system/components/native/views'

import { TextInput as NativeTextInput, ScrollView } from 'react-native'
import { useFragment } from 'react-relay'

import { SocialInputDrawer as DefaultSocialInputDrawer } from '../../../__shared__/native'
import { UploadingFilesList } from '../../../files/native'
import { CommentsFragmentQuery, DEFAULT_MAX_THREAD_DEPTH, useCommentReply } from '../../common'
import CommentFilesUpsertActions from '../CommentFilesUpsertActions'
import DefaultCommentsList from '../CommentsList'
import CommentActionsProvider from '../context/CommentActionsProvider'
import { createStyles } from './styles'
Expand Down Expand Up @@ -35,15 +37,17 @@ const BaseComments: FC<BaseCommentsProps> = ({
const commentCreateRef = useRef<NativeTextInput>(null)
const commentsListRefetchRef = useRef<(() => void) | null>(null)
const [, startTransition] = useTransition()
const [footerHeight, setFooterHeight] = useState(0)
const { resetCommentReply, resetCommentEdit } = useCommentReply()

const { isFocused, onFocusChange, textHeight, onTextHeightChange, keyboardHeight } =
SocialInputDrawer.useTextInputProperties()

const { form, submit, isLoading, editVariables, replyVariables } = useCommentComposer({
targetObjectId: target.id,
onSubmitSuccess: () => commentCreateRef.current?.blur(),
})
const { form, submit, isLoading, editVariables, replyVariables, attachments } =
useCommentComposer({
targetObjectId: target.id,
onSubmitSuccess: () => commentCreateRef.current?.blur(),
})

const body = form.watch('body')
const showHandle = isFocused || body !== ''
Expand Down Expand Up @@ -92,6 +96,7 @@ const BaseComments: FC<BaseCommentsProps> = ({
keyboardHeight={keyboardHeight}
showHandle={showHandle}
textHeight={textHeight}
footerHeight={footerHeight}
{...SocialInputDrawerProps.PlaceholderProps}
/>
</View>
Expand All @@ -107,7 +112,18 @@ const BaseComments: FC<BaseCommentsProps> = ({
submit={submit}
editVariables={editVariables}
replyVariables={replyVariables}
Footer={attachments.isEnabled ? UploadingFilesList : undefined}
FooterProps={{ scope: attachments.scope, shouldUseBottomSheetSafeComponents: true }}
onFooterHeightChange={setFooterHeight}
{...SocialInputDrawerProps.DrawerProps}
SocialInputProps={{
SocialUpsertActions: CommentFilesUpsertActions,
SocialUpsertActionsProps: {
onFilesSelected: attachments.onFilesSelected,
disabled: !attachments.isEnabled,
},
...SocialInputDrawerProps.DrawerProps?.SocialInputProps,
}}
/>
</View>
</CommentActionsProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
SOCIAL_UPSERT_FORM_VALIDATION_SCHEMA,
SocialUpsertForm,
} from '../../../../__shared__/common'
import { useDeferredFileAttachments } from '../../../../files/common'
import { useCommentCreateForm, useCommentReply, useCommentUpdateForm } from '../../../common'
import { UseCommentComposerOptions, UseCommentComposerReturn } from './types'

Expand All @@ -24,6 +25,12 @@ const useCommentComposer = ({
onSubmitSuccess,
}: UseCommentComposerOptions): UseCommentComposerReturn => {
const { editingComment, resetCommentEdit } = useCommentReply()
const {
handleFilesSelected,
attachTo,
hasPendingFiles,
scope: filesScope,
} = useDeferredFileAttachments()

// The single form behind the drawer, shared by both modes.
const form = useForm<SocialUpsertForm>({
Expand All @@ -42,7 +49,13 @@ const useCommentComposer = ({
form,
expandRepliesOnSuccess: true,
resetFormOnReplyTargetChange: true,
onSuccess: () => onSubmitSuccess?.(),
onSuccess: ({ commentId }) => {
if (commentId) {
// Fire-and-forget: attachTo reports mutation failures through its own onError.
attachTo(commentId).catch(() => undefined)
}
onSubmitSuccess?.()
},
})

const {
Expand Down Expand Up @@ -74,12 +87,20 @@ const useCommentComposer = ({
return {
form,
submit,
isLoading: isCreating || isUpdating,
// Block a new comment until every attachment has finished, so a paused or failed
// upload is never silently left off it. Edits do not carry attachments.
isLoading: isCreating || isUpdating || (!isEditMode && hasPendingFiles),
editVariables: {
isEditMode,
label: 'Editing your comment',
onEditCancel: cancelEdit,
},
attachments: {
// Attachments go on new comments only; while editing, the draft's files wait.
isEnabled: !isEditMode,
onFilesSelected: handleFilesSelected,
scope: filesScope,
},
replyVariables: {
isReplyMode: isReply,
label: 'Replying to ',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { UseFormReturn } from 'react-hook-form'

import { SocialUpsertForm } from '../../../../__shared__/common'
import type { UploadSource } from '../../../../files/common'

export interface UseCommentComposerOptions {
targetObjectId: string
Expand All @@ -21,11 +22,20 @@ export interface CommentComposerReplyVariables {
targetName: string
}

export interface CommentComposerAttachments {
/** False while editing: attachments are added to new comments only. */
isEnabled: boolean
onFilesSelected: (files: UploadSource[]) => Promise<void>
/** Upload-store scope holding this composer's pending files. */
scope: string
}

export interface UseCommentComposerReturn {
/** The active form: the update form while editing, the create form otherwise. */
form: UseFormReturn<SocialUpsertForm>
submit: () => void
isLoading: boolean
editVariables: CommentComposerEditVariables
attachments: CommentComposerAttachments
replyVariables: CommentComposerReplyVariables
}
Loading
Loading