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:
vineyardbovines
2026-06-03 17:36:23 -04:00
42 changed files with 2428 additions and 535 deletions
@@ -71,18 +71,18 @@ jobs:
profile: ${{ inputs.channel || 'testflight' }} profile: ${{ inputs.channel || 'testflight' }}
previous-commit-tag: ${{ inputs.runtimeVersion }} previous-commit-tag: ${{ inputs.runtimeVersion }}
- name: Lint check
run: pnpm lint
- name: Prettier check
run: pnpm prettier --check .
- name: 🔤 Compile translations - name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors - 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 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 - name: Type check
run: pnpm typecheck run: pnpm typecheck
+3 -23
View File
@@ -21,7 +21,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
job: [lint, prettier] job: [lint, prettier, typecheck]
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -62,6 +62,8 @@ jobs:
command: pnpm install --frozen-lockfile command: pnpm install --frozen-lockfile
attempt_limit: 3 attempt_limit: 3
attempt_delay: 2000 attempt_delay: 2000
- name: Check & compile i18n
run: pnpm intl:build
- name: Lint checks - name: Lint checks
run: pnpm ${{ matrix.job }} run: pnpm ${{ matrix.job }}
# Aggregates the matrix results into a single stable check name so branch # Aggregates the matrix results into a single stable check name so branch
@@ -80,28 +82,6 @@ jobs:
run: | run: |
echo "linting result: $RESULT" echo "linting result: $RESULT"
test "$RESULT" = "success" 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: testing:
name: Run tests name: Run tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
+3 -3
View File
@@ -128,15 +128,15 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Lint check
run: pnpm lint
- name: 🔤 Compile translations - name: 🔤 Compile translations
run: pnpm intl:build 2>&1 | tee i18n.log run: pnpm intl:build 2>&1 | tee i18n.log
- name: Check for i18n compilation errors - 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 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 - name: Type check
run: pnpm typecheck run: pnpm typecheck
+1833 -1
View File
File diff suppressed because it is too large Load Diff
+18 -20
View File
@@ -134,11 +134,10 @@ export default defineConfig(
'react-native/no-inline-styles': 'off', 'react-native/no-inline-styles': 'off',
...reactNativeA11y.configs.all.rules, ...reactNativeA11y.configs.all.rules,
'react-compiler/react-compiler': 'warn', 'react-compiler/react-compiler': 'warn',
// TODO: Fix these and set to error 'react-hooks/set-state-in-effect': 'error',
'react-hooks/set-state-in-effect': 'warn', 'react-hooks/purity': 'error',
'react-hooks/purity': 'warn', 'react-hooks/refs': 'error',
'react-hooks/refs': 'warn', 'react-hooks/immutability': 'error',
'react-hooks/immutability': 'warn',
/** /**
* Import sorting * Import sorting
@@ -235,9 +234,8 @@ export default defineConfig(
}, },
], ],
/** /**
* Maintain previous behavior - these are stricter in typescript-eslint * Maintain previous behavior via eslint-suppressions.json - these are
* v8 `warn` ones are probably worth fixing. `off` ones are a bit too * stricter in typescript-eslint v8. `off` ones are a bit too nit-picky.
* nit-picky
*/ */
'@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/ban-ts-comment': 'off',
@@ -247,18 +245,18 @@ export default defineConfig(
'@typescript-eslint/unbound-method': 'off', '@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-member-access': 'warn', '@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-call': 'warn', '@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'warn', '@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/require-await': 'warn', '@typescript-eslint/require-await': 'error',
'@typescript-eslint/no-unsafe-enum-comparison': 'warn', '@typescript-eslint/no-unsafe-enum-comparison': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'warn', '@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/no-redundant-type-constituents': 'warn', '@typescript-eslint/no-redundant-type-constituents': 'error',
'@typescript-eslint/no-duplicate-type-constituents': 'warn', '@typescript-eslint/no-duplicate-type-constituents': 'error',
'@typescript-eslint/no-base-to-string': 'warn', '@typescript-eslint/no-base-to-string': 'error',
'@typescript-eslint/prefer-promise-reject-errors': 'warn', '@typescript-eslint/prefer-promise-reject-errors': 'error',
'@typescript-eslint/await-thenable': 'warn', '@typescript-eslint/await-thenable': 'error',
'no-restricted-imports': [ 'no-restricted-imports': [
'error', 'error',
+1 -1
View File
@@ -93,7 +93,7 @@
"prettier": "prettier --check ." "prettier": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.20.8", "@atproto/api": "0.20.9",
"@atproto/syntax": "0.6.1", "@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
+5 -5
View File
@@ -242,8 +242,8 @@ importers:
.: .:
dependencies: dependencies:
'@atproto/api': '@atproto/api':
specifier: 0.20.8 specifier: 0.20.9
version: 0.20.8 version: 0.20.9
'@atproto/syntax': '@atproto/syntax':
specifier: 0.6.1 specifier: 0.6.1
version: 0.6.1 version: 0.6.1
@@ -877,8 +877,8 @@ packages:
graphql: graphql:
optional: true optional: true
'@atproto/api@0.20.8': '@atproto/api@0.20.9':
resolution: {integrity: sha512-rTkA6kOmA2axSrg6VgpdXpsCFWpofnHBOn6pKg69Ju5MpIHqk4haQMgjBcVh1G3kUxzwgSAr7SYrPS3dFe5Etg==} resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/common-web@0.5.0': '@atproto/common-web@0.5.0':
@@ -9493,7 +9493,7 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {} '@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.8': '@atproto/api@0.20.9':
dependencies: dependencies:
'@atproto/common-web': 0.5.0 '@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1 '@atproto/lexicon': 0.7.1
+9 -2
View File
@@ -1,6 +1,10 @@
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native' import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image' 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 {Trans, useLingui} from '@lingui/react/macro'
import {shareImageModal} from '#/lib/media/manip' import {shareImageModal} from '#/lib/media/manip'
@@ -52,7 +56,10 @@ export function Embed({
// a 10-image gallery doesn't blow out the row width. // a 10-image gallery doesn't blow out the row width.
return ( return (
<Outer style={style}> <Outer style={style}>
{e.view.items.slice(0, 4).map(item => { {e.view.items
.filter(AppBskyEmbedGallery.isViewImage)
.slice(0, 4)
.map(item => {
const image: AppBskyEmbedImages.ViewImage = { const image: AppBskyEmbedImages.ViewImage = {
thumb: item.thumbnail, thumb: item.thumbnail,
fullsize: item.fullsize, fullsize: item.fullsize,
+5 -8
View File
@@ -2,7 +2,7 @@ import {useRef} from 'react'
import {InteractionManager, View} from 'react-native' import {InteractionManager, View} from 'react-native'
import {type AnimatedRef} from 'react-native-reanimated' import {type AnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image' 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 {atoms as a, tokens} from '#/alf'
import {AutoSizedImage} from '#/components/images/AutoSizedImage' import {AutoSizedImage} from '#/components/images/AutoSizedImage'
@@ -28,7 +28,7 @@ export function ImageEmbed({
const {openLightbox} = useLightboxControls() const {openLightbox} = useLightboxControls()
const images: AppBskyEmbedImages.ViewImage[] = const images: AppBskyEmbedImages.ViewImage[] =
embed.type === 'gallery' embed.type === 'gallery'
? embed.view.items.map(item => ({ ? embed.view.items.filter(AppBskyEmbedGallery.isViewImage).map(item => ({
thumb: item.thumbnail, thumb: item.thumbnail,
fullsize: item.fullsize, fullsize: item.fullsize,
alt: item.alt, alt: item.alt,
@@ -101,8 +101,7 @@ export function ImageEmbed({
crop={ crop={
rest.viewContext === PostEmbedViewContext.ThreadHighlighted rest.viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none' ? 'none'
: rest.viewContext === : rest.isWithinQuote
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square' ? 'square'
: 'constrained' : 'constrained'
} }
@@ -117,10 +116,7 @@ export function ImageEmbed({
onPress(0, [containerRef], [dims]) onPress(0, [containerRef], [dims])
} }
onPressIn={() => onPressIn(0)} onPressIn={() => onPressIn(0)}
hideBadge={ hideBadge={rest.isWithinQuote}
rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
}
/> />
</ImageContextMenu> </ImageContextMenu>
</View> </View>
@@ -135,6 +131,7 @@ export function ImageEmbed({
onPress={onPress} onPress={onPress}
onPressIn={onPressIn} onPressIn={onPressIn}
viewContext={rest.viewContext} viewContext={rest.viewContext}
isWithinQuote={rest.isWithinQuote}
/> />
</View> </View>
) )
+3 -1
View File
@@ -224,6 +224,7 @@ export function Basic({
cancelButtonCta, cancelButtonCta,
confirmButtonCta, confirmButtonCta,
onConfirm, onConfirm,
onClose,
confirmButtonColor, confirmButtonColor,
showCancel = true, showCancel = true,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
@@ -240,11 +241,12 @@ export function Basic({
* should NOT close the dialog as a side effect of this method. * should NOT close the dialog as a side effect of this method.
*/ */
onConfirm: (e: GestureResponderEvent) => void onConfirm: (e: GestureResponderEvent) => void
onClose?: () => void
confirmButtonColor?: ButtonColor confirmButtonColor?: ButtonColor
showCancel?: boolean showCancel?: boolean
}>) { }>) {
return ( return (
<Outer control={control} testID="confirmModal"> <Outer control={control} testID="confirmModal" onClose={onClose}>
<Content> <Content>
<TitleText>{title}</TitleText> <TitleText>{title}</TitleText>
{description && <DescriptionText>{description}</DescriptionText>} {description && <DescriptionText>{description}</DescriptionText>}
+4 -2
View File
@@ -8,8 +8,10 @@ import {
type UninheritableButtonProps, type UninheritableButtonProps,
} from '#/components/Button' } from '#/components/Button'
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck' import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' 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 {type Props as SVGIconProps} from '#/components/icons/common'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {dismiss} from '#/components/Toast/sonner' import {dismiss} from '#/components/Toast/sonner'
+6 -1
View File
@@ -33,14 +33,19 @@ export const AfterReportDialog = memo(function BlockOrDeleteDialogInner({
control, control,
params, params,
currentScreen, currentScreen,
onClose,
}: { }: {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
params: ReportDialogParams params: ReportDialogParams
currentScreen: 'list' | 'conversation' currentScreen: 'list' | 'conversation'
onClose?: () => void
}): React.ReactNode { }): React.ReactNode {
const {t: l} = useLingui() const {t: l} = useLingui()
return ( return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> <Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner <Dialog.ScrollableInner
label={l`Would you like to block this user and/or delete this conversation?`} label={l`Would you like to block this user and/or delete this conversation?`}
+5 -53
View File
@@ -1,5 +1,5 @@
import {memo, useCallback} from 'react' import {memo, useCallback} from 'react'
import {LayoutAnimation, Platform} from 'react-native' import {Platform} from 'react-native'
import * as Clipboard from 'expo-clipboard' import * as Clipboard from 'expo-clipboard'
import { import {
type ChatBskyConvoDefs, type ChatBskyConvoDefs,
@@ -7,26 +7,21 @@ import {
RichText, RichText,
} from '@atproto/api' } from '@atproto/api'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {richTextToString} from '#/lib/strings/rich-text-helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {useConvoActive} from '#/state/messages/convo' import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import * as ContextMenu from '#/components/ContextMenu' import * as ContextMenu from '#/components/ContextMenu'
import {type TriggerProps} from '#/components/ContextMenu/types' 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 {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language' import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 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 * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
@@ -48,11 +43,8 @@ export let MessageContextMenu = ({
const {t: l, i18n} = useLingui() const {t: l, i18n} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const queryClient = useQueryClient()
const convo = useConvoActive() const convo = useConvoActive()
const deleteControl = usePromptControl() const {openDeleteMessage, openReportMessage} = useMessageDialogs()
const reportControl = usePromptControl()
const blockOrDeleteControl = usePromptControl()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const translate = useGoogleTranslate() const translate = useGoogleTranslate()
@@ -93,14 +85,6 @@ export let MessageContextMenu = ({
}) })
}, [ax, langPrefs.primaryLanguage, message.text, translate]) }, [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( const onEmojiSelect = useCallback(
(emoji: string) => { (emoji: string) => {
if ( if (
@@ -128,7 +112,6 @@ export let MessageContextMenu = ({
const sender = senderProfile const sender = senderProfile
return ( return (
<>
<ContextMenu.Root> <ContextMenu.Root>
{IS_NATIVE && reactionsAvailable && ( {IS_NATIVE && reactionsAvailable && (
<ContextMenu.AuxiliaryView <ContextMenu.AuxiliaryView
@@ -179,7 +162,7 @@ export let MessageContextMenu = ({
destructive destructive
testID="messageDropdownDeleteBtn" testID="messageDropdownDeleteBtn"
label={l`Delete message for me`} label={l`Delete message for me`}
onPress={() => deleteControl.open()}> onPress={() => openDeleteMessage(message)}>
<ContextMenu.ItemIcon icon={TrashIcon} position="left" /> <ContextMenu.ItemIcon icon={TrashIcon} position="left" />
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText> <ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
</ContextMenu.Item> </ContextMenu.Item>
@@ -188,44 +171,13 @@ export let MessageContextMenu = ({
destructive destructive
testID="messageDropdownReportBtn" testID="messageDropdownReportBtn"
label={l`Report message`} label={l`Report message`}
onPress={() => reportControl.open()}> onPress={() => openReportMessage(message, senderProfile)}>
<ContextMenu.ItemIcon icon={FlagIcon} position="left" /> <ContextMenu.ItemIcon icon={FlagIcon} position="left" />
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText> <ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
</ContextMenu.Item> </ContextMenu.Item>
)} )}
</ContextMenu.Outer> </ContextMenu.Outer>
</ContextMenu.Root> </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}
/>
</>
) )
} }
MessageContextMenu = memo(MessageContextMenu) MessageContextMenu = memo(MessageContextMenu)
+8 -39
View File
@@ -40,8 +40,8 @@ import {useSession} from '#/state/session'
import {atoms as a, native, platform, useTheme} from '#/alf' import {atoms as a, native, platform, useTheme} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography' import {isOnlyEmoji} from '#/alf/typography'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {InlineLinkText, Link} from '#/components/Link' import {InlineLinkText, Link} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
@@ -49,7 +49,7 @@ import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {DateDivider} from './DateDivider' import {DateDivider} from './DateDivider'
import {MessageItemEmbed} from './MessageItemEmbed' import {MessageItemEmbed} from './MessageItemEmbed'
import {ReactionsDialog} from './ReactionsDialog' import {groupReactions} from './ReactionsDialog'
import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util' import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util'
const AVATAR_SIZE = 28 const AVATAR_SIZE = 28
@@ -118,7 +118,7 @@ let MessageItem = ({
const {message} = item const {message} = item
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did)) const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
const reactionsControl = useDialogControl() const {openReactions} = useMessageDialogs()
const isPending = item.type === 'pending-message' const isPending = item.type === 'pending-message'
@@ -243,34 +243,10 @@ let MessageItem = ({
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} /> <ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
) )
const groupedReactions = useMemo(() => { const groupedReactions = useMemo(
const reactions = message.reactions ?? [] () => groupReactions(message.reactions),
const grouped = new Map< [message.reactions],
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 reactions = useMemo(() => message.reactions ?? [], [message.reactions]) const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
@@ -336,7 +312,7 @@ let MessageItem = ({
transform: [{translateY: -8}], transform: [{translateY: -8}],
}, },
]} ]}
onPress={isGroupChat ? reactionsControl.open : undefined}> onPress={isGroupChat ? () => openReactions(message) : undefined}>
{groupedReactions.map(group => ( {groupedReactions.map(group => (
<Animated.View <Animated.View
entering={native(ZoomIn.springify(200).delay(400))} entering={native(ZoomIn.springify(200).delay(400))}
@@ -377,13 +353,6 @@ let MessageItem = ({
</Pressable> </Pressable>
</View> </View>
) : null} ) : null}
<ReactionsDialog
control={reactionsControl}
relatedProfiles={relatedProfiles}
message={message}
reactions={message.reactions}
groupedReactions={groupedReactions}
/>
</LayoutAnimationConfig> </LayoutAnimationConfig>
) )
+175
View File
@@ -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>
)
}
+32 -6
View File
@@ -1,4 +1,4 @@
import {useRef, useState} from 'react' import {useMemo, useRef, useState} from 'react'
import { import {
LayoutAnimation, LayoutAnimation,
Pressable, Pressable,
@@ -37,14 +37,12 @@ export function ReactionsDialog({
control, control,
relatedProfiles, relatedProfiles,
message, message,
reactions, onClose,
groupedReactions,
}: { }: {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
reactions?: ChatBskyConvoDefs.ReactionView[] onClose?: () => void
groupedReactions?: Reaction[]
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -54,6 +52,9 @@ export function ReactionsDialog({
const [selected, setSelected] = useState('all') const [selected, setSelected] = useState('all')
const reactions = message.reactions
const groupedReactions = useMemo(() => groupReactions(reactions), [reactions])
const filteredReactions = reactions?.filter( const filteredReactions = reactions?.filter(
r => selected === 'all' || r.value === selected, r => selected === 'all' || r.value === selected,
) )
@@ -78,7 +79,10 @@ export function ReactionsDialog({
return ( return (
<Dialog.Outer <Dialog.Outer
control={control} control={control}
onClose={() => setSelected('all')} onClose={() => {
setSelected('all')
onClose?.()
}}
nativeOptions={{ nativeOptions={{
preventExpansion: true, preventExpansion: true,
minHeight: screenHeight / 2, minHeight: screenHeight / 2,
@@ -388,3 +392,25 @@ function ReactionTab({
</Pressable> </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())
}
+14 -7
View File
@@ -54,6 +54,7 @@ interface GalleryProps {
) => void ) => void
onPressIn?: (index: number) => void onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
} }
const Context = createContext<{ const Context = createContext<{
@@ -97,6 +98,7 @@ export function Gallery({
onPress, onPress,
onPressIn, onPressIn,
viewContext, viewContext,
isWithinQuote,
}: GalleryProps) { }: GalleryProps) {
const {t: l} = useLingui() const {t: l} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
@@ -104,14 +106,21 @@ export function Gallery({
const largeAltBadge = useLargeAltBadgeEnabled() const largeAltBadge = useLargeAltBadgeEnabled()
const bps = useBreakpoints() const bps = useBreakpoints()
const window = useWindowDimensions() const window = useWindowDimensions()
const isWithinQuote =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage
const hideBadges = isWithinQuote const hideBadges = isWithinQuote
const contentHeight = useMemo(() => { const contentHeight = useMemo(() => {
if (isWithinChat) { if (isWithinChat) {
return 120 return 120
} }
if (isWithinQuote) {
if (bps.gtMobile) {
return 220
} else if (bps.gtPhone) {
return 190
} else {
return 150
}
}
if (bps.gtMobile) { if (bps.gtMobile) {
return 300 return 300
} else if (bps.gtPhone) { } else if (bps.gtPhone) {
@@ -119,7 +128,7 @@ export function Gallery({
} else { } else {
return 200 return 200
} }
}, [bps, isWithinChat]) }, [bps, isWithinChat, isWithinQuote])
/* /*
* Container overflow styles * Container overflow styles
@@ -220,7 +229,7 @@ export function Gallery({
crop={ crop={
viewContext === PostEmbedViewContext.ThreadHighlighted viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none' ? 'none'
: viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia : isWithinQuote
? 'square' ? 'square'
: 'constrained' : 'constrained'
} }
@@ -229,9 +238,7 @@ export function Gallery({
onPress?.(index, [containerRef], [dims]) onPress?.(index, [containerRef], [dims])
} }
onPressIn={() => onPressIn?.(index)} onPressIn={() => onPressIn?.(index)}
hideBadge={ hideBadge={isWithinQuote}
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
}
/> />
))} ))}
</View> </View>
@@ -1,4 +1,5 @@
import { import {
AppBskyEmbedGallery,
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
type AppBskyFeedDefs, type AppBskyFeedDefs,
@@ -7,7 +8,6 @@ import {
type ModerationUI, type ModerationUI,
} from '@atproto/api' } from '@atproto/api'
import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
import {unique} from '#/lib/moderation' import {unique} from '#/lib/moderation'
import {type AppModerationCause} from '#/components/Pills' import {type AppModerationCause} from '#/components/Pills'
import {Features, features} from '#/analytics/features' import {Features, features} from '#/analytics/features'
@@ -75,9 +75,11 @@ export function ReportDialog(
() => (props.subject ? parseReportSubject(props.subject) : undefined), () => (props.subject ? parseReportSubject(props.subject) : undefined),
[props.subject], [props.subject],
) )
const propsOnClose = props.onClose
const onClose = useCallback(() => { const onClose = useCallback(() => {
ax.metric('reportDialog:close', {}) ax.metric('reportDialog:close', {})
}, [ax]) propsOnClose?.()
}, [ax, propsOnClose])
return ( return (
<Dialog.Outer control={props.control} onClose={onClose}> <Dialog.Outer control={props.control} onClose={onClose}>
<Dialog.Handle /> <Dialog.Handle />
@@ -88,4 +88,8 @@ export type ReportDialogProps = {
* Called if the report was successfully submitted. * Called if the report was successfully submitted.
*/ */
onAfterSubmit?: () => void onAfterSubmit?: () => void
/**
* Called after the dialog finishes closing.
*/
onClose?: () => void
} }
-69
View File
@@ -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'
)
}
-12
View File
@@ -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}
+3 -3
View File
@@ -1,6 +1,7 @@
import { import {
type $Typed, type $Typed,
type AppBskyEmbedExternal, type AppBskyEmbedExternal,
type AppBskyEmbedGallery,
type AppBskyEmbedImages, type AppBskyEmbedImages,
type AppBskyEmbedRecord, type AppBskyEmbedRecord,
type AppBskyEmbedRecordWithMedia, type AppBskyEmbedRecordWithMedia,
@@ -21,7 +22,6 @@ import {sha256} from 'js-sha256'
import {CID} from 'multiformats/cid' import {CID} from 'multiformats/cid'
import * as Hasher from 'multiformats/hashes/hasher' import * as Hasher from 'multiformats/hashes/hasher'
import {type AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
import {isNetworkError} from '#/lib/strings/errors' import {isNetworkError} from '#/lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {logger} from '#/logger' import {logger} from '#/logger'
@@ -347,14 +347,14 @@ async function resolveMedia(
count: imagesDraft.length, count: imagesDraft.length,
}) })
onStateChange?.(t`Uploading images...`) onStateChange?.(t`Uploading images...`)
const items: AppBskyEmbedGallery.Image[] = await Promise.all( const items: $Typed<AppBskyEmbedGallery.Image>[] = await Promise.all(
imagesDraft.map(async (image, i) => { imagesDraft.map(async (image, i) => {
logger.debug(`Compressing gallery image #${i}`) logger.debug(`Compressing gallery image #${i}`)
const {path, width, height, mime} = await compressImage(image) const {path, width, height, mime} = await compressImage(image)
logger.debug(`Uploading gallery image #${i}`) logger.debug(`Uploading gallery image #${i}`)
const res = await uploadBlob(agent, path, mime) const res = await uploadBlob(agent, path, mime)
return { return {
$type: 'app.bsky.embed.gallery#image', $type: 'app.bsky.embed.gallery#image' as const,
image: res.data.blob, image: res.data.blob,
alt: image.alt, alt: image.alt,
aspectRatio: {width, height}, aspectRatio: {width, height},
+6 -6
View File
@@ -58,12 +58,12 @@ export function dateDiff(
if (diffSeconds < NOW) { if (diffSeconds < NOW) {
diff = { diff = {
value: 0, value: 0,
unit: 'now' as DateDiff['unit'], unit: 'now',
} }
} else if (diffSeconds < MINUTE) { } else if (diffSeconds < MINUTE) {
diff = { diff = {
value: diffSeconds, value: diffSeconds,
unit: 'second' as DateDiff['unit'], unit: 'second',
} }
} else if (diffSeconds < HOUR) { } else if (diffSeconds < HOUR) {
const value = const value =
@@ -72,7 +72,7 @@ export function dateDiff(
: Math.floor(diffSeconds / MINUTE) : Math.floor(diffSeconds / MINUTE)
diff = { diff = {
value, value,
unit: 'minute' as DateDiff['unit'], unit: 'minute',
} }
} else if (diffSeconds < DAY) { } else if (diffSeconds < DAY) {
const value = const value =
@@ -81,7 +81,7 @@ export function dateDiff(
: Math.floor(diffSeconds / HOUR) : Math.floor(diffSeconds / HOUR)
diff = { diff = {
value, value,
unit: 'hour' as DateDiff['unit'], unit: 'hour',
} }
} else if (diffSeconds < MONTH_30) { } else if (diffSeconds < MONTH_30) {
const value = const value =
@@ -90,7 +90,7 @@ export function dateDiff(
: Math.floor(diffSeconds / DAY) : Math.floor(diffSeconds / DAY)
diff = { diff = {
value, value,
unit: 'day' as DateDiff['unit'], unit: 'day',
} }
} else { } else {
const value = const value =
@@ -99,7 +99,7 @@ export function dateDiff(
: Math.floor(diffSeconds / MONTH_30) : Math.floor(diffSeconds / MONTH_30)
diff = { diff = {
value, value,
unit: 'month' as DateDiff['unit'], unit: 'month',
} }
} }
+2 -2
View File
@@ -19,8 +19,8 @@ export function makeProfileLink(
export function makeCustomFeedLink( export function makeCustomFeedLink(
did: string, did: string,
rkey: string, rkey: string,
segment?: string | undefined, segment?: string,
feedCacheKey?: 'discover' | 'explore' | undefined, feedCacheKey?: 'discover' | 'explore',
) { ) {
return ( return (
[`/profile`, did, 'feed', rkey, ...(segment ? [segment] : [])].join('/') + [`/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 {atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {DateDivider} from '#/components/dms/DateDivider' import {DateDivider} from '#/components/dms/DateDivider'
import {MessageItem} from '#/components/dms/MessageItem' import {MessageItem} from '#/components/dms/MessageItem'
import {MessageOverlays} from '#/components/dms/MessageOverlays'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup' import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
import {SystemMessageItem} from '#/components/dms/SystemMessageItem' import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
@@ -498,6 +499,7 @@ export function MessagesList({
return ( return (
<InviteLinkDialogProvider convo={convoState.convo}> <InviteLinkDialogProvider convo={convoState.convo}>
<MessageOverlays>
<KeyboardGestureArea <KeyboardGestureArea
interpolator="ios" interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419 // HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
@@ -585,7 +587,9 @@ export function MessagesList({
convoState={convoState} convoState={convoState}
hasAcceptOverride={hasAcceptOverride}> hasAcceptOverride={hasAcceptOverride}>
{({loading}) => {({loading}) =>
ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? ( ax.features.enabled(
ax.features.DmsNewMessageComposerEnable,
) ? (
<MessageComposer <MessageComposer
textInputId={textInputId} textInputId={textInputId}
onSendMessage={(message: string) => onSendMessage={(message: string) =>
@@ -618,7 +622,10 @@ export function MessagesList({
</KeyboardStickyView> </KeyboardStickyView>
</KeyboardGestureArea> </KeyboardGestureArea>
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />} {newMessagesPill.show && (
<NewMessagesPill onPress={scrollToEndOnPress} />
)}
</MessageOverlays>
</InviteLinkDialogProvider> </InviteLinkDialogProvider>
) )
} }
@@ -28,6 +28,9 @@ export function RequestListItem({
const isDeletedAccount = const isDeletedAccount =
!convo.primaryMember || convo.primaryMember.handle === 'missing.invalid' !convo.primaryMember || convo.primaryMember.handle === 'missing.invalid'
const canAcceptRequest =
convo.kind === 'direct' || convo.details.lockStatus === 'unlocked'
return ( return (
<View style={[a.relative, a.flex_1]}> <View style={[a.relative, a.flex_1]}>
<ChatListItem convo={convo.view} showMenu={false}> <ChatListItem convo={convo.view} showMenu={false}>
@@ -65,7 +68,9 @@ export function RequestListItem({
]}> ]}>
{convo.primaryMember && !isDeletedAccount ? ( {convo.primaryMember && !isDeletedAccount ? (
<> <>
{canAcceptRequest ? (
<AcceptChatButton convo={convo.view} currentScreen="list" /> <AcceptChatButton convo={convo.view} currentScreen="list" />
) : null}
<RejectMenu <RejectMenu
convo={convo.view} convo={convo.view}
profile={convo.primaryMember} profile={convo.primaryMember}
@@ -129,7 +129,7 @@ function getAppIconName(icon: string | false): DynamicAppIcon.IconName {
if (!icon || icon === 'DEFAULT') { if (!icon || icon === 'DEFAULT') {
return 'default_light' return 'default_light'
} else { } else {
return icon as DynamicAppIcon.IconName return icon
} }
} }
@@ -151,7 +151,7 @@ function Group({
values={[value]} values={[value]}
maxSelections={1} maxSelections={1}
onChange={vals => { 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]}> <View style={[a.flex_1, a.rounded_md, a.overflow_hidden]}>
{children} {children}
@@ -23,8 +23,7 @@ import {
import {useEventListener} from 'expo' import {useEventListener} from 'expo'
import {type VideoPlayer} from 'expo-video' import {type VideoPlayer} from 'expo-video'
import {tokens} from '#/alf' import {atoms as a, tokens} from '#/alf'
import {atoms as a} from '#/alf'
import {formatTime} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils' import {formatTime} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
+1 -1
View File
@@ -298,7 +298,7 @@ export function* findAllPostsInQueryData(
if (AppBskyFeedDefs.isPostView(item.subject)) { if (AppBskyFeedDefs.isPostView(item.subject)) {
const quotedPost = getEmbeddedPost(item.subject?.embed) const quotedPost = getEmbeddedPost(item.subject?.embed)
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) { if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
yield embedViewRecordToPostView(quotedPost!) yield embedViewRecordToPostView(quotedPost)
} }
} }
} }
+1 -1
View File
@@ -34,7 +34,7 @@ export function parseAppNux(nux: AppBskyActorDefs.Nux): AppNux | undefined {
export function serializeAppNux(nux: AppNux): AppBskyActorDefs.Nux { export function serializeAppNux(nux: AppNux): AppBskyActorDefs.Nux {
const {data, ...rest} = nux const {data, ...rest} = nux
const schema = NuxSchemas[nux.id as Nux] const schema = NuxSchemas[nux.id]
const result: AppBskyActorDefs.Nux = { const result: AppBskyActorDefs.Nux = {
...rest, ...rest,
+1 -1
View File
@@ -85,7 +85,7 @@ export function useSuggestedFollowsByActorWithDismiss({
const profiles = useMemo(() => { const profiles = useMemo(() => {
return (data?.suggestions ?? []).map(profile => ({ return (data?.suggestions ?? []).map(profile => ({
actor: profile as bsky.profile.AnyProfileView, actor: profile,
recId: data?.recId, recId: data?.recId,
})) }))
}, [data?.suggestions, data?.recId]) }, [data?.suggestions, data?.recId])
+1 -1
View File
@@ -196,7 +196,7 @@ export function sortAndAnnotateThreadItems(
* `repliesSeenCounter` later on, since `repliesSeenCounter` * `repliesSeenCounter` later on, since `repliesSeenCounter`
* is 1-indexed and `replyIndex` is 0-indexed. * is 1-indexed and `replyIndex` is 0-indexed.
*/ */
childMetadata!.replyIndex = childMetadata.replyIndex =
childParentMetadata.repliesSeenCounter childParentMetadata.repliesSeenCounter
} }
+1 -2
View File
@@ -1,6 +1,7 @@
import { import {
type $Typed, type $Typed,
AppBskyEmbedExternal, AppBskyEmbedExternal,
AppBskyEmbedGallery,
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedRecord, AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
@@ -10,8 +11,6 @@ import {
AppBskyLabelerDefs, AppBskyLabelerDefs,
} from '@atproto/api' } from '@atproto/api'
import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
export type Embed = export type Embed =
| { | {
type: 'post' type: 'post'
+6 -3
View File
@@ -2,6 +2,7 @@ import {useCallback, useMemo, useState} from 'react'
import {LayoutAnimation, Pressable, View} from 'react-native' import {LayoutAnimation, Pressable, View} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import { import {
AppBskyEmbedGallery,
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedRecord, AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
@@ -10,7 +11,6 @@ import {
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {type ComposerOptsPostRef} from '#/state/shell/composer' import {type ComposerOptsPostRef} from '#/state/shell/composer'
@@ -134,11 +134,14 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
} }
function galleryItemsToImages( function galleryItemsToImages(
items: AppBskyEmbedGallery.ViewImage[], items: AppBskyEmbedGallery.View['items'],
): AppBskyEmbedImages.ViewImage[] { ): AppBskyEmbedImages.ViewImage[] {
// The reply-to thumbnail only renders up to 4 tiles; slicing here keeps // The reply-to thumbnail only renders up to 4 tiles; slicing here keeps
// the existing layout switch valid for galleries up to 10 items. // the existing layout switch valid for galleries up to 10 items.
return items.slice(0, 4).map(item => ({ return items
.filter(AppBskyEmbedGallery.isViewImage)
.slice(0, 4)
.map(item => ({
thumb: item.thumbnail, thumb: item.thumbnail,
fullsize: item.fullsize, fullsize: item.fullsize,
alt: item.alt, alt: item.alt,
+28 -28
View File
@@ -1,16 +1,9 @@
/** /**
* Type converters for Draft API - convert between ComposerState and server Draft types. * 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' 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 {resolveLink} from '#/lib/api/resolve'
import {getDeviceName} from '#/lib/deviceName' import {getDeviceName} from '#/lib/deviceName'
import {getImageDim} from '#/lib/media/manip' import {getImageDim} from '#/lib/media/manip'
@@ -123,10 +116,15 @@ async function postDraftToServerPost(
localRefPaths, localRefPaths,
) )
} else if (post.embed.media.type === 'gallery') { } else if (post.embed.media.type === 'gallery') {
;(draftPost as DraftPostWithGallery).embedGallery = serializeImages( draftPost.embedGallery = {
post.embed.media.images, $type: 'app.bsky.draft.defs#draftEmbedGallery',
localRefPaths, 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') { } else if (post.embed.media.type === 'video') {
const video = await serializeVideo(post.embed.media.video, localRefPaths) const video = await serializeVideo(post.embed.media.video, localRefPaths)
if (video) { if (video) {
@@ -326,11 +324,11 @@ async function restoreDraftImages(
height, height,
mime: 'image/jpeg', mime: 'image/jpeg',
}, },
} as ComposerImage } satisfies ComposerImage
}) })
return (await Promise.all(imagePromises)).filter( 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 // Process gallery
const summaryEmbedGallery = (post as DraftPostWithGallery).embedGallery if (post.embedGallery) {
if (summaryEmbedGallery) { for (const item of post.embedGallery.items) {
for (const img of summaryEmbedGallery) { if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
meta.mediaCount++ meta.mediaCount++
meta.hasMedia = true meta.hasMedia = true
const exists = storage.mediaExists(img.localRef.path) const exists = storage.mediaExists(item.localRef.path)
if (!exists) { if (!exists) {
meta.hasMissingMedia = true meta.hasMissingMedia = true
} }
images.push({ images.push({
localPath: img.localRef.path, localPath: item.localRef.path,
altText: img.alt || '', altText: item.alt || '',
exists, exists,
}) })
} }
@@ -523,9 +521,11 @@ export async function draftToComposerPosts(
} }
// Restore gallery // Restore gallery
const embedGallery = (post as DraftPostWithGallery).embedGallery if (post.embedGallery && post.embedGallery.items.length > 0) {
if (embedGallery && embedGallery.length > 0) { const galleryImages = post.embedGallery.items.filter(
const images = await restoreDraftImages(embedGallery, loadedMedia) AppBskyDraftDefs.isDraftEmbedImage,
)
const images = await restoreDraftImages(galleryImages, loadedMedia)
if (images.length > 0) { if (images.length > 0) {
embed.media = {type: 'gallery', images} embed.media = {type: 'gallery', images}
} }
@@ -561,7 +561,7 @@ export async function draftToComposerPosts(
tinygif: mediaObject, tinygif: mediaObject,
preview: mediaObject, preview: mediaObject,
}, },
} as Gif, },
alt: gifData.alt, alt: gifData.alt,
} }
break break
@@ -680,10 +680,10 @@ export function extractLocalRefs(draft: AppBskyDraftDefs.Draft): Set<string> {
refs.add(img.localRef.path) refs.add(img.localRef.path)
} }
} }
const embedGallery = (post as DraftPostWithGallery).embedGallery if (post.embedGallery) {
if (embedGallery) { for (const item of post.embedGallery.items) {
for (const img of embedGallery) { if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
refs.add(img.localRef.path) refs.add(item.localRef.path)
} }
} }
if (post.embedVideos) { if (post.embedVideos) {
+11 -17
View File
@@ -1,10 +1,4 @@
import {AppBskyDraftCreateDraft, type AppBskyDraftDefs} from '@atproto/api' import {AppBskyDraftCreateDraft, 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 { import {
useInfiniteQuery, useInfiniteQuery,
useMutation, useMutation,
@@ -81,15 +75,15 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{
} }
} }
// Load gallery // Load gallery
const embedGallery = (post as DraftPostWithGallery).embedGallery if (post.embedGallery) {
if (embedGallery) { for (const item of post.embedGallery.items) {
for (const img of embedGallery) { if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
try { try {
const url = await storage.loadMediaFromLocal(img.localRef.path) const url = await storage.loadMediaFromLocal(item.localRef.path)
loadedMedia.set(img.localRef.path, url) loadedMedia.set(item.localRef.path, url)
} catch (e) { } catch (e) {
logger.error('Failed to load draft gallery image', { logger.error('Failed to load draft gallery image', {
path: img.localRef.path, path: item.localRef.path,
safeMessage: e instanceof Error ? e.message : String(e), safeMessage: e instanceof Error ? e.message : String(e),
}) })
} }
@@ -247,10 +241,10 @@ export function useDeleteDraftMutation() {
await storage.deleteMediaFromLocal(img.localRef.path) await storage.deleteMediaFromLocal(img.localRef.path)
} }
} }
const embedGallery = (post as DraftPostWithGallery).embedGallery if (post.embedGallery) {
if (embedGallery) { for (const item of post.embedGallery.items) {
for (const img of embedGallery) { if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue
await storage.deleteMediaFromLocal(img.localRef.path) await storage.deleteMediaFromLocal(item.localRef.path)
} }
} }
if (post.embedVideos) { if (post.embedVideos) {
+1 -1
View File
@@ -55,7 +55,7 @@ export function TestCtrls() {
accessibilityLabel="Text input field" accessibilityLabel="Text input field"
accessibilityHint="Enter proxy header" accessibilityHint="Enter proxy header"
testID="e2eProxyHeaderInput" testID="e2eProxyHeaderInput"
onChangeText={val => setProxyHeader(val as any)} onChangeText={val => setProxyHeader(val)}
autoComplete="off" autoComplete="off"
autoCorrect={false} autoCorrect={false}
autoCapitalize="none" autoCapitalize="none"
+1 -1
View File
@@ -3,9 +3,9 @@ import {
Pressable, Pressable,
type PressableProps, type PressableProps,
type StyleProp, type StyleProp,
type View,
type ViewStyle, type ViewStyle,
} from 'react-native' } from 'react-native'
import {type View} from 'react-native'
import {addStyle} from '#/lib/styles' import {addStyle} from '#/lib/styles'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
+3 -1
View File
@@ -4,6 +4,8 @@ import {Gesture, GestureDetector} from 'react-native-gesture-handler'
export function BlockDrawerGesture({children}: {children: React.ReactNode}) { export function BlockDrawerGesture({children}: {children: React.ReactNode}) {
const drawerGesture = useContext(DrawerGestureContext) ?? Gesture.Native() // noop for web 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> return <GestureDetector gesture={scrollGesture}>{children}</GestureDetector>
} }
@@ -171,7 +171,7 @@ function NativeStackNavigator({
} }
// Evicted screens get a lightweight placeholder instead of their full tree // Evicted screens get a lightweight placeholder instead of their full tree
finalDescriptors = {} as typeof descriptors finalDescriptors = {}
for (const key in descriptors) { for (const key in descriptors) {
if (mountSet.has(key)) { if (mountSet.has(key)) {
finalDescriptors[key] = descriptors[key] finalDescriptors[key] = descriptors[key]
+9 -2
View File
@@ -174,8 +174,15 @@ function DrawerLayout({children}: {children: React.ReactNode}) {
// so fail the drawer gesture immediately. // so fail the drawer gesture immediately.
.failOffsetX(-1) .failOffsetX(-1)
// Don't rush declaring that a movement to the right // Don't rush declaring that a movement to the right
// is a drawer swipe. It could be a vertical scroll. // is a drawer swipe. It could be a vertical scroll, or a
.activeOffsetX(5) // 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 { } else {