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:
@@ -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,6 +126,7 @@ 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}>
|
||||||
|
<ComposerProvider>
|
||||||
<StatsigProvider>
|
<StatsigProvider>
|
||||||
<MessagesProvider>
|
<MessagesProvider>
|
||||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||||
@@ -154,6 +156,7 @@ function InnerApp() {
|
|||||||
</LabelDefsProvider>
|
</LabelDefsProvider>
|
||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
</StatsigProvider>
|
</StatsigProvider>
|
||||||
|
</ComposerProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
</VideoVolumeProvider>
|
</VideoVolumeProvider>
|
||||||
|
|||||||
@@ -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,6 +117,7 @@ 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}>
|
||||||
|
<ComposerProvider>
|
||||||
<StatsigProvider>
|
<StatsigProvider>
|
||||||
<MessagesProvider>
|
<MessagesProvider>
|
||||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||||
@@ -143,6 +145,7 @@ function InnerApp() {
|
|||||||
</LabelDefsProvider>
|
</LabelDefsProvider>
|
||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
</StatsigProvider>
|
</StatsigProvider>
|
||||||
|
</ComposerProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|||||||
+38
-9
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
|
||||||
? {
|
|
||||||
aspectRatio:
|
|
||||||
gif.media_formats.gif.dims[0] / gif.media_formats.gif.dims[1],
|
|
||||||
width: '100%',
|
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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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})]}
|
||||||
|
|||||||
@@ -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}
|
|
||||||
}
|
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user