Merge main into gallery-embed, migrate off local shim
Bumps @atproto/api to 0.20.9, which ships the generated `AppBskyEmbedGallery` namespace and `DraftPost.embedGallery`. Swaps every `#/lib/api/gallery-embed-shim` import to `@atproto/api`, deletes the shim, drops the local `DraftPostWithGallery` widen-types, and adds the narrowing calls (`isViewImage`, `isDraftEmbedImage`) the real lexicon unions require. The merge also auto-resolves an inline image-restore block in `drafts/state/api.ts` against main's typing tweak — main switched to `satisfies ComposerImage` and `NonNullable<typeof img>`, which here move into the existing `restoreDraftImages` helper that's now shared by both the images and gallery restore paths.
This commit is contained in:
@@ -71,18 +71,18 @@ jobs:
|
||||
profile: ${{ inputs.channel || 'testflight' }}
|
||||
previous-commit-tag: ${{ inputs.runtimeVersion }}
|
||||
|
||||
- name: Lint check
|
||||
run: pnpm lint
|
||||
|
||||
- name: Prettier check
|
||||
run: pnpm prettier --check .
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: pnpm intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
|
||||
- name: Lint check
|
||||
run: pnpm lint
|
||||
|
||||
- name: Prettier check
|
||||
run: pnpm prettier --check .
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job: [lint, prettier]
|
||||
job: [lint, prettier, typecheck]
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v5
|
||||
@@ -62,6 +62,8 @@ jobs:
|
||||
command: pnpm install --frozen-lockfile
|
||||
attempt_limit: 3
|
||||
attempt_delay: 2000
|
||||
- name: Check & compile i18n
|
||||
run: pnpm intl:build
|
||||
- name: Lint checks
|
||||
run: pnpm ${{ matrix.job }}
|
||||
# Aggregates the matrix results into a single stable check name so branch
|
||||
@@ -80,28 +82,6 @@ jobs:
|
||||
run: |
|
||||
echo "linting result: $RESULT"
|
||||
test "$RESULT" = "success"
|
||||
typechecking:
|
||||
name: Run typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v6
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
- name: pnpm install
|
||||
uses: Wandalen/wretry.action@master
|
||||
with:
|
||||
command: pnpm install --frozen-lockfile
|
||||
attempt_limit: 3
|
||||
attempt_delay: 2000
|
||||
- name: Check & compile i18n
|
||||
run: pnpm intl:build
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
testing:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -128,15 +128,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint check
|
||||
run: pnpm lint
|
||||
|
||||
- name: 🔤 Compile translations
|
||||
run: pnpm intl:build 2>&1 | tee i18n.log
|
||||
|
||||
- name: Check for i18n compilation errors
|
||||
run: if grep -q "invalid syntax" "i18n.log"; then echo "\n\nFound compilation errors!\n\n" && exit 1; else echo "\n\nNo compilation errors!\n\n"; fi
|
||||
|
||||
- name: Lint check
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
|
||||
+1833
-1
File diff suppressed because it is too large
Load Diff
+18
-20
@@ -134,11 +134,10 @@ export default defineConfig(
|
||||
'react-native/no-inline-styles': 'off',
|
||||
...reactNativeA11y.configs.all.rules,
|
||||
'react-compiler/react-compiler': 'warn',
|
||||
// TODO: Fix these and set to error
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
'react-hooks/purity': 'warn',
|
||||
'react-hooks/refs': 'warn',
|
||||
'react-hooks/immutability': 'warn',
|
||||
'react-hooks/set-state-in-effect': 'error',
|
||||
'react-hooks/purity': 'error',
|
||||
'react-hooks/refs': 'error',
|
||||
'react-hooks/immutability': 'error',
|
||||
|
||||
/**
|
||||
* Import sorting
|
||||
@@ -235,9 +234,8 @@ export default defineConfig(
|
||||
},
|
||||
],
|
||||
/**
|
||||
* Maintain previous behavior - these are stricter in typescript-eslint
|
||||
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too
|
||||
* nit-picky
|
||||
* Maintain previous behavior via eslint-suppressions.json - these are
|
||||
* stricter in typescript-eslint v8. `off` ones are a bit too nit-picky.
|
||||
*/
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
@@ -247,18 +245,18 @@ export default defineConfig(
|
||||
'@typescript-eslint/unbound-method': 'off',
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'warn',
|
||||
'@typescript-eslint/no-unsafe-call': 'warn',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-misused-promises': 'warn',
|
||||
'@typescript-eslint/require-await': 'warn',
|
||||
'@typescript-eslint/no-unsafe-enum-comparison': 'warn',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'warn',
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'warn',
|
||||
'@typescript-eslint/no-duplicate-type-constituents': 'warn',
|
||||
'@typescript-eslint/no-base-to-string': 'warn',
|
||||
'@typescript-eslint/prefer-promise-reject-errors': 'warn',
|
||||
'@typescript-eslint/await-thenable': 'warn',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'error',
|
||||
'@typescript-eslint/no-unsafe-call': 'error',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
'@typescript-eslint/require-await': 'error',
|
||||
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'error',
|
||||
'@typescript-eslint/no-duplicate-type-constituents': 'error',
|
||||
'@typescript-eslint/no-base-to-string': 'error',
|
||||
'@typescript-eslint/prefer-promise-reject-errors': 'error',
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@
|
||||
"prettier": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.8",
|
||||
"@atproto/api": "0.20.9",
|
||||
"@atproto/syntax": "0.6.1",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
|
||||
Generated
+5
-5
@@ -242,8 +242,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@atproto/api':
|
||||
specifier: 0.20.8
|
||||
version: 0.20.8
|
||||
specifier: 0.20.9
|
||||
version: 0.20.9
|
||||
'@atproto/syntax':
|
||||
specifier: 0.6.1
|
||||
version: 0.6.1
|
||||
@@ -877,8 +877,8 @@ packages:
|
||||
graphql:
|
||||
optional: true
|
||||
|
||||
'@atproto/api@0.20.8':
|
||||
resolution: {integrity: sha512-rTkA6kOmA2axSrg6VgpdXpsCFWpofnHBOn6pKg69Ju5MpIHqk4haQMgjBcVh1G3kUxzwgSAr7SYrPS3dFe5Etg==}
|
||||
'@atproto/api@0.20.9':
|
||||
resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
@@ -9493,7 +9493,7 @@ snapshots:
|
||||
|
||||
'@0no-co/graphql.web@1.2.0': {}
|
||||
|
||||
'@atproto/api@0.20.8':
|
||||
'@atproto/api@0.20.9':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/lexicon': 0.7.1
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {
|
||||
AppBskyEmbedGallery,
|
||||
type AppBskyEmbedImages,
|
||||
type AppBskyFeedDefs,
|
||||
} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {shareImageModal} from '#/lib/media/manip'
|
||||
@@ -52,23 +56,26 @@ export function Embed({
|
||||
// a 10-image gallery doesn't blow out the row width.
|
||||
return (
|
||||
<Outer style={style}>
|
||||
{e.view.items.slice(0, 4).map(item => {
|
||||
const image: AppBskyEmbedImages.ViewImage = {
|
||||
thumb: item.thumbnail,
|
||||
fullsize: item.fullsize,
|
||||
alt: item.alt,
|
||||
aspectRatio: item.aspectRatio,
|
||||
}
|
||||
return peekable ? (
|
||||
<PeekableImageItem key={item.thumbnail} image={image} />
|
||||
) : (
|
||||
<ImageItem
|
||||
key={item.thumbnail}
|
||||
thumbnail={item.thumbnail}
|
||||
alt={item.alt}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{e.view.items
|
||||
.filter(AppBskyEmbedGallery.isViewImage)
|
||||
.slice(0, 4)
|
||||
.map(item => {
|
||||
const image: AppBskyEmbedImages.ViewImage = {
|
||||
thumb: item.thumbnail,
|
||||
fullsize: item.fullsize,
|
||||
alt: item.alt,
|
||||
aspectRatio: item.aspectRatio,
|
||||
}
|
||||
return peekable ? (
|
||||
<PeekableImageItem key={item.thumbnail} image={image} />
|
||||
) : (
|
||||
<ImageItem
|
||||
key={item.thumbnail}
|
||||
thumbnail={item.thumbnail}
|
||||
alt={item.alt}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Outer>
|
||||
)
|
||||
} else if (e.type === 'link') {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {useRef} from 'react'
|
||||
import {InteractionManager, View} from 'react-native'
|
||||
import {type AnimatedRef} from 'react-native-reanimated'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyEmbedImages} from '@atproto/api'
|
||||
import {AppBskyEmbedGallery, type AppBskyEmbedImages} from '@atproto/api'
|
||||
|
||||
import {atoms as a, tokens} from '#/alf'
|
||||
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
||||
@@ -28,7 +28,7 @@ export function ImageEmbed({
|
||||
const {openLightbox} = useLightboxControls()
|
||||
const images: AppBskyEmbedImages.ViewImage[] =
|
||||
embed.type === 'gallery'
|
||||
? embed.view.items.map(item => ({
|
||||
? embed.view.items.filter(AppBskyEmbedGallery.isViewImage).map(item => ({
|
||||
thumb: item.thumbnail,
|
||||
fullsize: item.fullsize,
|
||||
alt: item.alt,
|
||||
@@ -101,8 +101,7 @@ export function ImageEmbed({
|
||||
crop={
|
||||
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
|
||||
? 'none'
|
||||
: rest.viewContext ===
|
||||
PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
: rest.isWithinQuote
|
||||
? 'square'
|
||||
: 'constrained'
|
||||
}
|
||||
@@ -117,10 +116,7 @@ export function ImageEmbed({
|
||||
onPress(0, [containerRef], [dims])
|
||||
}
|
||||
onPressIn={() => onPressIn(0)}
|
||||
hideBadge={
|
||||
rest.viewContext ===
|
||||
PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
}
|
||||
hideBadge={rest.isWithinQuote}
|
||||
/>
|
||||
</ImageContextMenu>
|
||||
</View>
|
||||
@@ -135,6 +131,7 @@ export function ImageEmbed({
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
viewContext={rest.viewContext}
|
||||
isWithinQuote={rest.isWithinQuote}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -224,6 +224,7 @@ export function Basic({
|
||||
cancelButtonCta,
|
||||
confirmButtonCta,
|
||||
onConfirm,
|
||||
onClose,
|
||||
confirmButtonColor,
|
||||
showCancel = true,
|
||||
}: React.PropsWithChildren<{
|
||||
@@ -240,11 +241,12 @@ export function Basic({
|
||||
* should NOT close the dialog as a side effect of this method.
|
||||
*/
|
||||
onConfirm: (e: GestureResponderEvent) => void
|
||||
onClose?: () => void
|
||||
confirmButtonColor?: ButtonColor
|
||||
showCancel?: boolean
|
||||
}>) {
|
||||
return (
|
||||
<Outer control={control} testID="confirmModal">
|
||||
<Outer control={control} testID="confirmModal" onClose={onClose}>
|
||||
<Content>
|
||||
<TitleText>{title}</TitleText>
|
||||
{description && <DescriptionText>{description}</DescriptionText>}
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
type UninheritableButtonProps,
|
||||
} from '#/components/Button'
|
||||
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
|
||||
import {
|
||||
CircleInfo_Stroke2_Corner0_Rounded as CircleInfo,
|
||||
CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon,
|
||||
} from '#/components/icons/CircleInfo'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import {dismiss} from '#/components/Toast/sonner'
|
||||
|
||||
@@ -33,14 +33,19 @@ export const AfterReportDialog = memo(function BlockOrDeleteDialogInner({
|
||||
control,
|
||||
params,
|
||||
currentScreen,
|
||||
onClose,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
params: ReportDialogParams
|
||||
currentScreen: 'list' | 'conversation'
|
||||
onClose?: () => void
|
||||
}): React.ReactNode {
|
||||
const {t: l} = useLingui()
|
||||
return (
|
||||
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={onClose}
|
||||
nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
label={l`Would you like to block this user and/or delete this conversation?`}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {memo, useCallback} from 'react'
|
||||
import {LayoutAnimation, Platform} from 'react-native'
|
||||
import {Platform} from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import {
|
||||
type ChatBskyConvoDefs,
|
||||
@@ -7,26 +7,21 @@ import {
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a} from '#/alf'
|
||||
import * as ContextMenu from '#/components/ContextMenu'
|
||||
import {type TriggerProps} from '#/components/ContextMenu/types'
|
||||
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
|
||||
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
||||
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
|
||||
import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||
import {ReportDialog} from '#/components/moderation/ReportDialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {usePromptControl} from '#/components/Prompt'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
@@ -48,11 +43,8 @@ export let MessageContextMenu = ({
|
||||
const {t: l, i18n} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const {currentAccount} = useSession()
|
||||
const queryClient = useQueryClient()
|
||||
const convo = useConvoActive()
|
||||
const deleteControl = usePromptControl()
|
||||
const reportControl = usePromptControl()
|
||||
const blockOrDeleteControl = usePromptControl()
|
||||
const {openDeleteMessage, openReportMessage} = useMessageDialogs()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const translate = useGoogleTranslate()
|
||||
|
||||
@@ -93,14 +85,6 @@ export let MessageContextMenu = ({
|
||||
})
|
||||
}, [ax, langPrefs.primaryLanguage, message.text, translate])
|
||||
|
||||
const onDelete = useCallback(() => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
convo
|
||||
.deleteMessage(message.id)
|
||||
.then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
|
||||
.catch(() => Toast.show(l`Failed to delete message`))
|
||||
}, [l, convo, message.id])
|
||||
|
||||
const onEmojiSelect = useCallback(
|
||||
(emoji: string) => {
|
||||
if (
|
||||
@@ -128,104 +112,72 @@ export let MessageContextMenu = ({
|
||||
const sender = senderProfile
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Root>
|
||||
{IS_NATIVE && reactionsAvailable && (
|
||||
<ContextMenu.AuxiliaryView
|
||||
align={isFromSelf ? 'right' : 'left'}
|
||||
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
|
||||
<EmojiReactionPicker
|
||||
message={message}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
/>
|
||||
</ContextMenu.AuxiliaryView>
|
||||
)}
|
||||
|
||||
<ContextMenu.Trigger
|
||||
label={l`Message options`}
|
||||
contentLabel={l`Message from @${
|
||||
sender?.handle ?? 'unknown' // should always be defined
|
||||
}: ${message.text}`}>
|
||||
{children}
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
<ContextMenu.Outer
|
||||
<ContextMenu.Root>
|
||||
{IS_NATIVE && reactionsAvailable && (
|
||||
<ContextMenu.AuxiliaryView
|
||||
align={isFromSelf ? 'right' : 'left'}
|
||||
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
|
||||
timeStyle: 'short',
|
||||
})}`}
|
||||
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
|
||||
{message.text.length > 0 && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownTranslateBtn"
|
||||
label={l`Translate`}
|
||||
onPress={onPressTranslateMessage}>
|
||||
<ContextMenu.ItemIcon icon={LanguageIcon} position="left" />
|
||||
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownCopyBtn"
|
||||
label={l`Copy message text`}
|
||||
onPress={onCopyMessage}>
|
||||
<ContextMenu.ItemIcon icon={ClipboardIcon} position="left" />
|
||||
<ContextMenu.ItemText>
|
||||
{l`Copy message text`}
|
||||
</ContextMenu.ItemText>
|
||||
</ContextMenu.Item>
|
||||
</>
|
||||
)}
|
||||
<EmojiReactionPicker
|
||||
message={message}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
/>
|
||||
</ContextMenu.AuxiliaryView>
|
||||
)}
|
||||
|
||||
<ContextMenu.Trigger
|
||||
label={l`Message options`}
|
||||
contentLabel={l`Message from @${
|
||||
sender?.handle ?? 'unknown' // should always be defined
|
||||
}: ${message.text}`}>
|
||||
{children}
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
<ContextMenu.Outer
|
||||
align={isFromSelf ? 'right' : 'left'}
|
||||
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
|
||||
timeStyle: 'short',
|
||||
})}`}
|
||||
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
|
||||
{message.text.length > 0 && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownTranslateBtn"
|
||||
label={l`Translate`}
|
||||
onPress={onPressTranslateMessage}>
|
||||
<ContextMenu.ItemIcon icon={LanguageIcon} position="left" />
|
||||
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownCopyBtn"
|
||||
label={l`Copy message text`}
|
||||
onPress={onCopyMessage}>
|
||||
<ContextMenu.ItemIcon icon={ClipboardIcon} position="left" />
|
||||
<ContextMenu.ItemText>
|
||||
{l`Copy message text`}
|
||||
</ContextMenu.ItemText>
|
||||
</ContextMenu.Item>
|
||||
</>
|
||||
)}
|
||||
<ContextMenu.Item
|
||||
destructive
|
||||
testID="messageDropdownDeleteBtn"
|
||||
label={l`Delete message for me`}
|
||||
onPress={() => openDeleteMessage(message)}>
|
||||
<ContextMenu.ItemIcon icon={TrashIcon} position="left" />
|
||||
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
|
||||
</ContextMenu.Item>
|
||||
{!isFromSelf && (
|
||||
<ContextMenu.Item
|
||||
destructive
|
||||
testID="messageDropdownDeleteBtn"
|
||||
label={l`Delete message for me`}
|
||||
onPress={() => deleteControl.open()}>
|
||||
<ContextMenu.ItemIcon icon={TrashIcon} position="left" />
|
||||
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
|
||||
testID="messageDropdownReportBtn"
|
||||
label={l`Report message`}
|
||||
onPress={() => openReportMessage(message, senderProfile)}>
|
||||
<ContextMenu.ItemIcon icon={FlagIcon} position="left" />
|
||||
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
|
||||
</ContextMenu.Item>
|
||||
{!isFromSelf && (
|
||||
<ContextMenu.Item
|
||||
destructive
|
||||
testID="messageDropdownReportBtn"
|
||||
label={l`Report message`}
|
||||
onPress={() => reportControl.open()}>
|
||||
<ContextMenu.ItemIcon icon={FlagIcon} position="left" />
|
||||
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
</ContextMenu.Outer>
|
||||
</ContextMenu.Root>
|
||||
<ReportDialog
|
||||
control={reportControl}
|
||||
subject={{
|
||||
view: 'message',
|
||||
convoId: convo.convo.view.id,
|
||||
message,
|
||||
}}
|
||||
onAfterSubmit={() => {
|
||||
if (sender) {
|
||||
unstableCacheProfileView(queryClient, sender)
|
||||
}
|
||||
blockOrDeleteControl.open()
|
||||
}}
|
||||
/>
|
||||
<AfterReportDialog
|
||||
control={blockOrDeleteControl}
|
||||
currentScreen="conversation"
|
||||
params={{
|
||||
convoId: convo.convo.view.id,
|
||||
did: message.sender.did,
|
||||
}}
|
||||
/>
|
||||
<Prompt.Basic
|
||||
control={deleteControl}
|
||||
title={l`Delete message`}
|
||||
description={l`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants.`}
|
||||
confirmButtonCta={l`Delete`}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu.Outer>
|
||||
</ContextMenu.Root>
|
||||
)
|
||||
}
|
||||
MessageContextMenu = memo(MessageContextMenu)
|
||||
|
||||
@@ -40,8 +40,8 @@ import {useSession} from '#/state/session'
|
||||
import {atoms as a, native, platform, useTheme} from '#/alf'
|
||||
import {isOnlyEmoji} from '#/alf/typography'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
||||
import {InlineLinkText, Link} from '#/components/Link'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
@@ -49,7 +49,7 @@ import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {DateDivider} from './DateDivider'
|
||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||
import {ReactionsDialog} from './ReactionsDialog'
|
||||
import {groupReactions} from './ReactionsDialog'
|
||||
import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util'
|
||||
|
||||
const AVATAR_SIZE = 28
|
||||
@@ -118,7 +118,7 @@ let MessageItem = ({
|
||||
const {message} = item
|
||||
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
|
||||
|
||||
const reactionsControl = useDialogControl()
|
||||
const {openReactions} = useMessageDialogs()
|
||||
|
||||
const isPending = item.type === 'pending-message'
|
||||
|
||||
@@ -243,34 +243,10 @@ let MessageItem = ({
|
||||
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
|
||||
)
|
||||
|
||||
const groupedReactions = useMemo(() => {
|
||||
const reactions = message.reactions ?? []
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{
|
||||
key: string
|
||||
value: string
|
||||
senders: ChatBskyConvoDefs.ReactionViewSender[]
|
||||
count: number
|
||||
}
|
||||
>()
|
||||
for (const reaction of reactions) {
|
||||
if (!reaction) continue
|
||||
const existing = grouped.get(reaction.value)
|
||||
if (existing) {
|
||||
existing.senders.push(reaction.sender)
|
||||
existing.count++
|
||||
} else {
|
||||
grouped.set(reaction.value, {
|
||||
key: reaction.value,
|
||||
value: reaction.value,
|
||||
senders: [reaction.sender],
|
||||
count: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(grouped.values())
|
||||
}, [message.reactions])
|
||||
const groupedReactions = useMemo(
|
||||
() => groupReactions(message.reactions),
|
||||
[message.reactions],
|
||||
)
|
||||
|
||||
const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
|
||||
|
||||
@@ -336,7 +312,7 @@ let MessageItem = ({
|
||||
transform: [{translateY: -8}],
|
||||
},
|
||||
]}
|
||||
onPress={isGroupChat ? reactionsControl.open : undefined}>
|
||||
onPress={isGroupChat ? () => openReactions(message) : undefined}>
|
||||
{groupedReactions.map(group => (
|
||||
<Animated.View
|
||||
entering={native(ZoomIn.springify(200).delay(400))}
|
||||
@@ -377,13 +353,6 @@ let MessageItem = ({
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
<ReactionsDialog
|
||||
control={reactionsControl}
|
||||
relatedProfiles={relatedProfiles}
|
||||
message={message}
|
||||
reactions={message.reactions}
|
||||
groupedReactions={groupedReactions}
|
||||
/>
|
||||
</LayoutAnimationConfig>
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {LayoutAnimation} from 'react-native'
|
||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
|
||||
import {ReactionsDialog} from '#/components/dms/ReactionsDialog'
|
||||
import {ReportDialog} from '#/components/moderation/ReportDialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {usePromptControl} from '#/components/Prompt'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
type MessageDialogsContextType = {
|
||||
openDeleteMessage: (message: ChatBskyConvoDefs.MessageView) => void
|
||||
openReportMessage: (
|
||||
message: ChatBskyConvoDefs.MessageView,
|
||||
senderProfile: bsky.profile.AnyProfileView | undefined,
|
||||
) => void
|
||||
openReactions: (message: ChatBskyConvoDefs.MessageView) => void
|
||||
}
|
||||
|
||||
const Context = createContext<MessageDialogsContextType | null>(null)
|
||||
|
||||
export function useMessageDialogs() {
|
||||
const ctx = useContext(Context)
|
||||
if (!ctx) {
|
||||
throw new Error('useMessageDialogs must be used within a MessageOverlays')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function MessageOverlays({children}: {children: React.ReactNode}) {
|
||||
const {t: l} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
const convo = useConvoActive()
|
||||
|
||||
const deleteControl = usePromptControl()
|
||||
const reportControl = usePromptControl()
|
||||
const afterReportControl = usePromptControl()
|
||||
const reactionsControl = useDialogControl()
|
||||
|
||||
const [deleteTarget, setDeleteTarget] =
|
||||
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
||||
const [reportTarget, setReportTarget] = useState<{
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
senderProfile: bsky.profile.AnyProfileView | undefined
|
||||
} | null>(null)
|
||||
const [afterReportTarget, setAfterReportTarget] =
|
||||
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
||||
const [reactionsTarget, setReactionsTarget] =
|
||||
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
||||
|
||||
const openDeleteMessage = useCallback(
|
||||
(message: ChatBskyConvoDefs.MessageView) => {
|
||||
setDeleteTarget(message)
|
||||
deleteControl.open()
|
||||
},
|
||||
[deleteControl],
|
||||
)
|
||||
|
||||
const openReportMessage = useCallback(
|
||||
(
|
||||
message: ChatBskyConvoDefs.MessageView,
|
||||
senderProfile: bsky.profile.AnyProfileView | undefined,
|
||||
) => {
|
||||
setReportTarget({message, senderProfile})
|
||||
reportControl.open()
|
||||
},
|
||||
[reportControl],
|
||||
)
|
||||
|
||||
const openReactions = useCallback(
|
||||
(message: ChatBskyConvoDefs.MessageView) => {
|
||||
setReactionsTarget(message)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// These dialogs are conditionally mounted, so we can't open them in the same
|
||||
// tick that we set their targets - the control refs aren't attached yet. Open
|
||||
// in an effect after the dialog has mounted.
|
||||
useEffect(() => {
|
||||
if (reactionsTarget) {
|
||||
reactionsControl.open()
|
||||
}
|
||||
}, [reactionsTarget, reactionsControl])
|
||||
|
||||
useEffect(() => {
|
||||
if (afterReportTarget) {
|
||||
afterReportControl.open()
|
||||
}
|
||||
}, [afterReportTarget, afterReportControl])
|
||||
|
||||
const onConfirmDelete = useCallback(() => {
|
||||
if (!deleteTarget) return
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
convo
|
||||
.deleteMessage(deleteTarget.id)
|
||||
.then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
|
||||
.catch(() => Toast.show(l`Failed to delete message`))
|
||||
}, [l, convo, deleteTarget])
|
||||
|
||||
const onAfterReportSubmit = useCallback(() => {
|
||||
if (!reportTarget) return
|
||||
if (reportTarget.senderProfile) {
|
||||
unstableCacheProfileView(queryClient, reportTarget.senderProfile)
|
||||
}
|
||||
setAfterReportTarget(reportTarget.message)
|
||||
}, [queryClient, reportTarget])
|
||||
|
||||
const ctx = useMemo<MessageDialogsContextType>(
|
||||
() => ({openDeleteMessage, openReportMessage, openReactions}),
|
||||
[openDeleteMessage, openReportMessage, openReactions],
|
||||
)
|
||||
|
||||
const reportSubject = reportTarget
|
||||
? ({
|
||||
view: 'message',
|
||||
convoId: convo.convo.view.id,
|
||||
message: reportTarget.message,
|
||||
} as const)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Context.Provider value={ctx}>
|
||||
{children}
|
||||
<ReportDialog
|
||||
control={reportControl}
|
||||
subject={reportSubject}
|
||||
onAfterSubmit={onAfterReportSubmit}
|
||||
onClose={() => setReportTarget(null)}
|
||||
/>
|
||||
{afterReportTarget && (
|
||||
<AfterReportDialog
|
||||
control={afterReportControl}
|
||||
currentScreen="conversation"
|
||||
params={{
|
||||
convoId: convo.convo.view.id,
|
||||
did: afterReportTarget.sender.did,
|
||||
}}
|
||||
onClose={() => setAfterReportTarget(null)}
|
||||
/>
|
||||
)}
|
||||
{reactionsTarget && (
|
||||
<ReactionsDialog
|
||||
control={reactionsControl}
|
||||
relatedProfiles={convo.relatedProfiles}
|
||||
message={reactionsTarget}
|
||||
onClose={() => setReactionsTarget(null)}
|
||||
/>
|
||||
)}
|
||||
<Prompt.Basic
|
||||
control={deleteControl}
|
||||
title={l`Delete message`}
|
||||
description={l`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants.`}
|
||||
confirmButtonCta={l`Delete`}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={onConfirmDelete}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useRef, useState} from 'react'
|
||||
import {useMemo, useRef, useState} from 'react'
|
||||
import {
|
||||
LayoutAnimation,
|
||||
Pressable,
|
||||
@@ -37,14 +37,12 @@ export function ReactionsDialog({
|
||||
control,
|
||||
relatedProfiles,
|
||||
message,
|
||||
reactions,
|
||||
groupedReactions,
|
||||
onClose,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
reactions?: ChatBskyConvoDefs.ReactionView[]
|
||||
groupedReactions?: Reaction[]
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
@@ -54,6 +52,9 @@ export function ReactionsDialog({
|
||||
|
||||
const [selected, setSelected] = useState('all')
|
||||
|
||||
const reactions = message.reactions
|
||||
const groupedReactions = useMemo(() => groupReactions(reactions), [reactions])
|
||||
|
||||
const filteredReactions = reactions?.filter(
|
||||
r => selected === 'all' || r.value === selected,
|
||||
)
|
||||
@@ -78,7 +79,10 @@ export function ReactionsDialog({
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={() => setSelected('all')}
|
||||
onClose={() => {
|
||||
setSelected('all')
|
||||
onClose?.()
|
||||
}}
|
||||
nativeOptions={{
|
||||
preventExpansion: true,
|
||||
minHeight: screenHeight / 2,
|
||||
@@ -388,3 +392,25 @@ function ReactionTab({
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export function groupReactions(
|
||||
reactions: ChatBskyConvoDefs.ReactionView[] | undefined,
|
||||
): Reaction[] {
|
||||
const grouped = new Map<string, Reaction>()
|
||||
for (const reaction of reactions ?? []) {
|
||||
if (!reaction) continue
|
||||
const existing = grouped.get(reaction.value)
|
||||
if (existing) {
|
||||
existing.senders.push(reaction.sender)
|
||||
existing.count++
|
||||
} else {
|
||||
grouped.set(reaction.value, {
|
||||
key: reaction.value,
|
||||
value: reaction.value,
|
||||
senders: [reaction.sender],
|
||||
count: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(grouped.values())
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ interface GalleryProps {
|
||||
) => void
|
||||
onPressIn?: (index: number) => void
|
||||
viewContext?: PostEmbedViewContext
|
||||
isWithinQuote?: boolean
|
||||
}
|
||||
|
||||
const Context = createContext<{
|
||||
@@ -97,6 +98,7 @@ export function Gallery({
|
||||
onPress,
|
||||
onPressIn,
|
||||
viewContext,
|
||||
isWithinQuote,
|
||||
}: GalleryProps) {
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
@@ -104,14 +106,21 @@ export function Gallery({
|
||||
const largeAltBadge = useLargeAltBadgeEnabled()
|
||||
const bps = useBreakpoints()
|
||||
const window = useWindowDimensions()
|
||||
const isWithinQuote =
|
||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage
|
||||
const hideBadges = isWithinQuote
|
||||
const contentHeight = useMemo(() => {
|
||||
if (isWithinChat) {
|
||||
return 120
|
||||
}
|
||||
if (isWithinQuote) {
|
||||
if (bps.gtMobile) {
|
||||
return 220
|
||||
} else if (bps.gtPhone) {
|
||||
return 190
|
||||
} else {
|
||||
return 150
|
||||
}
|
||||
}
|
||||
if (bps.gtMobile) {
|
||||
return 300
|
||||
} else if (bps.gtPhone) {
|
||||
@@ -119,7 +128,7 @@ export function Gallery({
|
||||
} else {
|
||||
return 200
|
||||
}
|
||||
}, [bps, isWithinChat])
|
||||
}, [bps, isWithinChat, isWithinQuote])
|
||||
|
||||
/*
|
||||
* Container overflow styles
|
||||
@@ -220,7 +229,7 @@ export function Gallery({
|
||||
crop={
|
||||
viewContext === PostEmbedViewContext.ThreadHighlighted
|
||||
? 'none'
|
||||
: viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
: isWithinQuote
|
||||
? 'square'
|
||||
: 'constrained'
|
||||
}
|
||||
@@ -229,9 +238,7 @@ export function Gallery({
|
||||
onPress?.(index, [containerRef], [dims])
|
||||
}
|
||||
onPressIn={() => onPressIn?.(index)}
|
||||
hideBadge={
|
||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
}
|
||||
hideBadge={isWithinQuote}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AppBskyEmbedGallery,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
type AppBskyFeedDefs,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
type ModerationUI,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
|
||||
import {unique} from '#/lib/moderation'
|
||||
import {type AppModerationCause} from '#/components/Pills'
|
||||
import {Features, features} from '#/analytics/features'
|
||||
|
||||
@@ -75,9 +75,11 @@ export function ReportDialog(
|
||||
() => (props.subject ? parseReportSubject(props.subject) : undefined),
|
||||
[props.subject],
|
||||
)
|
||||
const propsOnClose = props.onClose
|
||||
const onClose = useCallback(() => {
|
||||
ax.metric('reportDialog:close', {})
|
||||
}, [ax])
|
||||
propsOnClose?.()
|
||||
}, [ax, propsOnClose])
|
||||
return (
|
||||
<Dialog.Outer control={props.control} onClose={onClose}>
|
||||
<Dialog.Handle />
|
||||
|
||||
@@ -88,4 +88,8 @@ export type ReportDialogProps = {
|
||||
* Called if the report was successfully submitted.
|
||||
*/
|
||||
onAfterSubmit?: () => void
|
||||
/**
|
||||
* Called after the dialog finishes closing.
|
||||
*/
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* Implementation backing `AppBskyEmbedGallery`. See gallery-embed-shim.ts.
|
||||
*/
|
||||
import {type AppBskyEmbedDefs, type BlobRef} from '@atproto/api'
|
||||
|
||||
export interface Main {
|
||||
$type?: 'app.bsky.embed.gallery'
|
||||
items: Image[]
|
||||
}
|
||||
|
||||
export interface Image {
|
||||
$type?: 'app.bsky.embed.gallery#image'
|
||||
image: BlobRef
|
||||
alt: string
|
||||
aspectRatio: AppBskyEmbedDefs.AspectRatio
|
||||
}
|
||||
|
||||
export interface View {
|
||||
$type?: 'app.bsky.embed.gallery#view'
|
||||
items: ViewImage[]
|
||||
}
|
||||
|
||||
export interface ViewImage {
|
||||
$type?: 'app.bsky.embed.gallery#viewImage'
|
||||
thumbnail: string
|
||||
fullsize: string
|
||||
alt: string
|
||||
aspectRatio: AppBskyEmbedDefs.AspectRatio
|
||||
}
|
||||
|
||||
export function isMain<V>(
|
||||
v: V,
|
||||
): v is V & Main & {$type: 'app.bsky.embed.gallery'} {
|
||||
return (
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
(v as {$type?: string}).$type === 'app.bsky.embed.gallery'
|
||||
)
|
||||
}
|
||||
|
||||
export function isImage<V>(
|
||||
v: V,
|
||||
): v is V & Image & {$type: 'app.bsky.embed.gallery#image'} {
|
||||
return (
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
(v as {$type?: string}).$type === 'app.bsky.embed.gallery#image'
|
||||
)
|
||||
}
|
||||
|
||||
export function isView<V>(
|
||||
v: V,
|
||||
): v is V & View & {$type: 'app.bsky.embed.gallery#view'} {
|
||||
return (
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
(v as {$type?: string}).$type === 'app.bsky.embed.gallery#view'
|
||||
)
|
||||
}
|
||||
|
||||
export function isViewImage<V>(
|
||||
v: V,
|
||||
): v is V & ViewImage & {$type: 'app.bsky.embed.gallery#viewImage'} {
|
||||
return (
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
(v as {$type?: string}).$type === 'app.bsky.embed.gallery#viewImage'
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Local shim for `app.bsky.embed.gallery` until @atproto/api ships the
|
||||
* generated types. Mirrors the shape from atproto PR #4827:
|
||||
* https://github.com/bluesky-social/atproto/pull/4827
|
||||
*
|
||||
* Once the lexicon ships and we bump @atproto/api, delete this file and
|
||||
* replace `import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'`
|
||||
* with `import {AppBskyEmbedGallery} from '@atproto/api'`.
|
||||
*/
|
||||
import * as gallery from './gallery-embed-shim.impl'
|
||||
|
||||
export {gallery as AppBskyEmbedGallery}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
type $Typed,
|
||||
type AppBskyEmbedExternal,
|
||||
type AppBskyEmbedGallery,
|
||||
type AppBskyEmbedImages,
|
||||
type AppBskyEmbedRecord,
|
||||
type AppBskyEmbedRecordWithMedia,
|
||||
@@ -21,7 +22,6 @@ import {sha256} from 'js-sha256'
|
||||
import {CID} from 'multiformats/cid'
|
||||
import * as Hasher from 'multiformats/hashes/hasher'
|
||||
|
||||
import {type AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
import {logger} from '#/logger'
|
||||
@@ -347,14 +347,14 @@ async function resolveMedia(
|
||||
count: imagesDraft.length,
|
||||
})
|
||||
onStateChange?.(t`Uploading images...`)
|
||||
const items: AppBskyEmbedGallery.Image[] = await Promise.all(
|
||||
const items: $Typed<AppBskyEmbedGallery.Image>[] = await Promise.all(
|
||||
imagesDraft.map(async (image, i) => {
|
||||
logger.debug(`Compressing gallery image #${i}`)
|
||||
const {path, width, height, mime} = await compressImage(image)
|
||||
logger.debug(`Uploading gallery image #${i}`)
|
||||
const res = await uploadBlob(agent, path, mime)
|
||||
return {
|
||||
$type: 'app.bsky.embed.gallery#image',
|
||||
$type: 'app.bsky.embed.gallery#image' as const,
|
||||
image: res.data.blob,
|
||||
alt: image.alt,
|
||||
aspectRatio: {width, height},
|
||||
|
||||
@@ -58,12 +58,12 @@ export function dateDiff(
|
||||
if (diffSeconds < NOW) {
|
||||
diff = {
|
||||
value: 0,
|
||||
unit: 'now' as DateDiff['unit'],
|
||||
unit: 'now',
|
||||
}
|
||||
} else if (diffSeconds < MINUTE) {
|
||||
diff = {
|
||||
value: diffSeconds,
|
||||
unit: 'second' as DateDiff['unit'],
|
||||
unit: 'second',
|
||||
}
|
||||
} else if (diffSeconds < HOUR) {
|
||||
const value =
|
||||
@@ -72,7 +72,7 @@ export function dateDiff(
|
||||
: Math.floor(diffSeconds / MINUTE)
|
||||
diff = {
|
||||
value,
|
||||
unit: 'minute' as DateDiff['unit'],
|
||||
unit: 'minute',
|
||||
}
|
||||
} else if (diffSeconds < DAY) {
|
||||
const value =
|
||||
@@ -81,7 +81,7 @@ export function dateDiff(
|
||||
: Math.floor(diffSeconds / HOUR)
|
||||
diff = {
|
||||
value,
|
||||
unit: 'hour' as DateDiff['unit'],
|
||||
unit: 'hour',
|
||||
}
|
||||
} else if (diffSeconds < MONTH_30) {
|
||||
const value =
|
||||
@@ -90,7 +90,7 @@ export function dateDiff(
|
||||
: Math.floor(diffSeconds / DAY)
|
||||
diff = {
|
||||
value,
|
||||
unit: 'day' as DateDiff['unit'],
|
||||
unit: 'day',
|
||||
}
|
||||
} else {
|
||||
const value =
|
||||
@@ -99,7 +99,7 @@ export function dateDiff(
|
||||
: Math.floor(diffSeconds / MONTH_30)
|
||||
diff = {
|
||||
value,
|
||||
unit: 'month' as DateDiff['unit'],
|
||||
unit: 'month',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ export function makeProfileLink(
|
||||
export function makeCustomFeedLink(
|
||||
did: string,
|
||||
rkey: string,
|
||||
segment?: string | undefined,
|
||||
feedCacheKey?: 'discover' | 'explore' | undefined,
|
||||
segment?: string,
|
||||
feedCacheKey?: 'discover' | 'explore',
|
||||
) {
|
||||
return (
|
||||
[`/profile`, did, 'feed', rkey, ...(segment ? [segment] : [])].join('/') +
|
||||
|
||||
@@ -56,6 +56,7 @@ import {MessageListError} from '#/screens/Messages/components/MessageListError'
|
||||
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
|
||||
import {DateDivider} from '#/components/dms/DateDivider'
|
||||
import {MessageItem} from '#/components/dms/MessageItem'
|
||||
import {MessageOverlays} from '#/components/dms/MessageOverlays'
|
||||
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
||||
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
|
||||
import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
|
||||
@@ -498,127 +499,133 @@ export function MessagesList({
|
||||
|
||||
return (
|
||||
<InviteLinkDialogProvider convo={convoState.convo}>
|
||||
<KeyboardGestureArea
|
||||
interpolator="ios"
|
||||
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
|
||||
offset={Math.round(inputHeightJS)}
|
||||
// slightly too buggy unfortunately, enable when possible
|
||||
// textInputNativeID={textInputId}
|
||||
style={[a.flex_1]}>
|
||||
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
|
||||
<Animated.View style={[a.flex_1, animatedListStyle]}>
|
||||
<ScrollProvider onScroll={onScroll}>
|
||||
<List
|
||||
ref={flatListRef}
|
||||
data={renderItems}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
disableFullWindowScroll={true}
|
||||
disableVirtualization={true}
|
||||
// The extra two items account for the header and the footer components
|
||||
initialNumToRender={IS_NATIVE ? 32 : 62}
|
||||
maxToRenderPerBatch={IS_WEB ? 32 : 62}
|
||||
keyboardDismissMode="interactive"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
maintainVisibleContentPosition={{minIndexForVisible: 0}}
|
||||
removeClippedSubviews={false}
|
||||
sideBorders={false}
|
||||
onContentSizeChange={onContentSizeChange}
|
||||
onStartReached={onStartReached}
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
showsVerticalScrollIndicator={!IS_ANDROID}
|
||||
scrollEventThrottle={100}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<MaybeLoader isLoading={convoState.isFetchingHistory} />
|
||||
{convoState.hasAllHistory ? (
|
||||
convoState.convo?.kind === 'group' ? (
|
||||
<MessagesListGroupInfoPanel convo={convoState.convo} />
|
||||
) : (
|
||||
<MessagesListInfoPanel convo={convoState.convo} />
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
// native only (prop is not supported on web)
|
||||
renderScrollComponent={renderScrollComponent}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: platform({
|
||||
// ios is slightly larger as the input has no top padding
|
||||
ios: tokens.space.lg,
|
||||
android: tokens.space.md,
|
||||
web: 0, // web uses ListFooterComponent instead for scroll reasons
|
||||
}),
|
||||
}}
|
||||
ListFooterComponent={
|
||||
<View
|
||||
style={web({height: tokens.space.md + inputHeightJS})}
|
||||
onLayout={onFooterLayout}
|
||||
/>
|
||||
}
|
||||
style={[
|
||||
web({
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
||||
scrollbarGutter: 'stable',
|
||||
}),
|
||||
]}
|
||||
pointerEvents={!hasScrolled ? 'none' : 'auto'}
|
||||
contentInset={{top: transparentHeaderHeight}}
|
||||
scrollIndicatorInsets={{top: transparentHeaderHeight}}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
</Animated.View>
|
||||
<KeyboardStickyView
|
||||
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
|
||||
onLayout={onInputLayout}
|
||||
minimumOffset={bottomInset}
|
||||
offset={{
|
||||
closed: platform({
|
||||
ios: tokens.space.lg, // hide bottom padding when closed
|
||||
default: 0,
|
||||
}),
|
||||
opened: 0,
|
||||
}}>
|
||||
{footer ?? (
|
||||
<ConversationFooter
|
||||
convoState={convoState}
|
||||
hasAcceptOverride={hasAcceptOverride}>
|
||||
{({loading}) =>
|
||||
ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
|
||||
<MessageComposer
|
||||
textInputId={textInputId}
|
||||
onSendMessage={(message: string) =>
|
||||
void onSendMessage(message)
|
||||
}
|
||||
hasEmbed={!!embedUri}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embedUri={embedUri}
|
||||
<MessageOverlays>
|
||||
<KeyboardGestureArea
|
||||
interpolator="ios"
|
||||
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
|
||||
offset={Math.round(inputHeightJS)}
|
||||
// slightly too buggy unfortunately, enable when possible
|
||||
// textInputNativeID={textInputId}
|
||||
style={[a.flex_1]}>
|
||||
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
|
||||
<Animated.View style={[a.flex_1, animatedListStyle]}>
|
||||
<ScrollProvider onScroll={onScroll}>
|
||||
<List
|
||||
ref={flatListRef}
|
||||
data={renderItems}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
disableFullWindowScroll={true}
|
||||
disableVirtualization={true}
|
||||
// The extra two items account for the header and the footer components
|
||||
initialNumToRender={IS_NATIVE ? 32 : 62}
|
||||
maxToRenderPerBatch={IS_WEB ? 32 : 62}
|
||||
keyboardDismissMode="interactive"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
maintainVisibleContentPosition={{minIndexForVisible: 0}}
|
||||
removeClippedSubviews={false}
|
||||
sideBorders={false}
|
||||
onContentSizeChange={onContentSizeChange}
|
||||
onStartReached={onStartReached}
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
showsVerticalScrollIndicator={!IS_ANDROID}
|
||||
scrollEventThrottle={100}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<MaybeLoader isLoading={convoState.isFetchingHistory} />
|
||||
{convoState.hasAllHistory ? (
|
||||
convoState.convo?.kind === 'group' ? (
|
||||
<MessagesListGroupInfoPanel convo={convoState.convo} />
|
||||
) : (
|
||||
<MessagesListInfoPanel convo={convoState.convo} />
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
// native only (prop is not supported on web)
|
||||
renderScrollComponent={renderScrollComponent}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: platform({
|
||||
// ios is slightly larger as the input has no top padding
|
||||
ios: tokens.space.lg,
|
||||
android: tokens.space.md,
|
||||
web: 0, // web uses ListFooterComponent instead for scroll reasons
|
||||
}),
|
||||
}}
|
||||
ListFooterComponent={
|
||||
<View
|
||||
style={web({height: tokens.space.md + inputHeightJS})}
|
||||
onLayout={onFooterLayout}
|
||||
/>
|
||||
}
|
||||
style={[
|
||||
web({
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
||||
scrollbarGutter: 'stable',
|
||||
}),
|
||||
]}
|
||||
pointerEvents={!hasScrolled ? 'none' : 'auto'}
|
||||
contentInset={{top: transparentHeaderHeight}}
|
||||
scrollIndicatorInsets={{top: transparentHeaderHeight}}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
</Animated.View>
|
||||
<KeyboardStickyView
|
||||
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
|
||||
onLayout={onInputLayout}
|
||||
minimumOffset={bottomInset}
|
||||
offset={{
|
||||
closed: platform({
|
||||
ios: tokens.space.lg, // hide bottom padding when closed
|
||||
default: 0,
|
||||
}),
|
||||
opened: 0,
|
||||
}}>
|
||||
{footer ?? (
|
||||
<ConversationFooter
|
||||
convoState={convoState}
|
||||
hasAcceptOverride={hasAcceptOverride}>
|
||||
{({loading}) =>
|
||||
ax.features.enabled(
|
||||
ax.features.DmsNewMessageComposerEnable,
|
||||
) ? (
|
||||
<MessageComposer
|
||||
textInputId={textInputId}
|
||||
onSendMessage={(message: string) =>
|
||||
void onSendMessage(message)
|
||||
}
|
||||
hasEmbed={!!embedUri}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageComposer>
|
||||
) : (
|
||||
<MessageInput
|
||||
textInputId={textInputId}
|
||||
onSendMessage={onSendMessage}
|
||||
hasEmbed={!!embedUri}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embedUri={embedUri}
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embedUri={embedUri}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageComposer>
|
||||
) : (
|
||||
<MessageInput
|
||||
textInputId={textInputId}
|
||||
onSendMessage={onSendMessage}
|
||||
hasEmbed={!!embedUri}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageInput>
|
||||
)
|
||||
}
|
||||
</ConversationFooter>
|
||||
)}
|
||||
</KeyboardStickyView>
|
||||
</KeyboardGestureArea>
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embedUri={embedUri}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageInput>
|
||||
)
|
||||
}
|
||||
</ConversationFooter>
|
||||
)}
|
||||
</KeyboardStickyView>
|
||||
</KeyboardGestureArea>
|
||||
|
||||
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
|
||||
{newMessagesPill.show && (
|
||||
<NewMessagesPill onPress={scrollToEndOnPress} />
|
||||
)}
|
||||
</MessageOverlays>
|
||||
</InviteLinkDialogProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ export function RequestListItem({
|
||||
const isDeletedAccount =
|
||||
!convo.primaryMember || convo.primaryMember.handle === 'missing.invalid'
|
||||
|
||||
const canAcceptRequest =
|
||||
convo.kind === 'direct' || convo.details.lockStatus === 'unlocked'
|
||||
|
||||
return (
|
||||
<View style={[a.relative, a.flex_1]}>
|
||||
<ChatListItem convo={convo.view} showMenu={false}>
|
||||
@@ -65,7 +68,9 @@ export function RequestListItem({
|
||||
]}>
|
||||
{convo.primaryMember && !isDeletedAccount ? (
|
||||
<>
|
||||
<AcceptChatButton convo={convo.view} currentScreen="list" />
|
||||
{canAcceptRequest ? (
|
||||
<AcceptChatButton convo={convo.view} currentScreen="list" />
|
||||
) : null}
|
||||
<RejectMenu
|
||||
convo={convo.view}
|
||||
profile={convo.primaryMember}
|
||||
|
||||
@@ -129,7 +129,7 @@ function getAppIconName(icon: string | false): DynamicAppIcon.IconName {
|
||||
if (!icon || icon === 'DEFAULT') {
|
||||
return 'default_light'
|
||||
} else {
|
||||
return icon as DynamicAppIcon.IconName
|
||||
return icon
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ function Group({
|
||||
values={[value]}
|
||||
maxSelections={1}
|
||||
onChange={vals => {
|
||||
if (vals[0]) onChange(vals[0] as DynamicAppIcon.IconName)
|
||||
if (vals[0]) onChange(vals[0])
|
||||
}}>
|
||||
<View style={[a.flex_1, a.rounded_md, a.overflow_hidden]}>
|
||||
{children}
|
||||
|
||||
@@ -23,8 +23,7 @@ import {
|
||||
import {useEventListener} from 'expo'
|
||||
import {type VideoPlayer} from 'expo-video'
|
||||
|
||||
import {tokens} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {atoms as a, tokens} from '#/alf'
|
||||
import {formatTime} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
|
||||
@@ -298,7 +298,7 @@ export function* findAllPostsInQueryData(
|
||||
if (AppBskyFeedDefs.isPostView(item.subject)) {
|
||||
const quotedPost = getEmbeddedPost(item.subject?.embed)
|
||||
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
|
||||
yield embedViewRecordToPostView(quotedPost!)
|
||||
yield embedViewRecordToPostView(quotedPost)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function parseAppNux(nux: AppBskyActorDefs.Nux): AppNux | undefined {
|
||||
|
||||
export function serializeAppNux(nux: AppNux): AppBskyActorDefs.Nux {
|
||||
const {data, ...rest} = nux
|
||||
const schema = NuxSchemas[nux.id as Nux]
|
||||
const schema = NuxSchemas[nux.id]
|
||||
|
||||
const result: AppBskyActorDefs.Nux = {
|
||||
...rest,
|
||||
|
||||
@@ -85,7 +85,7 @@ export function useSuggestedFollowsByActorWithDismiss({
|
||||
|
||||
const profiles = useMemo(() => {
|
||||
return (data?.suggestions ?? []).map(profile => ({
|
||||
actor: profile as bsky.profile.AnyProfileView,
|
||||
actor: profile,
|
||||
recId: data?.recId,
|
||||
}))
|
||||
}, [data?.suggestions, data?.recId])
|
||||
|
||||
@@ -196,7 +196,7 @@ export function sortAndAnnotateThreadItems(
|
||||
* `repliesSeenCounter` later on, since `repliesSeenCounter`
|
||||
* is 1-indexed and `replyIndex` is 0-indexed.
|
||||
*/
|
||||
childMetadata!.replyIndex =
|
||||
childMetadata.replyIndex =
|
||||
childParentMetadata.repliesSeenCounter
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
type $Typed,
|
||||
AppBskyEmbedExternal,
|
||||
AppBskyEmbedGallery,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
@@ -10,8 +11,6 @@ import {
|
||||
AppBskyLabelerDefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
|
||||
|
||||
export type Embed =
|
||||
| {
|
||||
type: 'post'
|
||||
|
||||
@@ -2,6 +2,7 @@ import {useCallback, useMemo, useState} from 'react'
|
||||
import {LayoutAnimation, Pressable, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {
|
||||
AppBskyEmbedGallery,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {type ComposerOptsPostRef} from '#/state/shell/composer'
|
||||
@@ -134,16 +134,19 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
}
|
||||
|
||||
function galleryItemsToImages(
|
||||
items: AppBskyEmbedGallery.ViewImage[],
|
||||
items: AppBskyEmbedGallery.View['items'],
|
||||
): AppBskyEmbedImages.ViewImage[] {
|
||||
// The reply-to thumbnail only renders up to 4 tiles; slicing here keeps
|
||||
// the existing layout switch valid for galleries up to 10 items.
|
||||
return items.slice(0, 4).map(item => ({
|
||||
thumb: item.thumbnail,
|
||||
fullsize: item.fullsize,
|
||||
alt: item.alt,
|
||||
aspectRatio: item.aspectRatio,
|
||||
}))
|
||||
return items
|
||||
.filter(AppBskyEmbedGallery.isViewImage)
|
||||
.slice(0, 4)
|
||||
.map(item => ({
|
||||
thumb: item.thumbnail,
|
||||
fullsize: item.fullsize,
|
||||
alt: item.alt,
|
||||
aspectRatio: item.aspectRatio,
|
||||
}))
|
||||
}
|
||||
|
||||
function ComposerReplyToImages({
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
/**
|
||||
* Type converters for Draft API - convert between ComposerState and server Draft types.
|
||||
*/
|
||||
import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api'
|
||||
import {AppBskyDraftDefs, AtUri, RichText} from '@atproto/api'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
// Shim: AppBskyDraftDefs.DraftPost gains an `embedGallery` field in atproto
|
||||
// PR #4827. Until @atproto/api ships those types, we widen the shape locally.
|
||||
// Delete this once the lexicon publishes.
|
||||
type DraftPostWithGallery = AppBskyDraftDefs.DraftPost & {
|
||||
embedGallery?: AppBskyDraftDefs.DraftEmbedImage[]
|
||||
}
|
||||
|
||||
import {resolveLink} from '#/lib/api/resolve'
|
||||
import {getDeviceName} from '#/lib/deviceName'
|
||||
import {getImageDim} from '#/lib/media/manip'
|
||||
@@ -123,10 +116,15 @@ async function postDraftToServerPost(
|
||||
localRefPaths,
|
||||
)
|
||||
} else if (post.embed.media.type === 'gallery') {
|
||||
;(draftPost as DraftPostWithGallery).embedGallery = serializeImages(
|
||||
post.embed.media.images,
|
||||
localRefPaths,
|
||||
)
|
||||
draftPost.embedGallery = {
|
||||
$type: 'app.bsky.draft.defs#draftEmbedGallery',
|
||||
items: serializeImages(post.embed.media.images, localRefPaths).map(
|
||||
img => ({
|
||||
$type: 'app.bsky.draft.defs#draftEmbedImage' as const,
|
||||
...img,
|
||||
}),
|
||||
),
|
||||
}
|
||||
} else if (post.embed.media.type === 'video') {
|
||||
const video = await serializeVideo(post.embed.media.video, localRefPaths)
|
||||
if (video) {
|
||||
@@ -326,11 +324,11 @@ async function restoreDraftImages(
|
||||
height,
|
||||
mime: 'image/jpeg',
|
||||
},
|
||||
} as ComposerImage
|
||||
} satisfies ComposerImage
|
||||
})
|
||||
|
||||
return (await Promise.all(imagePromises)).filter(
|
||||
(img): img is ComposerImage => img !== null,
|
||||
(img): img is NonNullable<typeof img> => img !== null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -380,18 +378,18 @@ export function draftViewToSummary({
|
||||
}
|
||||
|
||||
// Process gallery
|
||||
const summaryEmbedGallery = (post as DraftPostWithGallery).embedGallery
|
||||
if (summaryEmbedGallery) {
|
||||
for (const img of summaryEmbedGallery) {
|
||||
if (post.embedGallery) {
|
||||
for (const item of post.embedGallery.items) {
|
||||
if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
|
||||
meta.mediaCount++
|
||||
meta.hasMedia = true
|
||||
const exists = storage.mediaExists(img.localRef.path)
|
||||
const exists = storage.mediaExists(item.localRef.path)
|
||||
if (!exists) {
|
||||
meta.hasMissingMedia = true
|
||||
}
|
||||
images.push({
|
||||
localPath: img.localRef.path,
|
||||
altText: img.alt || '',
|
||||
localPath: item.localRef.path,
|
||||
altText: item.alt || '',
|
||||
exists,
|
||||
})
|
||||
}
|
||||
@@ -523,9 +521,11 @@ export async function draftToComposerPosts(
|
||||
}
|
||||
|
||||
// Restore gallery
|
||||
const embedGallery = (post as DraftPostWithGallery).embedGallery
|
||||
if (embedGallery && embedGallery.length > 0) {
|
||||
const images = await restoreDraftImages(embedGallery, loadedMedia)
|
||||
if (post.embedGallery && post.embedGallery.items.length > 0) {
|
||||
const galleryImages = post.embedGallery.items.filter(
|
||||
AppBskyDraftDefs.isDraftEmbedImage,
|
||||
)
|
||||
const images = await restoreDraftImages(galleryImages, loadedMedia)
|
||||
if (images.length > 0) {
|
||||
embed.media = {type: 'gallery', images}
|
||||
}
|
||||
@@ -561,7 +561,7 @@ export async function draftToComposerPosts(
|
||||
tinygif: mediaObject,
|
||||
preview: mediaObject,
|
||||
},
|
||||
} as Gif,
|
||||
},
|
||||
alt: gifData.alt,
|
||||
}
|
||||
break
|
||||
@@ -680,10 +680,10 @@ export function extractLocalRefs(draft: AppBskyDraftDefs.Draft): Set<string> {
|
||||
refs.add(img.localRef.path)
|
||||
}
|
||||
}
|
||||
const embedGallery = (post as DraftPostWithGallery).embedGallery
|
||||
if (embedGallery) {
|
||||
for (const img of embedGallery) {
|
||||
refs.add(img.localRef.path)
|
||||
if (post.embedGallery) {
|
||||
for (const item of post.embedGallery.items) {
|
||||
if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
|
||||
refs.add(item.localRef.path)
|
||||
}
|
||||
}
|
||||
if (post.embedVideos) {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {AppBskyDraftCreateDraft, type AppBskyDraftDefs} from '@atproto/api'
|
||||
|
||||
// Shim: AppBskyDraftDefs.DraftPost gains `embedGallery` in atproto PR #4827.
|
||||
// Delete once @atproto/api publishes the new lexicon.
|
||||
type DraftPostWithGallery = AppBskyDraftDefs.DraftPost & {
|
||||
embedGallery?: AppBskyDraftDefs.DraftEmbedImage[]
|
||||
}
|
||||
import {AppBskyDraftCreateDraft, AppBskyDraftDefs} from '@atproto/api'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
@@ -81,15 +75,15 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{
|
||||
}
|
||||
}
|
||||
// Load gallery
|
||||
const embedGallery = (post as DraftPostWithGallery).embedGallery
|
||||
if (embedGallery) {
|
||||
for (const img of embedGallery) {
|
||||
if (post.embedGallery) {
|
||||
for (const item of post.embedGallery.items) {
|
||||
if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
|
||||
try {
|
||||
const url = await storage.loadMediaFromLocal(img.localRef.path)
|
||||
loadedMedia.set(img.localRef.path, url)
|
||||
const url = await storage.loadMediaFromLocal(item.localRef.path)
|
||||
loadedMedia.set(item.localRef.path, url)
|
||||
} catch (e) {
|
||||
logger.error('Failed to load draft gallery image', {
|
||||
path: img.localRef.path,
|
||||
path: item.localRef.path,
|
||||
safeMessage: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
}
|
||||
@@ -247,10 +241,10 @@ export function useDeleteDraftMutation() {
|
||||
await storage.deleteMediaFromLocal(img.localRef.path)
|
||||
}
|
||||
}
|
||||
const embedGallery = (post as DraftPostWithGallery).embedGallery
|
||||
if (embedGallery) {
|
||||
for (const img of embedGallery) {
|
||||
await storage.deleteMediaFromLocal(img.localRef.path)
|
||||
if (post.embedGallery) {
|
||||
for (const item of post.embedGallery.items) {
|
||||
if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
|
||||
await storage.deleteMediaFromLocal(item.localRef.path)
|
||||
}
|
||||
}
|
||||
if (post.embedVideos) {
|
||||
|
||||
@@ -55,7 +55,7 @@ export function TestCtrls() {
|
||||
accessibilityLabel="Text input field"
|
||||
accessibilityHint="Enter proxy header"
|
||||
testID="e2eProxyHeaderInput"
|
||||
onChangeText={val => setProxyHeader(val as any)}
|
||||
onChangeText={val => setProxyHeader(val)}
|
||||
autoComplete="off"
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
|
||||
@@ -3,9 +3,9 @@ import {
|
||||
Pressable,
|
||||
type PressableProps,
|
||||
type StyleProp,
|
||||
type View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {type View} from 'react-native'
|
||||
|
||||
import {addStyle} from '#/lib/styles'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
|
||||
@@ -4,6 +4,8 @@ import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||
|
||||
export function BlockDrawerGesture({children}: {children: React.ReactNode}) {
|
||||
const drawerGesture = useContext(DrawerGestureContext) ?? Gesture.Native() // noop for web
|
||||
const scrollGesture = Gesture.Native().blocksExternalGesture(drawerGesture)
|
||||
let scrollGesture = Gesture.Native()
|
||||
.shouldCancelWhenOutside(false) // for some reason defaults to true on Android
|
||||
.blocksExternalGesture(drawerGesture)
|
||||
return <GestureDetector gesture={scrollGesture}>{children}</GestureDetector>
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ function NativeStackNavigator({
|
||||
}
|
||||
|
||||
// Evicted screens get a lightweight placeholder instead of their full tree
|
||||
finalDescriptors = {} as typeof descriptors
|
||||
finalDescriptors = {}
|
||||
for (const key in descriptors) {
|
||||
if (mountSet.has(key)) {
|
||||
finalDescriptors[key] = descriptors[key]
|
||||
|
||||
@@ -174,8 +174,15 @@ function DrawerLayout({children}: {children: React.ReactNode}) {
|
||||
// so fail the drawer gesture immediately.
|
||||
.failOffsetX(-1)
|
||||
// Don't rush declaring that a movement to the right
|
||||
// is a drawer swipe. It could be a vertical scroll.
|
||||
.activeOffsetX(5)
|
||||
// is a drawer swipe. It could be a vertical scroll, or a
|
||||
// slow horizontal carousel swipe. On Android a child
|
||||
// `blocksExternalGesture` only holds the drawer off once the
|
||||
// native scroll has activated, which on a slow swipe doesn't
|
||||
// happen until movement crosses the native touch slop
|
||||
// (~8-16px). Activating the drawer below that lets a slow
|
||||
// carousel swipe pop the drawer open (APP-2119), so require
|
||||
// more travel before claiming on Android.
|
||||
.activeOffsetX(IS_ANDROID ? 20 : 5)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user