Files
bsky-social-app/src/components/dialogs/Embed.tsx
T
Samuel Newman fa72f7684b scope web-only dialog width caps to web
now that android tablet sheets lay content out at the real 640dp canvas,
unscoped maxWidth caps on dialog content (previously inert on phones)
became visible as narrow left-aligned content. wrap them in web() to
match the established pattern (BlockDialog, ModerationDetailsDialog).
also covers gtMobile-gated caps, which were live on android tablets
because useBreakpoints has no native split.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 16:33:57 +03:00

261 lines
8.5 KiB
TypeScript

import {memo, useEffect, useMemo, useState} from 'react'
import {View} from 'react-native'
import {AtUri} from '@atproto/syntax'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {EMBED_SCRIPT} from '#/lib/constants'
import {niceDate} from '#/lib/strings/time'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as SegmentedControl from '#/components/forms/SegmentedControl'
import * as TextField from '#/components/forms/TextField'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon,
ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon,
} from '#/components/icons/Chevron'
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
import {Text} from '#/components/Typography'
import {type app} from '#/lexicons'
export type ColorModeValues = 'system' | 'light' | 'dark'
type EmbedDialogProps = {
control: Dialog.DialogControlProps
postAuthor: app.bsky.actor.defs.ProfileViewBasic
postCid: string
postUri: string
record: app.bsky.feed.post.Main
timestamp: string
}
let EmbedDialog = ({control, ...rest}: EmbedDialogProps): React.ReactNode => {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<EmbedDialogInner {...rest} />
</Dialog.Outer>
)
}
EmbedDialog = memo(EmbedDialog)
export {EmbedDialog}
function EmbedDialogInner({
postAuthor,
postCid,
postUri,
record,
timestamp,
}: Omit<EmbedDialogProps, 'control'>) {
const t = useTheme()
const {_, i18n} = useLingui()
const [copied, setCopied] = useState(false)
const [showCustomisation, setShowCustomisation] = useState(false)
const [colorMode, setColorMode] = useState<ColorModeValues>('system')
// reset copied state after 2 seconds
useEffect(() => {
if (copied) {
const timeout = setTimeout(() => {
setCopied(false)
}, 2000)
return () => clearTimeout(timeout)
}
}, [copied])
const snippet = useMemo(() => {
function toEmbedUrl(href: string) {
return toShareUrl(href) + '?ref_src=embed'
}
const lang = record.langs && record.langs.length > 0 ? record.langs[0] : ''
const profileHref = toEmbedUrl(['/profile', postAuthor.did].join('/'))
const urip = new AtUri(postUri)
const href = toEmbedUrl(
['/profile', postAuthor.did, 'post', urip.rkey].join('/'),
)
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
// DO NOT ADD ANY NEW INTERPOLATIONS BELOW WITHOUT ESCAPING THEM!
// Also, keep this code synced with the bskyembed code in landing.tsx.
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
postUri,
)}" data-bluesky-cid="${escapeHtml(
postCid,
)}" data-bluesky-embed-color-mode="${escapeHtml(
colorMode,
)}"><p lang="${escapeHtml(lang)}">${escapeHtml(record.text)}${
record.embed
? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
: ''
}</p>&mdash; ${escapeHtml(
postAuthor.displayName || postAuthor.handle,
)} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
postAuthor.handle,
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
niceDate(i18n, timestamp),
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
}, [i18n, postUri, postCid, record, timestamp, postAuthor, colorMode])
return (
<Dialog.Inner label={_(msg`Embed post`)} style={[web({maxWidth: 500})]}>
<View style={[a.gap_lg]}>
<View style={[a.gap_sm]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Embed post</Trans>
</Text>
<Text
style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}>
<Trans>
Embed this post in your website. Simply copy the following snippet
and paste it into the HTML code of your website.
</Trans>
</Text>
</View>
<View
style={[a.border, t.atoms.border_contrast_low, {borderRadius: 18}]}>
<Button
label={
showCustomisation
? _(msg`Hide customization options`)
: _(msg`Show customization options`)
}
color="secondary"
variant="ghost"
size="small"
shape="default"
onPress={() => setShowCustomisation(c => !c)}
style={[
a.justify_start,
showCustomisation && t.atoms.bg_contrast_25,
]}>
<ButtonIcon
icon={showCustomisation ? ChevronBottomIcon : ChevronRightIcon}
/>
<ButtonText>
<Trans>Customization options</Trans>
</ButtonText>
</Button>
{showCustomisation && (
<View style={[a.gap_sm, a.p_md]}>
<Text style={[t.atoms.text_contrast_medium, a.font_semi_bold]}>
<Trans>Color theme</Trans>
</Text>
<SegmentedControl.Root
label={_(msg`Color mode`)}
type="radio"
value={colorMode}
onChange={setColorMode}>
<SegmentedControl.Item value="system" label={_(msg`System`)}>
<SegmentedControl.ItemText>
<Trans>System</Trans>
</SegmentedControl.ItemText>
</SegmentedControl.Item>
<SegmentedControl.Item value="light" label={_(msg`Light`)}>
<SegmentedControl.ItemText>
<Trans>Light</Trans>
</SegmentedControl.ItemText>
</SegmentedControl.Item>
<SegmentedControl.Item value="dark" label={_(msg`Dark`)}>
<SegmentedControl.ItemText>
<Trans>Dark</Trans>
</SegmentedControl.ItemText>
</SegmentedControl.Item>
</SegmentedControl.Root>
</View>
)}
</View>
<View style={[a.flex_row, a.gap_sm]}>
<View style={[a.flex_1]}>
<TextField.Root>
<TextField.Icon icon={CodeBracketsIcon} />
<TextField.Input
label={_(msg`Embed HTML code`)}
editable={false}
selection={{start: 0, end: snippet.length}}
value={snippet}
/>
</TextField.Root>
</View>
<Button
label={_(msg`Copy code`)}
color="primary"
size="large"
onPress={() => {
void navigator.clipboard.writeText(snippet)
setCopied(true)
}}>
{copied ? (
<>
<ButtonIcon icon={CheckIcon} />
<ButtonText>
<Trans>Copied!</Trans>
</ButtonText>
</>
) : (
<ButtonText>
<Trans>Copy code</Trans>
</ButtonText>
)}
</Button>
</View>
</View>
<Dialog.Close />
</Dialog.Inner>
)
}
/**
* Based on a snippet of code from React, which itself was based on the escape-html library.
* Copyright (c) Meta Platforms, Inc. and affiliates
* Copyright (c) 2012-2013 TJ Holowaychuk
* Copyright (c) 2015 Andreas Lubbe
* Copyright (c) 2015 Tiancheng "Timothy" Gu
* Licensed as MIT.
*/
const matchHtmlRegExp = /["'&<>]/
function escapeHtml(string: string) {
const str = String(string)
const match = matchHtmlRegExp.exec(str)
if (!match) {
return str
}
let escape
let html = ''
let index
let lastIndex = 0
for (index = match.index; index < str.length; index++) {
switch (str.charCodeAt(index)) {
case 34: // "
escape = '&quot;'
break
case 38: // &
escape = '&amp;'
break
case 39: // '
escape = '&#x27;'
break
case 60: // <
escape = '&lt;'
break
case 62: // >
escape = '&gt;'
break
default:
continue
}
if (lastIndex !== index) {
html += str.slice(lastIndex, index)
}
lastIndex = index + 1
html += escape
}
return lastIndex !== index ? html + str.slice(lastIndex, index) : html
}