Fetch link previews from RQ (#5608)

Co-authored-by: Mary <git@mary.my.id>
Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
dan
2024-10-08 09:02:58 +09:00
committed by GitHub
parent dd8be2e939
commit c06040cc20
17 changed files with 494 additions and 520 deletions
+32 -29
View File
@@ -51,6 +51,7 @@ import {
} from '#/state/session' } from '#/state/session'
import {readLastActiveAccount} from '#/state/session/util' import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
@@ -125,35 +126,37 @@ function InnerApp() {
// Resets the entire tree below when it changes: // Resets the entire tree below when it changes:
key={currentAccount?.did}> key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}> <QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider> <ComposerProvider>
<MessagesProvider> <StatsigProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */} <MessagesProvider>
<LabelDefsProvider> {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<ModerationOptsProvider> <LabelDefsProvider>
<LoggedOutViewProvider> <ModerationOptsProvider>
<SelectedFeedProvider> <LoggedOutViewProvider>
<HiddenRepliesProvider> <SelectedFeedProvider>
<UnreadNotifsProvider> <HiddenRepliesProvider>
<BackgroundNotificationPreferencesProvider> <UnreadNotifsProvider>
<MutedThreadsProvider> <BackgroundNotificationPreferencesProvider>
<ProgressGuideProvider> <MutedThreadsProvider>
<GestureHandlerRootView <ProgressGuideProvider>
style={s.h100pct}> <GestureHandlerRootView
<TestCtrls /> style={s.h100pct}>
<Shell /> <TestCtrls />
<NuxDialogs /> <Shell />
</GestureHandlerRootView> <NuxDialogs />
</ProgressGuideProvider> </GestureHandlerRootView>
</MutedThreadsProvider> </ProgressGuideProvider>
</BackgroundNotificationPreferencesProvider> </MutedThreadsProvider>
</UnreadNotifsProvider> </BackgroundNotificationPreferencesProvider>
</HiddenRepliesProvider> </UnreadNotifsProvider>
</SelectedFeedProvider> </HiddenRepliesProvider>
</LoggedOutViewProvider> </SelectedFeedProvider>
</ModerationOptsProvider> </LoggedOutViewProvider>
</LabelDefsProvider> </ModerationOptsProvider>
</MessagesProvider> </LabelDefsProvider>
</StatsigProvider> </MessagesProvider>
</StatsigProvider>
</ComposerProvider>
</QueryProvider> </QueryProvider>
</React.Fragment> </React.Fragment>
</VideoVolumeProvider> </VideoVolumeProvider>
+30 -27
View File
@@ -41,6 +41,7 @@ import {
} from '#/state/session' } from '#/state/session'
import {readLastActiveAccount} from '#/state/session/util' import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut' import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
@@ -116,33 +117,35 @@ function InnerApp() {
// Resets the entire tree below when it changes: // Resets the entire tree below when it changes:
key={currentAccount?.did}> key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}> <QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider> <ComposerProvider>
<MessagesProvider> <StatsigProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */} <MessagesProvider>
<LabelDefsProvider> {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<ModerationOptsProvider> <LabelDefsProvider>
<LoggedOutViewProvider> <ModerationOptsProvider>
<SelectedFeedProvider> <LoggedOutViewProvider>
<HiddenRepliesProvider> <SelectedFeedProvider>
<UnreadNotifsProvider> <HiddenRepliesProvider>
<BackgroundNotificationPreferencesProvider> <UnreadNotifsProvider>
<MutedThreadsProvider> <BackgroundNotificationPreferencesProvider>
<SafeAreaProvider> <MutedThreadsProvider>
<ProgressGuideProvider> <SafeAreaProvider>
<Shell /> <ProgressGuideProvider>
<NuxDialogs /> <Shell />
</ProgressGuideProvider> <NuxDialogs />
</SafeAreaProvider> </ProgressGuideProvider>
</MutedThreadsProvider> </SafeAreaProvider>
</BackgroundNotificationPreferencesProvider> </MutedThreadsProvider>
</UnreadNotifsProvider> </BackgroundNotificationPreferencesProvider>
</HiddenRepliesProvider> </UnreadNotifsProvider>
</SelectedFeedProvider> </HiddenRepliesProvider>
</LoggedOutViewProvider> </SelectedFeedProvider>
</ModerationOptsProvider> </LoggedOutViewProvider>
</LabelDefsProvider> </ModerationOptsProvider>
</MessagesProvider> </LabelDefsProvider>
</StatsigProvider> </MessagesProvider>
</StatsigProvider>
</ComposerProvider>
</QueryProvider> </QueryProvider>
<ToastContainer /> <ToastContainer />
</React.Fragment> </React.Fragment>
+38 -9
View File
@@ -12,12 +12,17 @@ import {
ComAtprotoRepoStrongRef, ComAtprotoRepoStrongRef,
RichText, RichText,
} from '@atproto/api' } from '@atproto/api'
import {QueryClient} from '@tanstack/react-query'
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'
import {ComposerImage, compressImage} from '#/state/gallery' import {ComposerImage, compressImage} from '#/state/gallery'
import {writePostgateRecord} from '#/state/queries/postgate' import {writePostgateRecord} from '#/state/queries/postgate'
import {
fetchResolveGifQuery,
fetchResolveLinkQuery,
} from '#/state/queries/resolve-link'
import { import {
createThreadgateRecord, createThreadgateRecord,
ThreadgateAllowUISetting, ThreadgateAllowUISetting,
@@ -27,7 +32,6 @@ import {
import {ComposerState, EmbedDraft} from '#/view/com/composer/state/composer' import {ComposerState, EmbedDraft} from '#/view/com/composer/state/composer'
import {createGIFDescription} from '../gif-alt-text' import {createGIFDescription} from '../gif-alt-text'
import {LinkMeta} from '../link-meta/link-meta' import {LinkMeta} from '../link-meta/link-meta'
import {resolveGif, resolveLink} from './resolve'
import {uploadBlob} from './upload-blob' import {uploadBlob} from './upload-blob'
export {uploadBlob} export {uploadBlob}
@@ -51,7 +55,11 @@ interface PostOpts {
langs?: string[] langs?: string[]
} }
export async function post(agent: BskyAgent, opts: PostOpts) { export async function post(
agent: BskyAgent,
queryClient: QueryClient,
opts: PostOpts,
) {
let reply let reply
let rt = new RichText({text: opts.rawText.trimEnd()}, {cleanNewlines: true}) let rt = new RichText({text: opts.rawText.trimEnd()}, {cleanNewlines: true})
@@ -64,6 +72,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
const embed = await resolveEmbed( const embed = await resolveEmbed(
agent, agent,
queryClient,
opts.composerState, opts.composerState,
opts.onStateChange, opts.onStateChange,
) )
@@ -178,6 +187,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
async function resolveEmbed( async function resolveEmbed(
agent: BskyAgent, agent: BskyAgent,
queryClient: QueryClient,
draft: ComposerState, draft: ComposerState,
onStateChange: ((state: string) => void) | undefined, onStateChange: ((state: string) => void) | undefined,
): Promise< ): Promise<
@@ -190,8 +200,8 @@ async function resolveEmbed(
> { > {
if (draft.embed.quote) { if (draft.embed.quote) {
const [resolvedMedia, resolvedQuote] = await Promise.all([ const [resolvedMedia, resolvedQuote] = await Promise.all([
resolveMedia(agent, draft.embed, onStateChange), resolveMedia(agent, queryClient, draft.embed, onStateChange),
resolveRecord(agent, draft.embed.quote.uri), resolveRecord(agent, queryClient, draft.embed.quote.uri),
]) ])
if (resolvedMedia) { if (resolvedMedia) {
return { return {
@@ -208,12 +218,21 @@ async function resolveEmbed(
record: resolvedQuote, record: resolvedQuote,
} }
} }
const resolvedMedia = await resolveMedia(agent, draft.embed, onStateChange) const resolvedMedia = await resolveMedia(
agent,
queryClient,
draft.embed,
onStateChange,
)
if (resolvedMedia) { if (resolvedMedia) {
return resolvedMedia return resolvedMedia
} }
if (draft.embed.link) { if (draft.embed.link) {
const resolvedLink = await resolveLink(agent, draft.embed.link.uri) const resolvedLink = await fetchResolveLinkQuery(
queryClient,
agent,
draft.embed.link.uri,
)
if (resolvedLink.type === 'record') { if (resolvedLink.type === 'record') {
return { return {
$type: 'app.bsky.embed.record', $type: 'app.bsky.embed.record',
@@ -226,6 +245,7 @@ async function resolveEmbed(
async function resolveMedia( async function resolveMedia(
agent: BskyAgent, agent: BskyAgent,
queryClient: QueryClient,
embedDraft: EmbedDraft, embedDraft: EmbedDraft,
onStateChange: ((state: string) => void) | undefined, onStateChange: ((state: string) => void) | undefined,
): Promise< ): Promise<
@@ -286,7 +306,11 @@ async function resolveMedia(
} }
if (embedDraft.media?.type === 'gif') { if (embedDraft.media?.type === 'gif') {
const gifDraft = embedDraft.media const gifDraft = embedDraft.media
const resolvedGif = await resolveGif(agent, gifDraft.gif) const resolvedGif = await fetchResolveGifQuery(
queryClient,
agent,
gifDraft.gif,
)
let blob: BlobRef | undefined let blob: BlobRef | undefined
if (resolvedGif.thumb) { if (resolvedGif.thumb) {
onStateChange?.('Uploading link thumbnail...') onStateChange?.('Uploading link thumbnail...')
@@ -305,7 +329,11 @@ async function resolveMedia(
} }
} }
if (embedDraft.link) { if (embedDraft.link) {
const resolvedLink = await resolveLink(agent, embedDraft.link.uri) const resolvedLink = await fetchResolveLinkQuery(
queryClient,
agent,
embedDraft.link.uri,
)
if (resolvedLink.type === 'external') { if (resolvedLink.type === 'external') {
let blob: BlobRef | undefined let blob: BlobRef | undefined
if (resolvedLink.thumb) { if (resolvedLink.thumb) {
@@ -330,9 +358,10 @@ async function resolveMedia(
async function resolveRecord( async function resolveRecord(
agent: BskyAgent, agent: BskyAgent,
queryClient: QueryClient,
uri: string, uri: string,
): Promise<ComAtprotoRepoStrongRef.Main> { ): Promise<ComAtprotoRepoStrongRef.Main> {
const resolvedLink = await resolveLink(agent, uri) const resolvedLink = await fetchResolveLinkQuery(queryClient, agent, uri)
if (resolvedLink.type !== 'record') { if (resolvedLink.type !== 'record') {
throw Error('Expected uri to resolve to a record') throw Error('Expected uri to resolve to a record')
} }
+43 -6
View File
@@ -1,4 +1,4 @@
import {ComAtprotoRepoStrongRef} from '@atproto/api' import {AppBskyActorDefs, ComAtprotoRepoStrongRef} from '@atproto/api'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {BskyAgent} from '@atproto/api' import {BskyAgent} from '@atproto/api'
@@ -33,12 +33,32 @@ type ResolvedExternalLink = {
thumb: ComposerImage | undefined thumb: ComposerImage | undefined
} }
type ResolvedRecord = { type ResolvedPostRecord = {
type: 'record' type: 'record'
record: ComAtprotoRepoStrongRef.Main record: ComAtprotoRepoStrongRef.Main
kind: 'post'
meta: {
text: string
indexedAt: string
author: AppBskyActorDefs.ProfileViewBasic
}
} }
type ResolvedLink = ResolvedExternalLink | ResolvedRecord type ResolvedOtherRecord = {
type: 'record'
record: ComAtprotoRepoStrongRef.Main
kind: 'other'
meta: {
// We should replace this with a hydrated record (e.g. feed, list, starter pack)
// and change the composer preview to use the actual post embed components:
title: string
}
}
export type ResolvedLink =
| ResolvedExternalLink
| ResolvedPostRecord
| ResolvedOtherRecord
export async function resolveLink( export async function resolveLink(
agent: BskyAgent, agent: BskyAgent,
@@ -57,6 +77,8 @@ export async function resolveLink(
cid: result.cid, cid: result.cid,
uri: result.uri, uri: result.uri,
}, },
kind: 'post',
meta: result,
} }
} }
if (isBskyCustomFeedUrl(uri)) { if (isBskyCustomFeedUrl(uri)) {
@@ -64,7 +86,12 @@ export async function resolveLink(
const result = await getFeedAsEmbed(agent, fetchDid, uri) const result = await getFeedAsEmbed(agent, fetchDid, uri)
return { return {
type: 'record', type: 'record',
record: result.embed!.record, // TODO: Fix types. record: result.embed!.record,
kind: 'other',
meta: {
// TODO: Include hydrated content instead.
title: result.meta!.title!,
},
} }
} }
if (isBskyListUrl(uri)) { if (isBskyListUrl(uri)) {
@@ -72,7 +99,12 @@ export async function resolveLink(
const result = await getListAsEmbed(agent, fetchDid, uri) const result = await getListAsEmbed(agent, fetchDid, uri)
return { return {
type: 'record', type: 'record',
record: result.embed!.record, // TODO: Fix types. record: result.embed!.record,
kind: 'other',
meta: {
// TODO: Include hydrated content instead.
title: result.meta!.title!,
},
} }
} }
if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) { if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) {
@@ -80,7 +112,12 @@ export async function resolveLink(
const result = await getStarterPackAsEmbed(agent, fetchDid, uri) const result = await getStarterPackAsEmbed(agent, fetchDid, uri)
return { return {
type: 'record', type: 'record',
record: result.embed!.record, // TODO: Fix types. record: result.embed!.record,
kind: 'other',
meta: {
// TODO: Include hydrated content instead.
title: result.meta!.title!,
},
} }
} }
return resolveExternal(agent, uri) return resolveExternal(agent, uri)
+70
View File
@@ -0,0 +1,70 @@
import {QueryClient, useQuery} from '@tanstack/react-query'
import {STALE} from '#/state/queries/index'
import {useAgent} from '../session'
const RQKEY_LINK_ROOT = 'resolve-link'
export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url]
const RQKEY_GIF_ROOT = 'resolve-gif'
export const RQKEY_GIF = (url: string) => [RQKEY_GIF_ROOT, url]
import {BskyAgent} from '@atproto/api'
import {ResolvedLink, resolveGif, resolveLink} from '#/lib/api/resolve'
import {Gif} from './tenor'
export function useResolveLinkQuery(url: string) {
const agent = useAgent()
return useQuery({
staleTime: STALE.HOURS.ONE,
queryKey: RQKEY_LINK(url),
queryFn: async () => {
return await resolveLink(agent, url)
},
})
}
export function fetchResolveLinkQuery(
queryClient: QueryClient,
agent: BskyAgent,
url: string,
) {
return queryClient.fetchQuery({
staleTime: STALE.HOURS.ONE,
queryKey: RQKEY_LINK(url),
queryFn: async () => {
return await resolveLink(agent, url)
},
})
}
export function precacheResolveLinkQuery(
queryClient: QueryClient,
url: string,
resolvedLink: ResolvedLink,
) {
queryClient.setQueryData(RQKEY_LINK(url), resolvedLink)
}
export function useResolveGifQuery(gif: Gif) {
const agent = useAgent()
return useQuery({
staleTime: STALE.HOURS.ONE,
queryKey: RQKEY_GIF(gif.url),
queryFn: async () => {
return await resolveGif(agent, gif)
},
})
}
export function fetchResolveGifQuery(
queryClient: QueryClient,
agent: BskyAgent,
gif: Gif,
) {
return queryClient.fetchQuery({
staleTime: STALE.HOURS.ONE,
queryKey: RQKEY_GIF(gif.url),
queryFn: async () => {
return await resolveGif(agent, gif)
},
})
}
+23
View File
@@ -7,9 +7,12 @@ import {
} from '@atproto/api' } from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {postUriToRelativePath, toBskyAppUrl} from '#/lib/strings/url-helpers'
import {purgeTemporaryImageFiles} from '#/state/gallery' import {purgeTemporaryImageFiles} from '#/state/gallery'
import {precacheResolveLinkQuery} from '#/state/queries/resolve-link'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
export interface ComposerOptsPostRef { export interface ComposerOptsPostRef {
@@ -58,8 +61,28 @@ const controlsContext = React.createContext<ControlsContext>({
export function Provider({children}: React.PropsWithChildren<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
const {_} = useLingui() const {_} = useLingui()
const [state, setState] = React.useState<StateContext>() const [state, setState] = React.useState<StateContext>()
const queryClient = useQueryClient()
const openComposer = useNonReactiveCallback((opts: ComposerOpts) => { const openComposer = useNonReactiveCallback((opts: ComposerOpts) => {
if (opts.quote) {
const path = postUriToRelativePath(opts.quote.uri)
if (path) {
const appUrl = toBskyAppUrl(path)
precacheResolveLinkQuery(queryClient, appUrl, {
type: 'record',
kind: 'post',
record: {
cid: opts.quote.cid,
uri: opts.quote.uri,
},
meta: {
author: opts.quote.author,
indexedAt: opts.quote.indexedAt,
text: opts.quote.text,
},
})
}
}
const author = opts.replyTo?.author || opts.quote?.author const author = opts.replyTo?.author || opts.quote?.author
const isBlocked = Boolean( const isBlocked = Boolean(
author && author &&
+7 -9
View File
@@ -1,22 +1,22 @@
import React from 'react' import React from 'react'
import {Provider as ShellLayoutProvder} from './shell-layout'
import {Provider as ColorModeProvider} from './color-mode'
import {Provider as DrawerOpenProvider} from './drawer-open' import {Provider as DrawerOpenProvider} from './drawer-open'
import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled' import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled'
import {Provider as MinimalModeProvider} from './minimal-mode' import {Provider as MinimalModeProvider} from './minimal-mode'
import {Provider as ColorModeProvider} from './color-mode'
import {Provider as OnboardingProvider} from './onboarding' import {Provider as OnboardingProvider} from './onboarding'
import {Provider as ComposerProvider} from './composer' import {Provider as ShellLayoutProvder} from './shell-layout'
import {Provider as TickEveryMinuteProvider} from './tick-every-minute' import {Provider as TickEveryMinuteProvider} from './tick-every-minute'
export {useSetThemePrefs, useThemePrefs} from './color-mode'
export {useComposerControls, useComposerState} from './composer'
export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open' export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open'
export { export {
useIsDrawerSwipeDisabled, useIsDrawerSwipeDisabled,
useSetDrawerSwipeDisabled, useSetDrawerSwipeDisabled,
} from './drawer-swipe-disabled' } from './drawer-swipe-disabled'
export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode' export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode'
export {useThemePrefs, useSetThemePrefs} from './color-mode' export {useOnboardingDispatch, useOnboardingState} from './onboarding'
export {useOnboardingState, useOnboardingDispatch} from './onboarding'
export {useComposerState, useComposerControls} from './composer'
export {useTickEveryMinute} from './tick-every-minute' export {useTickEveryMinute} from './tick-every-minute'
export function Provider({children}: React.PropsWithChildren<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
@@ -27,9 +27,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
<MinimalModeProvider> <MinimalModeProvider>
<ColorModeProvider> <ColorModeProvider>
<OnboardingProvider> <OnboardingProvider>
<ComposerProvider> <TickEveryMinuteProvider>{children}</TickEveryMinuteProvider>
<TickEveryMinuteProvider>{children}</TickEveryMinuteProvider>
</ComposerProvider>
</OnboardingProvider> </OnboardingProvider>
</ColorModeProvider> </ColorModeProvider>
</MinimalModeProvider> </MinimalModeProvider>
+69 -107
View File
@@ -46,19 +46,15 @@ import {RichText} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import * as apilib from '#/lib/api/index' import * as apilib from '#/lib/api/index'
import {until} from '#/lib/async/until' import {until} from '#/lib/async/until'
import {MAX_GRAPHEME_LENGTH} from '#/lib/constants' import {MAX_GRAPHEME_LENGTH} from '#/lib/constants'
import {
createGIFDescription,
parseAltFromGIFDescription,
} from '#/lib/gif-alt-text'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {LikelyType} from '#/lib/link-meta/link-meta'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {insertMentionAt} from '#/lib/strings/mention-manip' import {insertMentionAt} from '#/lib/strings/mention-manip'
@@ -87,8 +83,11 @@ import {useComposerControls} from '#/state/shell/composer'
import {ComposerOpts} from '#/state/shell/composer' import {ComposerOpts} from '#/state/shell/composer'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo' import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
import {ExternalEmbed} from '#/view/com/composer/ExternalEmbed' import {
import {GifAltText} from '#/view/com/composer/GifAltText' ExternalEmbedGif,
ExternalEmbedLink,
} from '#/view/com/composer/ExternalEmbed'
import {GifAltTextDialog} from '#/view/com/composer/GifAltText'
import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn' import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
import {Gallery} from '#/view/com/composer/photos/Gallery' import {Gallery} from '#/view/com/composer/photos/Gallery'
import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn' import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
@@ -100,12 +99,11 @@ import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLa
// due to linting false positives // due to linting false positives
import {TextInput, TextInputRef} from '#/view/com/composer/text-input/TextInput' import {TextInput, TextInputRef} from '#/view/com/composer/text-input/TextInput'
import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn' import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
import {useExternalLinkFetch} from '#/view/com/composer/useExternalLinkFetch'
import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn' import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn'
import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress' import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
import {QuoteEmbed, QuoteX} from '#/view/com/util/post-embeds/QuoteEmbed' import {LazyQuoteEmbed, QuoteX} from '#/view/com/util/post-embeds/QuoteEmbed'
import {Text} from '#/view/com/util/text/Text' import {Text} from '#/view/com/util/text/Text'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
@@ -117,13 +115,15 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {createPortalGroup} from '#/components/Portal' import {createPortalGroup} from '#/components/Portal'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {Text as NewText} from '#/components/Typography' import {Text as NewText} from '#/components/Typography'
import {composerReducer, createComposerState} from './state/composer' import {
composerReducer,
createComposerState,
MAX_IMAGES,
} from './state/composer'
import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video' import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
const Portal = createPortalGroup() const Portal = createPortalGroup()
const MAX_IMAGES = 4
type CancelRef = { type CancelRef = {
onPressCancel: () => void onPressCancel: () => void
} }
@@ -135,7 +135,7 @@ export const ComposePost = ({
replyTo, replyTo,
onPost, onPost,
quote: initQuote, quote: initQuote,
quoteCount, quoteCount: initQuoteCount,
mention: initMention, mention: initMention,
openEmojiPicker, openEmojiPicker,
text: initText, text: initText,
@@ -147,6 +147,7 @@ export const ComposePost = ({
}) => { }) => {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient()
const currentDid = currentAccount!.did const currentDid = currentAccount!.did
const {data: currentProfile} = useProfileQuery({did: currentDid}) const {data: currentProfile} = useProfileQuery({did: currentDid})
const {isModalActive} = useModals() const {isModalActive} = useModals()
@@ -183,9 +184,6 @@ export const ComposePost = ({
const graphemeLength = useMemo(() => { const graphemeLength = useMemo(() => {
return shortenLinks(richtext).graphemeLength return shortenLinks(richtext).graphemeLength
}, [richtext]) }, [richtext])
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
initQuote,
)
// TODO: Move more state here. // TODO: Move more state here.
const [composerState, dispatch] = useReducer( const [composerState, dispatch] = useReducer(
@@ -246,8 +244,6 @@ export const ComposePost = ({
const [publishOnUpload, setPublishOnUpload] = useState(false) const [publishOnUpload, setPublishOnUpload] = useState(false)
const {extLink, setExtLink} = useExternalLinkFetch({setQuote, setError})
const [extGif, setExtGif] = useState<Gif>()
const [labels, setLabels] = useState<string[]>([]) const [labels, setLabels] = useState<string[]>([])
const [threadgateAllowUISettings, onChangeThreadgateAllowUISettings] = const [threadgateAllowUISettings, onChangeThreadgateAllowUISettings] =
useState<ThreadgateAllowUISetting[]>( useState<ThreadgateAllowUISetting[]>(
@@ -255,10 +251,24 @@ export const ComposePost = ({
) )
const [postgate, setPostgate] = useState(createPostgateRecord({post: ''})) const [postgate, setPostgate] = useState(createPostgateRecord({post: ''}))
let quote: string | undefined
if (composerState.embed.quote) {
quote = composerState.embed.quote.uri
}
let images = NO_IMAGES let images = NO_IMAGES
if (composerState.embed.media?.type === 'images') { if (composerState.embed.media?.type === 'images') {
images = composerState.embed.media.images images = composerState.embed.media.images
} }
let extGif: Gif | undefined
let extGifAlt: string | undefined
if (composerState.embed.media?.type === 'gif') {
extGif = composerState.embed.media.gif
extGifAlt = composerState.embed.media.alt
}
let extLink: string | undefined
if (composerState.embed.link) {
extLink = composerState.embed.link.uri
}
const onClose = useCallback(() => { const onClose = useCallback(() => {
closeComposer() closeComposer()
@@ -335,14 +345,9 @@ export const ComposePost = ({
} }
}, [onEscape, isModalActive]) }, [onEscape, isModalActive])
const onNewLink = useCallback( const onNewLink = useCallback((uri: string) => {
(uri: string) => { dispatch({type: 'embed_add_uri', uri})
dispatch({type: 'embed_add_uri', uri}) }, [])
if (extLink != null) return
setExtLink({uri, isLoading: true})
},
[extLink, setExtLink],
)
const onImageAdd = useCallback( const onImageAdd = useCallback(
(next: ComposerImage[]) => { (next: ComposerImage[]) => {
@@ -371,14 +376,10 @@ export const ComposePost = ({
if (images.some(img => img.alt === '')) return true if (images.some(img => img.alt === '')) return true
if (extGif) { if (extGif && !extGifAlt) return true
if (!extLink?.meta?.description) return true
const parsedAlt = parseAltFromGIFDescription(extLink.meta.description)
if (!parsedAlt.isPreferred) return true
}
return false return false
}, [images, extLink, extGif, requireAltTextEnabled]) }, [images, extGifAlt, extGif, requireAltTextEnabled])
const onPressPublish = React.useCallback( const onPressPublish = React.useCallback(
async (finishedUploading?: boolean) => { async (finishedUploading?: boolean) => {
@@ -411,17 +412,13 @@ export const ComposePost = ({
setError(_(msg`Did you want to say anything?`)) setError(_(msg`Did you want to say anything?`))
return return
} }
if (extLink?.isLoading) {
setError(_(msg`Please wait for your link card to finish loading`))
return
}
setIsProcessing(true) setIsProcessing(true)
let postUri let postUri
try { try {
postUri = ( postUri = (
await apilib.post(agent, { await apilib.post(agent, queryClient, {
composerState, // TODO: move more state here. composerState, // TODO: move more state here.
rawText: richtext.text, rawText: richtext.text,
replyTo: replyTo?.uri, replyTo: replyTo?.uri,
@@ -449,13 +446,6 @@ export const ComposePost = ({
hasImages: images.length > 0, hasImages: images.length > 0,
}) })
if (extLink) {
setExtLink({
...extLink,
isLoading: true,
localThumb: undefined,
} as apilib.ExternalEmbedDraft)
}
let err = cleanError(e.message) let err = cleanError(e.message)
if (err.includes('not locate record')) { if (err.includes('not locate record')) {
err = _( err = _(
@@ -481,13 +471,13 @@ export const ComposePost = ({
emitPostCreated() emitPostCreated()
} }
setLangPrefs.savePostLanguageToHistory() setLangPrefs.savePostLanguageToHistory()
if (quote) { if (initQuote && initQuoteCount !== undefined) {
// We want to wait for the quote count to update before we call `onPost`, which will refetch data // We want to wait for the quote count to update before we call `onPost`, which will refetch data
whenAppViewReady(agent, quote.uri, res => { whenAppViewReady(agent, initQuote.uri, res => {
const thread = res.data.thread const thread = res.data.thread
if ( if (
AppBskyFeedDefs.isThreadViewPost(thread) && AppBskyFeedDefs.isThreadViewPost(thread) &&
thread.post.quoteCount !== quoteCount thread.post.quoteCount !== initQuoteCount
) { ) {
onPost?.(postUri) onPost?.(postUri)
return true return true
@@ -519,14 +509,15 @@ export const ComposePost = ({
onPost, onPost,
postgate, postgate,
quote, quote,
quoteCount, initQuote,
initQuoteCount,
replyTo, replyTo,
richtext.text, richtext.text,
setExtLink,
setLangPrefs, setLangPrefs,
threadgateAllowUISettings, threadgateAllowUISettings,
videoState.asset, videoState.asset,
videoState.status, videoState.status,
queryClient,
], ],
) )
@@ -549,11 +540,9 @@ export const ComposePost = ({
const canSelectImages = const canSelectImages =
images.length < MAX_IMAGES && images.length < MAX_IMAGES &&
!extLink &&
videoState.status === 'idle' && videoState.status === 'idle' &&
!videoState.video !videoState.video
const hasMedia = const hasMedia = images.length > 0 || Boolean(videoState.video)
images.length > 0 || Boolean(extLink) || Boolean(videoState.video)
const onEmojiButtonPress = useCallback(() => { const onEmojiButtonPress = useCallback(() => {
openEmojiPicker?.(textInput.current?.getCursorPosition()) openEmojiPicker?.(textInput.current?.getCursorPosition())
@@ -563,45 +552,13 @@ export const ComposePost = ({
textInput.current?.focus() textInput.current?.focus()
}, []) }, [])
const onSelectGif = useCallback( const onSelectGif = useCallback((gif: Gif) => {
(gif: Gif) => { dispatch({type: 'embed_add_gif', gif})
dispatch({type: 'embed_add_gif', gif}) }, [])
setExtLink({
uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`,
isLoading: true,
meta: {
url: gif.media_formats.gif.url,
image: gif.media_formats.preview.url,
likelyType: LikelyType.HTML,
title: gif.content_description,
description: createGIFDescription(gif.content_description),
},
})
setExtGif(gif)
},
[setExtLink],
)
const handleChangeGifAltText = useCallback( const handleChangeGifAltText = useCallback((altText: string) => {
(altText: string) => { dispatch({type: 'embed_update_gif', alt: altText})
dispatch({type: 'embed_update_gif', alt: altText}) }, [])
setExtLink(ext =>
ext && ext.meta
? {
...ext,
meta: {
...ext.meta,
description: createGIFDescription(
ext.meta.title ?? '',
altText,
),
},
}
: ext,
)
},
[setExtLink],
)
const { const {
scrollHandler, scrollHandler,
@@ -660,7 +617,7 @@ export const ComposePost = ({
<LabelsBtn <LabelsBtn
labels={labels} labels={labels}
onChange={setLabels} onChange={setLabels}
hasMedia={hasMedia} hasMedia={hasMedia || Boolean(extLink)}
/> />
{canPost ? ( {canPost ? (
<Button <Button
@@ -759,29 +716,35 @@ export const ComposePost = ({
dispatch={dispatch} dispatch={dispatch}
Portal={Portal.Portal} Portal={Portal.Portal}
/> />
{images.length === 0 && extLink && (
<View style={a.relative}> {extGif && (
<ExternalEmbed <View style={a.relative} key={extGif.url}>
link={extLink} <ExternalEmbedGif
gif={extGif} gif={extGif}
onRemove={() => { onRemove={() => {
if (extGif) { dispatch({type: 'embed_remove_gif'})
dispatch({type: 'embed_remove_gif'})
} else {
dispatch({type: 'embed_remove_link'})
}
setExtLink(undefined)
setExtGif(undefined)
}} }}
/> />
<GifAltText <GifAltTextDialog
link={extLink}
gif={extGif} gif={extGif}
altText={extGifAlt ?? ''}
onSubmit={handleChangeGifAltText} onSubmit={handleChangeGifAltText}
Portal={Portal.Portal} Portal={Portal.Portal}
/> />
</View> </View>
)} )}
{!composerState.embed.media && extLink && (
<View style={a.relative} key={extLink}>
<ExternalEmbedLink
uri={extLink}
onRemove={() => {
dispatch({type: 'embed_remove_link'})
}}
/>
</View>
)}
<LayoutAnimationConfig skipExiting> <LayoutAnimationConfig skipExiting>
{hasVideo && ( {hasVideo && (
<Animated.View <Animated.View
@@ -835,13 +798,12 @@ export const ComposePost = ({
{quote ? ( {quote ? (
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}> <View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
<View style={{pointerEvents: 'none'}}> <View style={{pointerEvents: 'none'}}>
<QuoteEmbed quote={quote} /> <LazyQuoteEmbed uri={quote} />
</View> </View>
{quote.uri !== initQuote?.uri && ( {!initQuote && (
<QuoteX <QuoteX
onRemove={() => { onRemove={() => {
dispatch({type: 'embed_remove_quote'}) dispatch({type: 'embed_remove_quote'})
setQuote(undefined)
}} }}
/> />
)} )}
+84 -43
View File
@@ -1,71 +1,112 @@
import React from 'react' import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native' import {StyleProp, View, ViewStyle} from 'react-native'
import {ExternalEmbedDraft} from 'lib/api/index' import {cleanError} from '#/lib/strings/errors'
import {Gif} from 'state/queries/tenor' import {
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn' useResolveGifQuery,
import {ExternalLinkEmbed} from 'view/com/util/post-embeds/ExternalLinkEmbed' useResolveLinkQuery,
} from '#/state/queries/resolve-link'
import {Gif} from '#/state/queries/tenor'
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
import {ExternalLinkEmbed} from '#/view/com/util/post-embeds/ExternalLinkEmbed'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export const ExternalEmbed = ({ export const ExternalEmbedGif = ({
link,
onRemove, onRemove,
gif, gif,
}: { }: {
link?: ExternalEmbedDraft
onRemove: () => void onRemove: () => void
gif?: Gif gif: Gif
}) => { }) => {
const t = useTheme() const t = useTheme()
const {data, error} = useResolveGifQuery(gif)
const linkInfo = React.useMemo( const linkInfo = React.useMemo(
() => () =>
link && { data && {
title: link.meta?.title ?? link.uri, title: data.title ?? data.uri,
uri: link.uri, uri: data.uri,
description: link.meta?.description ?? '', description: data.description ?? '',
thumb: link.localThumb?.source.path, thumb: data.thumb?.source.path,
}, },
[link], [data],
) )
if (!link) return null const loadingStyle: ViewStyle = {
aspectRatio: gif.media_formats.gif.dims[0] / gif.media_formats.gif.dims[1],
const loadingStyle: ViewStyle | undefined = gif width: '100%',
? { }
aspectRatio:
gif.media_formats.gif.dims[0] / gif.media_formats.gif.dims[1],
width: '100%',
}
: undefined
return ( return (
<View <View style={[a.overflow_hidden, t.atoms.border_contrast_medium]}>
style={[ {linkInfo ? (
!gif && a.mb_xl, <View style={{pointerEvents: 'auto'}}>
a.overflow_hidden, <ExternalLinkEmbed link={linkInfo} hideAlt />
t.atoms.border_contrast_medium, </View>
]}> ) : error ? (
{link.isLoading ? ( <Container style={[a.align_start, a.p_md, a.gap_xs]}>
<Text numberOfLines={1} style={t.atoms.text_contrast_high}>
{gif.url}
</Text>
<Text numberOfLines={2} style={[{color: t.palette.negative_400}]}>
{cleanError(error)}
</Text>
</Container>
) : (
<Container style={loadingStyle}> <Container style={loadingStyle}>
<Loader size="xl" /> <Loader size="xl" />
</Container> </Container>
) : link.meta?.error ? ( )}
<Container style={[a.align_start, a.p_md, a.gap_xs]}> <ExternalEmbedRemoveBtn onRemove={onRemove} />
<Text numberOfLines={1} style={t.atoms.text_contrast_high}> </View>
{link.uri} )
</Text> }
<Text numberOfLines={2} style={[{color: t.palette.negative_400}]}>
{link.meta?.error} export const ExternalEmbedLink = ({
</Text> uri,
</Container> onRemove,
) : linkInfo ? ( }: {
<View style={{pointerEvents: !gif ? 'none' : 'auto'}}> uri: string
onRemove: () => void
}) => {
const t = useTheme()
const {data, error} = useResolveLinkQuery(uri)
const linkInfo = React.useMemo(
() =>
data && {
title:
data.type === 'external'
? data.title
: data.kind === 'other'
? data.meta.title
: uri,
uri,
description: data.type === 'external' ? data.description : '',
thumb: data.type === 'external' ? data.thumb?.source.path : undefined,
},
[data, uri],
)
return (
<View style={[a.mb_xl, a.overflow_hidden, t.atoms.border_contrast_medium]}>
{linkInfo ? (
<View style={{pointerEvents: 'none'}}>
<ExternalLinkEmbed link={linkInfo} hideAlt /> <ExternalLinkEmbed link={linkInfo} hideAlt />
</View> </View>
) : null} ) : error ? (
<Container style={[a.align_start, a.p_md, a.gap_xs]}>
<Text numberOfLines={1} style={t.atoms.text_contrast_high}>
{uri}
</Text>
<Text numberOfLines={2} style={[{color: t.palette.negative_400}]}>
{cleanError(error)}
</Text>
</Container>
) : (
<Container>
<Loader size="xl" />
</Container>
)}
<ExternalEmbedRemoveBtn onRemove={onRemove} /> <ExternalEmbedRemoveBtn onRemove={onRemove} />
</View> </View>
) )
+56 -39
View File
@@ -1,10 +1,8 @@
import React, {useState} from 'react' import React, {useState} from 'react'
import {TouchableOpacity, View} from 'react-native' import {TouchableOpacity, View} from 'react-native'
import {AppBskyEmbedExternal} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {ExternalEmbedDraft} from '#/lib/api'
import {HITSLOP_10, MAX_ALT_TEXT} from '#/lib/constants' import {HITSLOP_10, MAX_ALT_TEXT} from '#/lib/constants'
import {parseAltFromGIFDescription} from '#/lib/gif-alt-text' import {parseAltFromGIFDescription} from '#/lib/gif-alt-text'
import { import {
@@ -12,6 +10,7 @@ import {
parseEmbedPlayerFromUrl, parseEmbedPlayerFromUrl,
} from '#/lib/strings/embed-player' } from '#/lib/strings/embed-player'
import {isAndroid} from '#/platform/detection' import {isAndroid} from '#/platform/detection'
import {useResolveGifQuery} from '#/state/queries/resolve-link'
import {Gif} from '#/state/queries/tenor' import {Gif} from '#/state/queries/tenor'
import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper' import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper'
import {atoms as a, native, useTheme} from '#/alf' import {atoms as a, native, useTheme} from '#/alf'
@@ -27,38 +26,54 @@ import {Text} from '#/components/Typography'
import {GifEmbed} from '../util/post-embeds/GifEmbed' import {GifEmbed} from '../util/post-embeds/GifEmbed'
import {AltTextReminder} from './photos/Gallery' import {AltTextReminder} from './photos/Gallery'
export function GifAltText({ export function GifAltTextDialog({
link: linkProp,
gif, gif,
altText,
onSubmit, onSubmit,
Portal, Portal,
}: { }: {
link: ExternalEmbedDraft gif: Gif
gif?: Gif altText: string
onSubmit: (alt: string) => void onSubmit: (alt: string) => void
Portal: PortalComponent Portal: PortalComponent
}) {
const {data} = useResolveGifQuery(gif)
const vendorAltText = parseAltFromGIFDescription(data?.description ?? '').alt
const params = data ? parseEmbedPlayerFromUrl(data.uri) : undefined
if (!data || !params) {
return null
}
return (
<GifAltTextDialogLoaded
altText={altText}
vendorAltText={vendorAltText}
thumb={data.thumb?.source.path}
params={params}
onSubmit={onSubmit}
Portal={Portal}
/>
)
}
export function GifAltTextDialogLoaded({
vendorAltText,
altText,
onSubmit,
params,
thumb,
Portal,
}: {
vendorAltText: string
altText: string
onSubmit: (alt: string) => void
params: EmbedPlayerParams
thumb: string | undefined
Portal: PortalComponent
}) { }) {
const control = Dialog.useDialogControl() const control = Dialog.useDialogControl()
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const [altTextDraft, setAltTextDraft] = useState(altText || vendorAltText)
const {link, params} = React.useMemo(() => {
return {
link: {
title: linkProp.meta?.title ?? linkProp.uri,
uri: linkProp.uri,
description: linkProp.meta?.description ?? '',
thumb: linkProp.localThumb?.source.path,
},
params: parseEmbedPlayerFromUrl(linkProp.uri),
}
}, [linkProp])
const parsedAlt = parseAltFromGIFDescription(link.description)
const [altText, setAltText] = useState(parsedAlt.alt)
if (!gif || !params) return null
return ( return (
<> <>
<TouchableOpacity <TouchableOpacity
@@ -80,7 +95,7 @@ export function GifAltText({
a.align_center, a.align_center,
{backgroundColor: 'rgba(0, 0, 0, 0.75)'}, {backgroundColor: 'rgba(0, 0, 0, 0.75)'},
]}> ]}>
{parsedAlt.isPreferred ? ( {altText ? (
<Check size="xs" fill={t.palette.white} style={a.ml_xs} /> <Check size="xs" fill={t.palette.white} style={a.ml_xs} />
) : ( ) : (
<Plus size="sm" fill={t.palette.white} /> <Plus size="sm" fill={t.palette.white} />
@@ -97,17 +112,17 @@ export function GifAltText({
<Dialog.Outer <Dialog.Outer
control={control} control={control}
onClose={() => { onClose={() => {
onSubmit(altText) onSubmit(altTextDraft)
}} }}
Portal={Portal}> Portal={Portal}>
<Dialog.Handle /> <Dialog.Handle />
<AltTextInner <AltTextInner
altText={altText} vendorAltText={vendorAltText}
setAltText={setAltText} altText={altTextDraft}
onChange={setAltTextDraft}
thumb={thumb}
control={control} control={control}
link={link}
params={params} params={params}
key={link.uri}
/> />
</Dialog.Outer> </Dialog.Outer>
</> </>
@@ -115,17 +130,19 @@ export function GifAltText({
} }
function AltTextInner({ function AltTextInner({
vendorAltText,
altText, altText,
setAltText, onChange,
control, control,
link,
params, params,
thumb,
}: { }: {
vendorAltText: string
altText: string altText: string
setAltText: (text: string) => void onChange: (text: string) => void
control: DialogControlProps control: DialogControlProps
link: AppBskyEmbedExternal.ViewExternal
params: EmbedPlayerParams params: EmbedPlayerParams
thumb: string | undefined
}) { }) {
const t = useTheme() const t = useTheme()
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
@@ -142,10 +159,8 @@ function AltTextInner({
<TextField.Root> <TextField.Root>
<Dialog.Input <Dialog.Input
label={_(msg`Alt text`)} label={_(msg`Alt text`)}
placeholder={link.title} placeholder={vendorAltText}
onChangeText={text => { onChangeText={onChange}
setAltText(text)
}}
defaultValue={altText} defaultValue={altText}
multiline multiline
numberOfLines={3} numberOfLines={3}
@@ -200,7 +215,9 @@ function AltTextInner({
</Text> </Text>
<View style={[a.align_center]}> <View style={[a.align_center]}>
<GifEmbed <GifEmbed
link={link} thumb={thumb}
altText={altText}
isPreferredAltText={true}
params={params} params={params}
hideAlt hideAlt
style={[native({maxHeight: 225})]} style={[native({maxHeight: 225})]}
+1 -2
View File
@@ -64,7 +64,7 @@ export type ComposerAction =
| {type: 'embed_update_gif'; alt: string} | {type: 'embed_update_gif'; alt: string}
| {type: 'embed_remove_gif'} | {type: 'embed_remove_gif'}
const MAX_IMAGES = 4 export const MAX_IMAGES = 4
export function composerReducer( export function composerReducer(
state: ComposerState, state: ComposerState,
@@ -317,7 +317,6 @@ export function createComposerState({
} }
} }
} }
// TODO: Other initial content.
return { return {
embed: { embed: {
quote, quote,
@@ -1,47 +0,0 @@
import {useEffect, useState} from 'react'
import {useAgent} from '#/state/session'
import * as apilib from 'lib/api/index'
import {getLinkMeta} from 'lib/link-meta/link-meta'
import {ComposerOpts} from 'state/shell/composer'
export function useExternalLinkFetch({}: {
setQuote: (opts: ComposerOpts['quote']) => void
}) {
const agent = useAgent()
const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
undefined,
)
useEffect(() => {
let aborted = false
const cleanup = () => {
aborted = true
}
if (!extLink) {
return cleanup
}
if (!extLink.meta) {
getLinkMeta(agent, extLink.uri).then(meta => {
if (aborted) {
return
}
setExtLink({
uri: extLink.uri,
isLoading: !!meta.image,
meta,
})
})
return cleanup
}
if (extLink.isLoading) {
setExtLink({
...extLink,
isLoading: false, // done
})
}
return cleanup
}, [extLink, agent])
return {extLink, setExtLink}
}
@@ -1,187 +0,0 @@
import {useEffect, useState} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as apilib from '#/lib/api/index'
import {POST_IMG_MAX} from '#/lib/constants'
import {
EmbeddingDisabledError,
getFeedAsEmbed,
getListAsEmbed,
getPostAsQuote,
getStarterPackAsEmbed,
} from '#/lib/link-meta/bsky'
import {getLinkMeta} from '#/lib/link-meta/link-meta'
import {resolveShortLink} from '#/lib/link-meta/resolve-short-link'
import {downloadAndResize} from '#/lib/media/manip'
import {
isBskyCustomFeedUrl,
isBskyListUrl,
isBskyPostUrl,
isBskyStarterPackUrl,
isBskyStartUrl,
isShortLink,
} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {createComposerImage} from '#/state/gallery'
import {useFetchDid} from '#/state/queries/handle'
import {useGetPost} from '#/state/queries/post'
import {useAgent} from '#/state/session'
import {ComposerOpts} from '#/state/shell/composer'
export function useExternalLinkFetch({
setQuote,
setError,
}: {
setQuote: (opts: ComposerOpts['quote']) => void
setError: (err: string) => void
}) {
const {_} = useLingui()
const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
undefined,
)
const getPost = useGetPost()
const fetchDid = useFetchDid()
const agent = useAgent()
useEffect(() => {
let aborted = false
const cleanup = () => {
aborted = true
}
if (!extLink) {
return cleanup
}
if (!extLink.meta) {
if (isBskyPostUrl(extLink.uri)) {
getPostAsQuote(getPost, extLink.uri).then(
newQuote => {
if (aborted) {
return
}
setQuote(newQuote)
setExtLink(undefined)
},
err => {
if (err instanceof EmbeddingDisabledError) {
setError(_(msg`This post's author has disabled quote posts.`))
} else {
logger.error('Failed to fetch post for quote embedding', {
message: err.toString(),
})
}
setExtLink(undefined)
},
)
} else if (isBskyCustomFeedUrl(extLink.uri)) {
getFeedAsEmbed(agent, fetchDid, extLink.uri).then(
({embed, meta}) => {
if (aborted) {
return
}
setExtLink({
uri: extLink.uri,
isLoading: false,
meta,
embed,
})
},
err => {
logger.error('Failed to fetch feed for embedding', {message: err})
setExtLink(undefined)
},
)
} else if (isBskyListUrl(extLink.uri)) {
getListAsEmbed(agent, fetchDid, extLink.uri).then(
({embed, meta}) => {
if (aborted) {
return
}
setExtLink({
uri: extLink.uri,
isLoading: false,
meta,
embed,
})
},
err => {
logger.error('Failed to fetch list for embedding', {message: err})
setExtLink(undefined)
},
)
} else if (
isBskyStartUrl(extLink.uri) ||
isBskyStarterPackUrl(extLink.uri)
) {
getStarterPackAsEmbed(agent, fetchDid, extLink.uri).then(
({embed, meta}) => {
if (aborted) {
return
}
setExtLink({
uri: extLink.uri,
isLoading: false,
meta,
embed,
})
},
)
} else if (isShortLink(extLink.uri)) {
if (isShortLink(extLink.uri)) {
resolveShortLink(extLink.uri).then(res => {
if (res && res !== extLink.uri) {
setExtLink({
uri: res,
isLoading: true,
})
}
})
}
} else {
getLinkMeta(agent, extLink.uri).then(meta => {
if (aborted) {
return
}
setExtLink({
uri: extLink.uri,
isLoading: !!meta.image,
meta,
})
})
}
return cleanup
}
if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) {
downloadAndResize({
uri: extLink.meta.image,
width: POST_IMG_MAX.width,
height: POST_IMG_MAX.height,
mode: 'contain',
maxSize: POST_IMG_MAX.size,
timeout: 15e3,
})
.catch(() => undefined)
.then(thumb => (thumb ? createComposerImage(thumb) : undefined))
.then(thumb => {
if (aborted) {
return
}
setExtLink({
...extLink,
isLoading: false, // done
localThumb: thumb,
})
})
return cleanup
}
if (extLink.isLoading) {
setExtLink({
...extLink,
isLoading: false, // done
})
}
return cleanup
}, [_, extLink, setQuote, getPost, fetchDid, agent, setError])
return {extLink, setExtLink}
}
+2 -2
View File
@@ -18,6 +18,8 @@ import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {POST_CTRL_HITSLOP} from '#/lib/constants' import {POST_CTRL_HITSLOP} from '#/lib/constants'
import {CountWheel} from '#/lib/custom-animations/CountWheel'
import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {shareUrl} from '#/lib/sharing' import {shareUrl} from '#/lib/sharing'
@@ -35,8 +37,6 @@ import {
ProgressGuideAction, ProgressGuideAction,
useProgressGuideControls, useProgressGuideControls,
} from '#/state/shell/progress-guide' } from '#/state/shell/progress-guide'
import {CountWheel} from 'lib/custom-animations/CountWheel'
import {AnimatedLikeIcon} from 'lib/custom-animations/LikeIcon'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox' import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
@@ -5,6 +5,7 @@ import {AppBskyEmbedExternal} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {parseAltFromGIFDescription} from '#/lib/gif-alt-text'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {shareUrl} from '#/lib/sharing' import {shareUrl} from '#/lib/sharing'
@@ -55,7 +56,16 @@ export const ExternalLinkEmbed = ({
}, [link.uri, externalEmbedPrefs]) }, [link.uri, externalEmbedPrefs])
if (embedPlayerParams?.source === 'tenor') { if (embedPlayerParams?.source === 'tenor') {
return <GifEmbed params={embedPlayerParams} link={link} hideAlt={hideAlt} /> const parsedAlt = parseAltFromGIFDescription(link.description)
return (
<GifEmbed
params={embedPlayerParams}
thumb={link.thumb}
altText={parsedAlt.alt}
isPreferredAltText={parsedAlt.isPreferred}
hideAlt={hideAlt}
/>
)
} }
return ( return (
+9 -12
View File
@@ -7,12 +7,10 @@ import {
View, View,
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
import {AppBskyEmbedExternal} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {HITSLOP_20} from '#/lib/constants' import {HITSLOP_20} from '#/lib/constants'
import {parseAltFromGIFDescription} from '#/lib/gif-alt-text'
import {EmbedPlayerParams} from '#/lib/strings/embed-player' import {EmbedPlayerParams} from '#/lib/strings/embed-player'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {useAutoplayDisabled} from '#/state/preferences' import {useAutoplayDisabled} from '#/state/preferences'
@@ -77,12 +75,16 @@ function PlaybackControls({
export function GifEmbed({ export function GifEmbed({
params, params,
link, thumb,
altText,
isPreferredAltText,
hideAlt, hideAlt,
style = {width: '100%'}, style = {width: '100%'},
}: { }: {
params: EmbedPlayerParams params: EmbedPlayerParams
link: AppBskyEmbedExternal.ViewExternal thumb: string | undefined
altText: string
isPreferredAltText: boolean
hideAlt?: boolean hideAlt?: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
@@ -111,11 +113,6 @@ export function GifEmbed({
playerRef.current?.toggleAsync() playerRef.current?.toggleAsync()
}, []) }, [])
const parsedAlt = React.useMemo(
() => parseAltFromGIFDescription(link.description),
[link],
)
return ( return (
<View style={[a.rounded_md, a.overflow_hidden, a.mt_sm, style]}> <View style={[a.rounded_md, a.overflow_hidden, a.mt_sm, style]}>
<View <View
@@ -131,13 +128,13 @@ export function GifEmbed({
/> />
<GifView <GifView
source={params.playerUri} source={params.playerUri}
placeholderSource={link.thumb} placeholderSource={thumb}
style={[a.flex_1, a.rounded_md]} style={[a.flex_1, a.rounded_md]}
autoplay={!autoplayDisabled} autoplay={!autoplayDisabled}
onPlayerStateChange={onPlayerStateChange} onPlayerStateChange={onPlayerStateChange}
ref={playerRef} ref={playerRef}
accessibilityHint={_(msg`Animated GIF`)} accessibilityHint={_(msg`Animated GIF`)}
accessibilityLabel={parsedAlt.alt} accessibilityLabel={altText}
/> />
{!playerState.isPlaying && ( {!playerState.isPlaying && (
<Fill <Fill
@@ -150,7 +147,7 @@ export function GifEmbed({
/> />
)} )}
<MediaInsetBorder /> <MediaInsetBorder />
{!hideAlt && parsedAlt.isPreferred && <AltText text={parsedAlt.alt} />} {!hideAlt && isPreferredAltText && <AltText text={altText} />}
</View> </View>
</View> </View>
) )
@@ -31,6 +31,7 @@ import {makeProfileLink} from '#/lib/routes/links'
import {s} from '#/lib/styles' import {s} from '#/lib/styles'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {precacheProfile} from '#/state/queries/profile' import {precacheProfile} from '#/state/queries/profile'
import {useResolveLinkQuery} from '#/state/queries/resolve-link'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {ComposerOptsQuote} from '#/state/shell/composer' import {ComposerOptsQuote} from '#/state/shell/composer'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
@@ -286,6 +287,24 @@ export function QuoteX({onRemove}: {onRemove: () => void}) {
) )
} }
export function LazyQuoteEmbed({uri}: {uri: string}) {
const {data} = useResolveLinkQuery(uri)
if (!data || data.type !== 'record' || data.kind !== 'post') {
return null
}
return (
<QuoteEmbed
quote={{
cid: data.record.cid,
uri: data.record.uri,
author: data.meta.author,
indexedAt: data.meta.indexedAt,
text: data.meta.text,
}}
/>
)
}
function viewRecordToPostView( function viewRecordToPostView(
viewRecord: AppBskyEmbedRecord.ViewRecord, viewRecord: AppBskyEmbedRecord.ViewRecord,
): AppBskyFeedDefs.PostView { ): AppBskyFeedDefs.PostView {