Merge remote-tracking branch 'origin/main' into rn-0.75
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
import {useFonts as defaultUseFonts} from 'expo-font'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {Device, device} from '#/storage'
|
||||
|
||||
@@ -34,39 +32,6 @@ export function setFontFamily(fontFamily: Device['fontFamily']) {
|
||||
device.set(['fontFamily'], fontFamily)
|
||||
}
|
||||
|
||||
/*
|
||||
* IMPORTANT: This is unused. Expo statically extracts these fonts, but we load
|
||||
* them manually so that we can parallelize the loading along with the JS
|
||||
* bundle.
|
||||
*
|
||||
* See `#/alf/util/useFonts` for the actually used hooks.
|
||||
*
|
||||
* All used fonts MUST be configured here. Unused fonts are commented out, but
|
||||
* the files are there if we need them.
|
||||
*/
|
||||
export function DO_NOT_USE() {
|
||||
return defaultUseFonts({
|
||||
// 'Inter-Thin': require('../../assets/fonts/inter/Inter-Thin.otf'),
|
||||
// 'Inter-ThinItalic': require('../../assets/fonts/inter/Inter-ThinItalic.otf'),
|
||||
// 'Inter-ExtraLight': require('../../assets/fonts/inter/Inter-ExtraLight.otf'),
|
||||
// 'Inter-ExtraLightItalic': require('../../assets/fonts/inter/Inter-ExtraLightItalic.otf'),
|
||||
// 'Inter-Light': require('../../assets/fonts/inter/Inter-Light.otf'),
|
||||
// 'Inter-LightItalic': require('../../assets/fonts/inter/Inter-LightItalic.otf'),
|
||||
'Inter-Regular': require('../../assets/fonts/inter/Inter-Regular.otf'),
|
||||
'Inter-Italic': require('../../assets/fonts/inter/Inter-Italic.otf'),
|
||||
// 'Inter-Medium': require('../../assets/fonts/inter/Inter-Medium.otf'),
|
||||
// 'Inter-MediumItalic': require('../../assets/fonts/inter/Inter-MediumItalic.otf'),
|
||||
'Inter-SemiBold': require('../../assets/fonts/inter/Inter-SemiBold.otf'),
|
||||
'Inter-SemiBoldItalic': require('../../assets/fonts/inter/Inter-SemiBoldItalic.otf'),
|
||||
// 'Inter-Bold': require('../../assets/fonts/inter/Inter-Bold.otf'),
|
||||
// 'Inter-BoldItalic': require('../../assets/fonts/inter/Inter-BoldItalic.otf'),
|
||||
'Inter-ExtraBold': require('../../assets/fonts/inter/Inter-ExtraBold.otf'),
|
||||
'Inter-ExtraBoldItalic': require('../../assets/fonts/inter/Inter-ExtraBoldItalic.otf'),
|
||||
// 'Inter-Black': require('../../assets/fonts/inter/Inter-Black.otf'),
|
||||
// 'Inter-BlackItalic': require('../../assets/fonts/inter/Inter-BlackItalic.otf'),
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* Unused fonts are commented out, but the files are there if we need them.
|
||||
*/
|
||||
|
||||
+1
-1
@@ -183,7 +183,7 @@ export function createThemes({
|
||||
} as const
|
||||
|
||||
const darkPalette: Palette = {
|
||||
white: color.gray_0,
|
||||
white: color.gray_25,
|
||||
black: color.trueBlack,
|
||||
|
||||
contrast_25: color.gray_975,
|
||||
|
||||
@@ -11,17 +11,17 @@ import {msg, plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {logger} from '#/logger'
|
||||
import {precacheFeedFromGeneratorView} from '#/state/queries/feed'
|
||||
import {
|
||||
useAddSavedFeedsMutation,
|
||||
usePreferencesQuery,
|
||||
useRemoveFeedMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {precacheFeedFromGeneratorView} from 'state/queries/feed'
|
||||
import {useSession} from 'state/session'
|
||||
import {useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
@@ -121,7 +121,10 @@ export function TitleAndByline({
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
|
||||
<Text
|
||||
emoji
|
||||
style={[a.text_md, a.font_bold, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{creator && (
|
||||
|
||||
@@ -5,7 +5,7 @@ import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Link, LinkProps} from '#/components/Link'
|
||||
@@ -185,11 +185,11 @@ function KnownFollowersInner({
|
||||
serverCount > 2 ? (
|
||||
<Trans>
|
||||
Followed by{' '}
|
||||
<Text key={slice[0].profile.did} style={textStyle}>
|
||||
<Text emoji key={slice[0].profile.did} style={textStyle}>
|
||||
{slice[0].profile.displayName}
|
||||
</Text>
|
||||
,{' '}
|
||||
<Text key={slice[1].profile.did} style={textStyle}>
|
||||
<Text emoji key={slice[1].profile.did} style={textStyle}>
|
||||
{slice[1].profile.displayName}
|
||||
</Text>
|
||||
, and{' '}
|
||||
@@ -203,11 +203,11 @@ function KnownFollowersInner({
|
||||
// only 2
|
||||
<Trans>
|
||||
Followed by{' '}
|
||||
<Text key={slice[0].profile.did} style={textStyle}>
|
||||
<Text emoji key={slice[0].profile.did} style={textStyle}>
|
||||
{slice[0].profile.displayName}
|
||||
</Text>{' '}
|
||||
and{' '}
|
||||
<Text key={slice[1].profile.did} style={textStyle}>
|
||||
<Text emoji key={slice[1].profile.did} style={textStyle}>
|
||||
{slice[1].profile.displayName}
|
||||
</Text>
|
||||
</Trans>
|
||||
@@ -216,7 +216,7 @@ function KnownFollowersInner({
|
||||
// 1-n followers, including blocks
|
||||
<Trans>
|
||||
Followed by{' '}
|
||||
<Text key={slice[0].profile.did} style={textStyle}>
|
||||
<Text emoji key={slice[0].profile.did} style={textStyle}>
|
||||
{slice[0].profile.displayName}
|
||||
</Text>{' '}
|
||||
and{' '}
|
||||
@@ -230,7 +230,7 @@ function KnownFollowersInner({
|
||||
// only 1
|
||||
<Trans>
|
||||
Followed by{' '}
|
||||
<Text key={slice[0].profile.did} style={textStyle}>
|
||||
<Text emoji key={slice[0].profile.did} style={textStyle}>
|
||||
{slice[0].profile.displayName}
|
||||
</Text>
|
||||
</Trans>
|
||||
|
||||
@@ -44,17 +44,22 @@ export function Avatar({avatar}: {avatar?: string}) {
|
||||
}
|
||||
|
||||
export function Title({value}: {value: string}) {
|
||||
return <Text style={[a.text_md, a.font_bold, a.leading_tight]}>{value}</Text>
|
||||
return (
|
||||
<Text emoji style={[a.text_md, a.font_bold, a.leading_tight]}>
|
||||
{value}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function Description({value, handle}: {value?: string; handle: string}) {
|
||||
const {_} = useLingui()
|
||||
return value ? (
|
||||
<Text numberOfLines={2}>
|
||||
<RichText value={value} style={[a.leading_snug]} />
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={[a.leading_snug]}>
|
||||
<Trans>By {sanitizeHandle(handle, '@')}</Trans>
|
||||
<Text emoji style={[a.leading_snug]}>
|
||||
{_(msg`By ${sanitizeHandle(handle, '@')}`)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
+12
-12
@@ -7,13 +7,14 @@ import {
|
||||
moderateUserList,
|
||||
ModerationUI,
|
||||
} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {useModerationOpts} from 'state/preferences/moderation-opts'
|
||||
import {precacheList} from 'state/queries/feed'
|
||||
import {useSession} from 'state/session'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {precacheList} from '#/state/queries/feed'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {
|
||||
Avatar,
|
||||
@@ -111,6 +112,7 @@ export function TitleAndByline({
|
||||
modUi?: ModerationUI
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
return (
|
||||
@@ -130,6 +132,7 @@ export function TitleAndByline({
|
||||
</Hider.Mask>
|
||||
<Hider.Content>
|
||||
<Text
|
||||
emoji
|
||||
style={[a.text_md, a.font_bold, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
{title}
|
||||
@@ -139,15 +142,12 @@ export function TitleAndByline({
|
||||
|
||||
{creator && (
|
||||
<Text
|
||||
emoji
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{purpose === MODLIST ? (
|
||||
<Trans>
|
||||
Moderation list by {sanitizeHandle(creator.handle, '@')}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||
)}
|
||||
{purpose === MODLIST
|
||||
? _(msg`Moderation list by ${sanitizeHandle(creator.handle, '@')}`)
|
||||
: _(msg`List by ${sanitizeHandle(creator.handle, '@')}`)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -130,6 +130,7 @@ export function Label({
|
||||
)}
|
||||
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
text,
|
||||
a.font_bold,
|
||||
|
||||
@@ -11,13 +11,13 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {LogEvents} from '#/lib/statsig/statsig'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {useSession} from 'state/session'
|
||||
import {useSession} from '#/state/session'
|
||||
import {ProfileCardPills} from '#/view/com/profile/ProfileCard'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {ProfileCardPills} from 'view/com/profile/ProfileCard'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
@@ -175,11 +175,13 @@ export function NameAndHandle({
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
|
||||
numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text
|
||||
emoji
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{handle}
|
||||
|
||||
@@ -256,6 +256,7 @@ function LabelerToggle({title}: {title: string}) {
|
||||
a.z_10,
|
||||
]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
native({marginTop: 2}),
|
||||
t.atoms.text_contrast_medium,
|
||||
|
||||
@@ -66,6 +66,7 @@ export function RichText({
|
||||
(flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier
|
||||
return (
|
||||
<Text
|
||||
emoji
|
||||
selectable={selectable}
|
||||
testID={testID}
|
||||
style={[plainStyles, {fontSize}]}
|
||||
@@ -77,6 +78,7 @@ export function RichText({
|
||||
}
|
||||
return (
|
||||
<Text
|
||||
emoji
|
||||
selectable={selectable}
|
||||
testID={testID}
|
||||
style={plainStyles}
|
||||
@@ -148,7 +150,11 @@ export function RichText({
|
||||
/>,
|
||||
)
|
||||
} else {
|
||||
els.push(segment.text)
|
||||
els.push(
|
||||
<Text key={key} emoji style={plainStyles}>
|
||||
{segment.text}
|
||||
</Text>,
|
||||
)
|
||||
}
|
||||
key++
|
||||
}
|
||||
@@ -213,6 +219,7 @@ function RichTextTag({
|
||||
<React.Fragment>
|
||||
<TagMenu control={control} tag={tag} authorHandle={authorHandle}>
|
||||
<Text
|
||||
emoji
|
||||
selectable={selectable}
|
||||
{...native({
|
||||
accessibilityLabel: _(msg`Hashtag: #${tag}`),
|
||||
|
||||
@@ -2,15 +2,15 @@ import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {AppBskyGraphDefs, AppBskyGraphStarterpack, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
|
||||
import {precacheResolvedUri} from 'state/queries/resolve-uri'
|
||||
import {precacheStarterPack} from 'state/queries/starter-packs'
|
||||
import {useSession} from 'state/session'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
|
||||
import {precacheResolvedUri} from '#/state/queries/resolve-uri'
|
||||
import {precacheStarterPack} from '#/state/queries/starter-packs'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {StarterPack} from '#/components/icons/StarterPack'
|
||||
import {BaseLink} from '#/components/Link'
|
||||
@@ -66,21 +66,18 @@ export function Card({
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
{!noIcon ? <StarterPack width={40} gradient="sky" /> : null}
|
||||
<View>
|
||||
<Text style={[a.text_md, a.font_bold, a.leading_snug]}>
|
||||
<Text emoji style={[a.text_md, a.font_bold, a.leading_snug]}>
|
||||
{record.name}
|
||||
</Text>
|
||||
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Starter pack by{' '}
|
||||
{creator?.did === currentAccount?.did
|
||||
? _(msg`you`)
|
||||
: `@${sanitizeHandle(creator.handle)}`}
|
||||
</Trans>
|
||||
<Text emoji style={[a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
{creator?.did === currentAccount?.did
|
||||
? _(msg`Starter pack by you`)
|
||||
: _(msg`Starter pack by ${sanitizeHandle(creator.handle, '@')}`)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{!noDescription && record.description ? (
|
||||
<Text numberOfLines={3} style={[a.leading_snug]}>
|
||||
<Text emoji numberOfLines={3} style={[a.leading_snug]}>
|
||||
{record.description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -12,11 +12,11 @@ import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from 'lib/constants'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {useSession} from 'state/session'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from '#/lib/constants'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useSession} from '#/state/session'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
@@ -78,6 +78,7 @@ function WizardListCard({
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.font_bold,
|
||||
|
||||
@@ -1,15 +1,85 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, TextProps as RNTextProps, TextStyle} from 'react-native'
|
||||
import {UITextView} from 'react-native-uitextview'
|
||||
import createEmojiRegex from 'emoji-regex'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS, isNative} from '#/platform/detection'
|
||||
import {Alf, applyFonts, atoms, flatten, useAlf, useTheme, web} from '#/alf'
|
||||
import {IS_DEV} from '#/env'
|
||||
|
||||
export type TextProps = RNTextProps & {
|
||||
export type StringChild = string | (string | null)[]
|
||||
|
||||
export type TextProps = Omit<RNTextProps, 'children'> & {
|
||||
/**
|
||||
* Lets the user select text, to use the native copy and paste functionality.
|
||||
*/
|
||||
selectable?: boolean
|
||||
/**
|
||||
* Provides `data-*` attributes to the underlying `UITextView` component on
|
||||
* web only.
|
||||
*/
|
||||
dataSet?: Record<string, string | number | undefined>
|
||||
/**
|
||||
* Appears as a small tooltip on web hover.
|
||||
*/
|
||||
title?: string
|
||||
} & (
|
||||
| {
|
||||
emoji: true
|
||||
children: StringChild
|
||||
}
|
||||
| {
|
||||
emoji?: false
|
||||
children: RNTextProps['children']
|
||||
}
|
||||
)
|
||||
|
||||
const EMOJI = createEmojiRegex()
|
||||
|
||||
export function childHasEmoji(children: React.ReactNode) {
|
||||
return (Array.isArray(children) ? children : [children]).some(
|
||||
child => typeof child === 'string' && createEmojiRegex().test(child),
|
||||
)
|
||||
}
|
||||
|
||||
export function childIsString(
|
||||
children: React.ReactNode,
|
||||
): children is StringChild {
|
||||
return (
|
||||
typeof children === 'string' ||
|
||||
(Array.isArray(children) &&
|
||||
children.every(child => typeof child === 'string' || child === null))
|
||||
)
|
||||
}
|
||||
|
||||
export function renderChildrenWithEmoji(children: StringChild) {
|
||||
const normalized = Array.isArray(children) ? children : [children]
|
||||
|
||||
return (
|
||||
<UITextView>
|
||||
{normalized.map(child => {
|
||||
if (typeof child !== 'string') return child
|
||||
|
||||
const emojis = child.match(EMOJI)
|
||||
|
||||
if (emojis === null) {
|
||||
return child
|
||||
}
|
||||
|
||||
return child.split(EMOJI).map((stringPart, index) => (
|
||||
<UITextView key={index}>
|
||||
{stringPart}
|
||||
{emojis[index] ? (
|
||||
<UITextView style={{color: 'black', fontFamily: 'System'}}>
|
||||
{emojis[index]}
|
||||
</UITextView>
|
||||
) : null}
|
||||
</UITextView>
|
||||
))
|
||||
})}
|
||||
</UITextView>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,7 +134,15 @@ export function normalizeTextStyles(
|
||||
/**
|
||||
* Our main text component. Use this most of the time.
|
||||
*/
|
||||
export function Text({style, selectable, ...rest}: TextProps) {
|
||||
export function Text({
|
||||
children,
|
||||
emoji,
|
||||
style,
|
||||
selectable,
|
||||
title,
|
||||
dataSet,
|
||||
...rest
|
||||
}: TextProps) {
|
||||
const {fonts, flags} = useAlf()
|
||||
const t = useTheme()
|
||||
const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)], {
|
||||
@@ -73,7 +151,29 @@ export function Text({style, selectable, ...rest}: TextProps) {
|
||||
flags,
|
||||
})
|
||||
|
||||
return <UITextView selectable={selectable} uiTextView style={s} {...rest} />
|
||||
if (IS_DEV) {
|
||||
if (!emoji && childHasEmoji(children)) {
|
||||
logger.warn(
|
||||
`Text: emoji detected but emoji not enabled: "${children}"\n\nPlease add <Text emoji />'`,
|
||||
)
|
||||
}
|
||||
|
||||
if (emoji && !childIsString(children)) {
|
||||
logger.error('Text: when <Text emoji />, children can only be strings.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<UITextView
|
||||
selectable={selectable}
|
||||
uiTextView
|
||||
style={s}
|
||||
{...rest}
|
||||
// @ts-ignore
|
||||
dataSet={Object.assign({tooltip: title}, dataSet || {})}>
|
||||
{isIOS && emoji ? renderChildrenWithEmoji(children) : children}
|
||||
</UITextView>
|
||||
)
|
||||
}
|
||||
|
||||
export function createHeadingElement({level}: {level: number}) {
|
||||
|
||||
@@ -106,16 +106,18 @@ function EmbedDialogInner({
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={CodeBrackets} />
|
||||
<TextField.Input
|
||||
label={_(msg`Embed HTML code`)}
|
||||
editable={false}
|
||||
selection={{start: 0, end: snippet.length}}
|
||||
value={snippet}
|
||||
style={{}}
|
||||
/>
|
||||
</TextField.Root>
|
||||
<View style={[a.flex_1]}>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={CodeBrackets} />
|
||||
<TextField.Input
|
||||
label={_(msg`Embed HTML code`)}
|
||||
editable={false}
|
||||
selection={{start: 0, end: snippet.length}}
|
||||
value={snippet}
|
||||
style={{}}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Copy code`)}
|
||||
color="primary"
|
||||
|
||||
@@ -10,14 +10,14 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {BACK_HITSLOP} from 'lib/constants'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {isConvoActive, useConvo} from 'state/messages/convo'
|
||||
import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {BACK_HITSLOP} from '#/lib/constants'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {isConvoActive, useConvo} from '#/state/messages/convo'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
|
||||
@@ -170,6 +170,7 @@ function HeaderReady({
|
||||
</View>
|
||||
<View style={a.flex_1}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {mergeRefs} from '#/lib/merge-refs'
|
||||
import {HITSLOP_20} from 'lib/constants'
|
||||
import {android, atoms as a, useTheme, web} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
@@ -73,7 +73,7 @@ export function Root({children, isInvalid = false}: RootProps) {
|
||||
return (
|
||||
<Context.Provider value={context}>
|
||||
<View
|
||||
style={[a.flex_row, a.align_center, a.relative, a.flex_1, a.px_md]}
|
||||
style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]}
|
||||
{...web({
|
||||
onClick: () => inputRef.current?.focus(),
|
||||
onMouseOver: onHoverIn,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Accessibility_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm8-10C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 7.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm-2.86.26.014.002c.944.125 1.893.238 2.846.238.95 0 1.904-.113 2.846-.238l.014-.002h.003a1 1 0 0 1 .273 1.98l-.006.002-.017.002c-.67.089-1.341.162-2.014.21.195 1.32.65 2.33 1.626 3.357a1 1 0 0 1-1.45 1.378 8.3 8.3 0 0 1-1.234-1.647 8.2 8.2 0 0 1-1.342 1.673 1 1 0 0 1-1.398-1.43c.673-.658 1.088-1.274 1.342-1.922.163-.42.269-.878.32-1.404a33 33 0 0 1-2.075-.215l-.017-.002-.006-.001a1 1 0 0 1 .271-1.982l.004.001Z',
|
||||
})
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const ArrowBoxLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3.293 3.293A1 1 0 0 1 4 3h7.25a1 1 0 1 1 0 2H5v14h6.25a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 .293-.707Zm11.5 3.5a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z',
|
||||
})
|
||||
|
||||
export const ArrowBoxLeft_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M6 5a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h5.25a1 1 0 1 1 0 2H6a3 3 0 0 1-3-3V6a3 3 0 0 1 3-3h5.25a1 1 0 1 1 0 2H6Zm8.793 1.793a1 1 0 0 1 1.414 0l4.5 4.5a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414-1.414L17.586 13H8.75a1 1 0 1 1 0-2h8.836l-2.793-2.793a1 1 0 0 1 0-1.414Z',
|
||||
})
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const At_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.958 9.958 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.207 4.207 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458.358-2.547 2.514-4.586 5.044-4.23.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.222 2.222 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529-.24 1.71.784 3.03 1.98 3.198 1.195.168 2.543-.819 2.784-2.529.24-1.71-.784-3.03-1.98-3.198Z',
|
||||
path: 'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.96 9.96 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.2 4.2 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458s2.514-4.586 5.044-4.23c.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.22 2.22 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529s.784 3.03 1.98 3.198 2.543-.819 2.784-2.529-.784-3.03-1.98-3.198Z',
|
||||
})
|
||||
|
||||
export const At_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.96 9.96 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.2 4.2 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458s2.514-4.586 5.044-4.23c.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.22 2.22 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529s.784 3.03 1.98 3.198 2.544-.819 2.784-2.529-.784-3.03-1.98-3.198Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const BirthdayCake_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'm12 .757 2.122 2.122A3 3 0 0 1 13 7.829V9h4.5a3 3 0 0 1 3 3v1.646c0 .603-.18 1.177-.5 1.658V19a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-3.696a3 3 0 0 1-.5-1.658V12a3 3 0 0 1 3-3H11V7.829a3 3 0 0 1-1.121-4.95L12 .757ZM6.5 11a1 1 0 0 0-1 1v1.646a1 1 0 0 0 .629.928l.5.2a1 1 0 0 0 .742 0l1.015-.405a3 3 0 0 1 2.228 0l1.015.405a1 1 0 0 0 .742 0l1.015-.405a3 3 0 0 1 2.228 0l1.015.405a1 1 0 0 0 .742 0l.5-.2a1 1 0 0 0 .629-.928V12a1 1 0 0 0-1-1h-11ZM6 16.674V19a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-2.326a3 3 0 0 1-2.114-.043l-1.015-.405a1 1 0 0 0-.742 0l-1.015.405a3 3 0 0 1-2.228 0l-1.015-.405a1 1 0 0 0-.742 0l-1.015.405A3 3 0 0 1 6 16.674ZM12.002 6a1 1 0 0 0 .706-1.707L12 3.586l-.707.707A1 1 0 0 0 12.002 6Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const BubbleInfo_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M6.002 5h12a1 1 0 0 1 1 1v10.036a1 1 0 0 1-1 1h-2.626a2 2 0 0 0-1.276.46l-2.098 1.738-2.065-1.731a2 2 0 0 0-1.285-.467h-2.65a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm12-2h-12a3 3 0 0 0-3 3v10.036a3 3 0 0 0 3 3h2.65l2.704 2.266a1 1 0 0 0 1.28.004l2.74-2.27h2.626a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3ZM13 11.75a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0v-2ZM12 10a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const CircleQuestion_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Z" clip-rule="evenodd"/><path fill="#000" fill-rule="evenodd" d="M12 9a1 1 0 0 0-.879.522 1 1 0 0 1-1.754-.96A3 3 0 0 1 12 7c1.515 0 2.567 1.006 2.866 2.189.302 1.189-.156 2.574-1.524 3.258A.62.62 0 0 0 13 13a1 1 0 1 1-2 0c0-.992.56-1.898 1.447-2.342.455-.227.572-.618.48-.978C12.836 9.314 12.529 9 12 9Z',
|
||||
})
|
||||
@@ -7,3 +7,7 @@ export const Envelope_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
export const Envelope_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 11.708 2.654 4.06A.998.998 0 0 1 3 4h18c.122 0 .238.022.346.061L12 11.708ZM2 19V6.11l9.367 7.664a1 1 0 0 0 1.266 0L22 6.11V19a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
|
||||
export const Envelope_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M6.161 4H17.84c.527 0 .982 0 1.356.03.395.033.789.104 1.167.297a3 3 0 0 1 1.311 1.311c.193.378.264.772.296 1.167.031.375.031.83.031 1.356v7.678c0 .527 0 .981-.03 1.356-.033.395-.104.789-.297 1.167a3 3 0 0 1-1.311 1.311c-.378.193-.772.264-1.167.296-.375.031-.83.031-1.357.031H6.162c-.527 0-.981 0-1.356-.03-.395-.033-.789-.104-1.167-.297a3 3 0 0 1-1.311-1.311c-.193-.378-.264-.772-.296-1.167A18 18 0 0 1 2 15.838V8.162c0-.527 0-.981.03-1.356.033-.395.104-.789.297-1.167a3 3 0 0 1 1.311-1.311c.378-.193.772-.264 1.167-.296C5.18 4 5.635 4 6.161 4ZM5.046 6.018l6.32 5.172a1 1 0 0 0 1.267 0l6.321-5.172A20 20 0 0 0 17.8 6H6.2c-.525 0-.88 0-1.154.018Zm14.953 1.73-6.1 4.99a3 3 0 0 1-3.799 0L4 7.748V15.8c0 .577 0 .949.024 1.232.022.272.06.372.085.422a1 1 0 0 0 .437.437c.05.025.15.063.422.085C5.25 18 5.623 18 6.2 18h11.6c.577 0 .949 0 1.232-.024.272-.022.372-.06.422-.085a1 1 0 0 0 .437-.437c.025-.05.063-.15.085-.422C20 16.75 20 16.377 20 15.8V7.747Z',
|
||||
})
|
||||
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const Eye_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3.135 12C5.413 16.088 8.77 18 12 18s6.587-1.912 8.865-6C18.587 7.912 15.23 6 12 6c-3.228 0-6.587 1.912-8.865 6ZM12 4c4.24 0 8.339 2.611 10.888 7.54a1 1 0 0 1 0 .92C20.338 17.388 16.24 20 12 20c-4.24 0-8.339-2.611-10.888-7.54a1 1 0 0 1 0-.92C3.662 6.612 7.76 4 12 4Zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z',
|
||||
})
|
||||
|
||||
export const Eye_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M12 6c-3.127 0-6.367 1.79-8.638 5.606a.77.77 0 0 0 0 .788C5.633 16.209 8.873 18 12 18s6.367-1.79 8.638-5.606a.77.77 0 0 0 0-.788C18.367 7.791 15.127 6 12 6Zm0-2c3.952 0 7.79 2.272 10.357 6.583a2.77 2.77 0 0 1 0 2.834C19.79 17.727 15.952 20 12 20s-7.79-2.272-10.357-6.583a2.77 2.77 0 0 1 0-2.834C4.21 6.273 8.048 4 12 4Zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z',
|
||||
})
|
||||
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const Lock_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M7 7a5 5 0 0 1 10 0v2h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h1V7Zm-1 4v9h12v-9H6Zm9-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
export const Lock_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M7 7a5 5 0 0 1 10 0v2a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7Zm0 4a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7Zm8-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const PaintRoller_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M6 6a3 3 0 0 1 3-3h9a3 3 0 0 1 3 3v2a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3H5v3a1 1 0 0 0 1 1h7a1 1 0 0 1 1 1v2.17c1.165.412 2 1.524 2 2.83v3a1 1 0 1 1-2 0v-3a1 1 0 1 0-2 0v3a1 1 0 1 1-2 0v-3c0-1.306.835-2.418 2-2.83V14H6a3 3 0 0 1-3-3V8a2 2 0 0 1 2-2h1Zm3-1a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H9Z',
|
||||
})
|
||||
@@ -4,6 +4,10 @@ export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z',
|
||||
})
|
||||
|
||||
export const Person_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM12 14c-2.95 0-5.163 1.733-6.08 4.21a.47.47 0 0 0 .09.493.9.9 0 0 0 .687.297h10.606a.9.9 0 0 0 .687-.297.47.47 0 0 0 .09-.493C17.163 15.732 14.95 14 12 14Zm-7.955 3.516C5.235 14.296 8.168 12 12 12s6.765 2.296 7.956 5.516c.34.92.107 1.828-.434 2.473A2.9 2.9 0 0 1 17.303 21H6.697a2.9 2.9 0 0 1-2.219-1.011 2.46 2.46 0 0 1-.433-2.473Z',
|
||||
})
|
||||
|
||||
export const PersonCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z',
|
||||
})
|
||||
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const Trash_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M7.416 5H3a1 1 0 0 0 0 2h1.064l.938 14.067A1 1 0 0 0 6 22h12a1 1 0 0 0 .998-.933L19.936 7H21a1 1 0 1 0 0-2h-4.416a5 5 0 0 0-9.168 0Zm2.348 0h4.472c-.55-.614-1.348-1-2.236-1-.888 0-1.687.386-2.236 1Zm6.087 2H6.07l.867 13h10.128l.867-13h-2.036a1 1 0 0 1-.044 0ZM10 10a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
export const Trash_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M7.416 5H3a1 1 0 0 0 0 2h1.064l.814 12.2A3 3 0 0 0 7.87 22h8.258a3 3 0 0 0 2.993-2.8L19.936 7H21a1 1 0 1 0 0-2h-4.416a5 5 0 0 0-9.168 0Zm2.348 0h4.472c-.55-.614-1.348-1-2.236-1s-1.687.386-2.236 1Zm6.087 2H6.07l.804 12.067a1 1 0 0 0 .998.933h8.258a1 1 0 0 0 .998-.933L17.93 7h-2.08ZM10 10a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Verified_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M10.467 2.698a2.03 2.03 0 0 1 3.066 0l1.358 1.564a.03.03 0 0 0 .027.01l2.046-.325a2.03 2.03 0 0 1 2.348 1.97l.036 2.071q0 .016.014.026l1.776 1.066a2.03 2.03 0 0 1 .532 3.018l-1.304 1.61a.03.03 0 0 0-.005.029l.675 1.957a2.03 2.03 0 0 1-1.533 2.655l-2.033.395a.03.03 0 0 0-.022.019l-.742 1.933a2.03 2.03 0 0 1-2.88 1.049l-1.811-1.005a.03.03 0 0 0-.03 0l-1.81 1.005a2.03 2.03 0 0 1-2.881-1.049l-.742-1.933a.03.03 0 0 0-.022-.02l-2.033-.394a2.03 2.03 0 0 1-1.533-2.655l.675-1.957a.03.03 0 0 0-.005-.03L2.33 12.099a2.03 2.03 0 0 1 .532-3.018l1.776-1.066a.03.03 0 0 0 .014-.026l.036-2.07a2.03 2.03 0 0 1 2.348-1.97l2.045.324a.03.03 0 0 0 .028-.01l1.358-1.564Zm1.52 1.304-.01.008-1.358 1.563a2.03 2.03 0 0 1-1.85.674l-2.046-.324H6.71l-.011.006-.009.01-.002.013-.036 2.07a2.03 2.03 0 0 1-.985 1.706l-1.775 1.066-.01.009-.004.012v.014q0 .003.006.01l1.304 1.61c.44.544.57 1.277.342 1.94l-.675 1.957-.002.012q0 .006.004.013l.01.01.01.005 2.034.394a2.03 2.03 0 0 1 1.509 1.266l.741 1.934q.004.01.007.01l.011.008.013.002q.003.001.012-.004l1.811-1.005a2.03 2.03 0 0 1 1.97 0l1.81 1.005.013.004.013-.003.011-.006.007-.011.742-1.934a2.03 2.03 0 0 1 1.508-1.266l2.033-.394.012-.005.009-.01.004-.012-.002-.013-.675-1.958a2.03 2.03 0 0 1 .342-1.94l1.304-1.609.006-.01v-.014l-.005-.012-.009-.009-1.775-1.066a2.03 2.03 0 0 1-.985-1.705l-.036-2.071-.002-.012-.009-.01-.011-.007h-.013l-2.045.324a2.03 2.03 0 0 1-1.85-.674l-1.36-1.563-.009-.008L12 4l-.013.002Zm3.22 5.79a1 1 0 0 1 0 1.415l-3.146 3.146a1.5 1.5 0 0 1-2.122 0l-1.146-1.146a1 1 0 1 1 1.414-1.414l.793.793 2.793-2.793a1 1 0 0 1 1.414 0Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Window_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M6 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3V6a3 3 0 0 0-3-3H6ZM5 18v-6h14v6a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1Zm0-8h14V6a1 1 0 0 0-1-1H6a1 1 0 0 0-1 1v4Zm6-3.5a1 1 0 1 0 0 2h6a1 1 0 1 0 0-2h-6ZM7.5 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z',
|
||||
})
|
||||
@@ -132,8 +132,10 @@ function Label({
|
||||
]}>
|
||||
<View style={[a.p_md, a.gap_sm, a.flex_row]}>
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<Text style={[a.font_bold, a.text_md]}>{strings.name}</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
<Text emoji style={[a.font_bold, a.text_md]}>
|
||||
{strings.name}
|
||||
</Text>
|
||||
<Text emoji style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
{strings.description}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -118,7 +118,11 @@ function ModerationDetailsDialogInner({
|
||||
: _(msg`The author of this thread has hidden this reply.`)
|
||||
} else if (modcause.type === 'label') {
|
||||
name = desc.name
|
||||
description = desc.description
|
||||
description = (
|
||||
<Text emoji style={[t.atoms.text, a.text_md, a.leading_snug]}>
|
||||
{desc.description}
|
||||
</Text>
|
||||
)
|
||||
} else {
|
||||
// should never happen
|
||||
name = ''
|
||||
@@ -127,7 +131,7 @@ function ModerationDetailsDialogInner({
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label={_(msg`Moderation details`)}>
|
||||
<Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}>
|
||||
<Text emoji style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text style={[t.atoms.text, a.text_md, a.leading_snug]}>
|
||||
|
||||
+16
-41
@@ -13,7 +13,10 @@ import {
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
import {logger} from '#/logger'
|
||||
import {ComposerImage, compressImage} from '#/state/gallery'
|
||||
import {writePostgateRecord} from '#/state/queries/postgate'
|
||||
import {
|
||||
createThreadgateRecord,
|
||||
@@ -21,12 +24,7 @@ import {
|
||||
threadgateAllowUISettingToAllowRecordValue,
|
||||
writeThreadgateRecord,
|
||||
} from '#/state/queries/threadgate'
|
||||
import {isNetworkError} from 'lib/strings/errors'
|
||||
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {ImageModel} from 'state/models/media/image'
|
||||
import {LinkMeta} from '../link-meta/link-meta'
|
||||
import {safeDeleteAsync} from '../media/manip'
|
||||
import {uploadBlob} from './upload-blob'
|
||||
|
||||
export {uploadBlob}
|
||||
@@ -36,7 +34,7 @@ export interface ExternalEmbedDraft {
|
||||
isLoading: boolean
|
||||
meta?: LinkMeta
|
||||
embed?: AppBskyEmbedRecord.Main
|
||||
localThumb?: ImageModel
|
||||
localThumb?: ComposerImage
|
||||
}
|
||||
|
||||
interface PostOpts {
|
||||
@@ -53,7 +51,7 @@ interface PostOpts {
|
||||
aspectRatio?: AppBskyEmbedDefs.AspectRatio
|
||||
}
|
||||
extLink?: ExternalEmbedDraft
|
||||
images?: ImageModel[]
|
||||
images?: ComposerImage[]
|
||||
labels?: string[]
|
||||
threadgate: ThreadgateAllowUISetting[]
|
||||
postgate: AppBskyFeedPostgate.Record
|
||||
@@ -99,18 +97,16 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
const images: AppBskyEmbedImages.Image[] = []
|
||||
for (const image of opts.images) {
|
||||
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
|
||||
|
||||
logger.debug(`Compressing image`)
|
||||
await image.compress()
|
||||
const path = image.compressed?.path ?? image.path
|
||||
const {width, height} = image.compressed || image
|
||||
const {path, width, height, mime} = await compressImage(image)
|
||||
|
||||
logger.debug(`Uploading image`)
|
||||
const res = await uploadBlob(agent, path, 'image/jpeg')
|
||||
if (isNative) {
|
||||
safeDeleteAsync(path)
|
||||
}
|
||||
const res = await uploadBlob(agent, path, mime)
|
||||
|
||||
images.push({
|
||||
image: res.data.blob,
|
||||
alt: image.altText ?? '',
|
||||
alt: image.alt,
|
||||
aspectRatio: {width, height},
|
||||
})
|
||||
}
|
||||
@@ -175,32 +171,11 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
let thumb
|
||||
if (opts.extLink.localThumb) {
|
||||
opts.onStateChange?.('Uploading link thumbnail...')
|
||||
let encoding
|
||||
if (opts.extLink.localThumb.mime) {
|
||||
encoding = opts.extLink.localThumb.mime
|
||||
} else if (opts.extLink.localThumb.path.endsWith('.png')) {
|
||||
encoding = 'image/png'
|
||||
} else if (
|
||||
opts.extLink.localThumb.path.endsWith('.jpeg') ||
|
||||
opts.extLink.localThumb.path.endsWith('.jpg')
|
||||
) {
|
||||
encoding = 'image/jpeg'
|
||||
} else {
|
||||
logger.warn('Unexpected image format for thumbnail, skipping', {
|
||||
thumbnail: opts.extLink.localThumb.path,
|
||||
})
|
||||
}
|
||||
if (encoding) {
|
||||
const thumbUploadRes = await uploadBlob(
|
||||
agent,
|
||||
opts.extLink.localThumb.path,
|
||||
encoding,
|
||||
)
|
||||
thumb = thumbUploadRes.data.blob
|
||||
if (isNative) {
|
||||
safeDeleteAsync(opts.extLink.localThumb.path)
|
||||
}
|
||||
}
|
||||
|
||||
const {path, mime} = opts.extLink.localThumb.source
|
||||
const res = await uploadBlob(agent, path, mime)
|
||||
|
||||
thumb = res.data.blob
|
||||
}
|
||||
|
||||
if (opts.quote) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import RNFS from 'react-native-fs'
|
||||
import {copyAsync} from 'expo-file-system'
|
||||
import {BskyAgent, ComAtprotoRepoUploadBlob} from '@atproto/api'
|
||||
|
||||
import {safeDeleteAsync} from '#/lib/media/manip'
|
||||
|
||||
/**
|
||||
* @param encoding Allows overriding the blob's type
|
||||
*/
|
||||
@@ -65,7 +67,7 @@ async function withSafeFile<T>(
|
||||
// temporary file).
|
||||
const newPath = uri.replace(/\.jpe?g$/, '.bin')
|
||||
try {
|
||||
await RNFS.copyFile(uri, newPath)
|
||||
await copyAsync({from: uri, to: newPath})
|
||||
} catch {
|
||||
// Failed to copy the file, just use the original
|
||||
return await fn(uri)
|
||||
@@ -74,7 +76,7 @@ async function withSafeFile<T>(
|
||||
return await fn(newPath)
|
||||
} finally {
|
||||
// Remove the temporary file
|
||||
await RNFS.unlink(newPath)
|
||||
await safeDeleteAsync(newPath)
|
||||
}
|
||||
} else {
|
||||
return fn(uri)
|
||||
|
||||
+16
-12
@@ -1,20 +1,24 @@
|
||||
import React from 'react'
|
||||
import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics'
|
||||
|
||||
import {isIOS, isWeb} from 'platform/detection'
|
||||
import {useHapticsDisabled} from 'state/preferences/disable-haptics'
|
||||
|
||||
const hapticImpact: ImpactFeedbackStyle = isIOS
|
||||
? ImpactFeedbackStyle.Medium
|
||||
: ImpactFeedbackStyle.Light // Users said the medium impact was too strong on Android; see APP-537s
|
||||
import {isIOS, isWeb} from '#/platform/detection'
|
||||
import {useHapticsDisabled} from '#/state/preferences/disable-haptics'
|
||||
|
||||
export function useHaptics() {
|
||||
const isHapticsDisabled = useHapticsDisabled()
|
||||
|
||||
return React.useCallback(() => {
|
||||
if (isHapticsDisabled || isWeb) {
|
||||
return
|
||||
}
|
||||
impactAsync(hapticImpact)
|
||||
}, [isHapticsDisabled])
|
||||
return React.useCallback(
|
||||
(strength: 'Light' | 'Medium' | 'Heavy' = 'Medium') => {
|
||||
if (isHapticsDisabled || isWeb) {
|
||||
return
|
||||
}
|
||||
|
||||
// Users said the medium impact was too strong on Android; see APP-537s
|
||||
const style = isIOS
|
||||
? ImpactFeedbackStyle[strength]
|
||||
: ImpactFeedbackStyle.Light
|
||||
impactAsync(style)
|
||||
},
|
||||
[isHapticsDisabled],
|
||||
)
|
||||
}
|
||||
|
||||
+67
-25
@@ -6,18 +6,20 @@ import {
|
||||
copyAsync,
|
||||
deleteAsync,
|
||||
EncodingType,
|
||||
getInfoAsync,
|
||||
makeDirectoryAsync,
|
||||
StorageAccessFramework,
|
||||
writeAsStringAsync,
|
||||
} from 'expo-file-system'
|
||||
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
|
||||
import * as MediaLibrary from 'expo-media-library'
|
||||
import * as Sharing from 'expo-sharing'
|
||||
// import ImageResizer from '@bam.tech/react-native-image-resizer'
|
||||
import {Buffer} from 'buffer'
|
||||
import RNFetchBlob from 'rn-fetch-blob'
|
||||
|
||||
import {POST_IMG_MAX} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {isAndroid, isIOS} from 'platform/detection'
|
||||
import {isAndroid, isIOS} from '#/platform/detection'
|
||||
import {Dimensions} from './types'
|
||||
|
||||
export async function compressIfNeeded(
|
||||
@@ -165,30 +167,48 @@ interface DoResizeOpts {
|
||||
}
|
||||
|
||||
async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> {
|
||||
// We need to get the dimensions of the image before we resize it. Previously, the library we used allowed us to enter
|
||||
// a "max size", and it would do the "best possible size" calculation for us.
|
||||
// Now instead, we have to supply the final dimensions to the manipulation function instead.
|
||||
// Performing an "empty" manipulation lets us get the dimensions of the original image. React Native's Image.getSize()
|
||||
// does not work for local files...
|
||||
const imageRes = await manipulateAsync(localUri, [], {})
|
||||
const newDimensions = getResizedDimensions({
|
||||
width: imageRes.width,
|
||||
height: imageRes.height,
|
||||
})
|
||||
|
||||
for (let i = 0; i < 9; i++) {
|
||||
// const quality = 100 - i * 10
|
||||
// const resizeRes = await ImageResizer.createResizedImage(
|
||||
// localUri,
|
||||
// opts.width,
|
||||
// opts.height,
|
||||
// 'JPEG',
|
||||
// quality,
|
||||
// undefined,
|
||||
// undefined,
|
||||
// undefined,
|
||||
// {mode: opts.mode},
|
||||
// )
|
||||
// if (resizeRes.size < opts.maxSize) {
|
||||
// return {
|
||||
// path: normalizePath(resizeRes.path),
|
||||
// mime: 'image/jpeg',
|
||||
// size: resizeRes.size,
|
||||
// width: resizeRes.width,
|
||||
// height: resizeRes.height,
|
||||
// }
|
||||
// } else {
|
||||
// safeDeleteAsync(resizeRes.path)
|
||||
// }
|
||||
// nearest 10th
|
||||
const quality = Math.round((1 - 0.1 * i) * 10) / 10
|
||||
const resizeRes = await manipulateAsync(
|
||||
localUri,
|
||||
[{resize: newDimensions}],
|
||||
{
|
||||
format: SaveFormat.JPEG,
|
||||
compress: quality,
|
||||
},
|
||||
)
|
||||
|
||||
const fileInfo = await getInfoAsync(resizeRes.uri)
|
||||
if (!fileInfo.exists) {
|
||||
throw new Error(
|
||||
'The image manipulation library failed to create a new image.',
|
||||
)
|
||||
}
|
||||
|
||||
if (fileInfo.size < opts.maxSize) {
|
||||
safeDeleteAsync(imageRes.uri)
|
||||
return {
|
||||
path: normalizePath(resizeRes.uri),
|
||||
mime: 'image/jpeg',
|
||||
size: fileInfo.size,
|
||||
width: resizeRes.width,
|
||||
height: resizeRes.height,
|
||||
}
|
||||
} else {
|
||||
safeDeleteAsync(resizeRes.uri)
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`This image is too big! We couldn't compress it down to ${opts.maxSize} bytes`,
|
||||
@@ -311,3 +331,25 @@ async function withTempFile<T>(
|
||||
safeDeleteAsync(tmpDirUri)
|
||||
}
|
||||
}
|
||||
|
||||
export function getResizedDimensions(originalDims: {
|
||||
width: number
|
||||
height: number
|
||||
}) {
|
||||
if (
|
||||
originalDims.width <= POST_IMG_MAX.width &&
|
||||
originalDims.height <= POST_IMG_MAX.height
|
||||
) {
|
||||
return originalDims
|
||||
}
|
||||
|
||||
const ratio = Math.min(
|
||||
POST_IMG_MAX.width / originalDims.width,
|
||||
POST_IMG_MAX.height / originalDims.height,
|
||||
)
|
||||
|
||||
return {
|
||||
width: Math.round(originalDims.width * ratio),
|
||||
height: Math.round(originalDims.height * ratio),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
import RNFS from 'react-native-fs'
|
||||
import {
|
||||
Image as RNImage,
|
||||
openCropper as openCropperFn,
|
||||
} from 'react-native-image-crop-picker'
|
||||
import {
|
||||
documentDirectory,
|
||||
getInfoAsync,
|
||||
readDirectoryAsync,
|
||||
} from 'expo-file-system'
|
||||
|
||||
import {compressIfNeeded} from './manip'
|
||||
import {CropperOptions} from './types'
|
||||
|
||||
async function getFile() {
|
||||
let files = await RNFS.readDir(
|
||||
RNFS.LibraryDirectoryPath.split('/')
|
||||
.slice(0, -5)
|
||||
.concat(['Media', 'DCIM', '100APPLE'])
|
||||
.join('/'),
|
||||
)
|
||||
files = files.filter(file => file.path.endsWith('.JPG'))
|
||||
const file = files[0]
|
||||
const imagesDir = documentDirectory!
|
||||
.split('/')
|
||||
.slice(0, -6)
|
||||
.concat(['Media', 'DCIM', '100APPLE'])
|
||||
.join('/')
|
||||
|
||||
let files = await readDirectoryAsync(imagesDir)
|
||||
files = files.filter(file => file.endsWith('.JPG'))
|
||||
const file = `${imagesDir}/${files[0]}`
|
||||
|
||||
const fileInfo = await getInfoAsync(file)
|
||||
|
||||
if (!fileInfo.exists) {
|
||||
throw new Error('Failed to get file info')
|
||||
}
|
||||
|
||||
return await compressIfNeeded({
|
||||
path: file.path,
|
||||
path: file,
|
||||
mime: 'image/jpeg',
|
||||
size: file.size,
|
||||
size: fileInfo.size,
|
||||
width: 4288,
|
||||
height: 2848,
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
MediaTypeOptions,
|
||||
} from 'expo-image-picker'
|
||||
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {getDataUriSize} from './util'
|
||||
|
||||
export async function openPicker(opts?: ImagePickerOptions) {
|
||||
@@ -28,7 +28,7 @@ export async function openPicker(opts?: ImagePickerOptions) {
|
||||
return false
|
||||
})
|
||||
.map(image => ({
|
||||
mime: 'image/jpeg',
|
||||
mime: image.mimeType || 'image/jpeg',
|
||||
height: image.height,
|
||||
width: image.width,
|
||||
path: image.uri,
|
||||
|
||||
@@ -18,9 +18,11 @@ export async function openCropper(opts: CropperOptions): Promise<RNImage> {
|
||||
name: 'crop-image',
|
||||
uri: opts.path,
|
||||
dimensions:
|
||||
opts.height && opts.width
|
||||
opts.width && opts.height
|
||||
? {width: opts.width, height: opts.height}
|
||||
: undefined,
|
||||
aspect: opts.webAspectRatio,
|
||||
circular: opts.webCircularCrop,
|
||||
onSelect: (img?: RNImage) => {
|
||||
if (img) {
|
||||
resolve(img)
|
||||
|
||||
@@ -18,4 +18,7 @@ export interface CameraOpts {
|
||||
cropperCircleOverlay?: boolean
|
||||
}
|
||||
|
||||
export type CropperOptions = Parameters<typeof openCropper>[0]
|
||||
export type CropperOptions = Parameters<typeof openCropper>[0] & {
|
||||
webAspectRatio?: number
|
||||
webCircularCrop?: boolean
|
||||
}
|
||||
|
||||
+2
-2
@@ -325,11 +325,11 @@ export const darkTheme: Theme = {
|
||||
textInverted: colors.green2,
|
||||
},
|
||||
inverted: {
|
||||
background: lightPalette.white,
|
||||
background: darkPalette.white,
|
||||
backgroundLight: lightPalette.contrast_50,
|
||||
text: lightPalette.black,
|
||||
textLight: lightPalette.contrast_700,
|
||||
textInverted: lightPalette.white,
|
||||
textInverted: darkPalette.white,
|
||||
link: lightPalette.primary_500,
|
||||
border: lightPalette.contrast_100,
|
||||
borderDark: lightPalette.contrast_200,
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {decrementBadgeCount} from '#/lib/notifications/notifications'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {
|
||||
postUriToRelativePath,
|
||||
toBskyAppUrl,
|
||||
@@ -19,10 +23,6 @@ import {isNative} from '#/platform/detection'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useHaptics} from 'lib/haptics'
|
||||
import {decrementBadgeCount} from 'lib/notifications/notifications'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
@@ -248,6 +248,7 @@ function ChatListItemReady({
|
||||
numberOfLines={1}
|
||||
style={[{maxWidth: '85%'}, web([a.leading_normal])]}>
|
||||
<Text
|
||||
emoji
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text,
|
||||
@@ -301,6 +302,7 @@ function ChatListItemReady({
|
||||
)}
|
||||
|
||||
<Text
|
||||
emoji
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
a.text_sm,
|
||||
|
||||
@@ -19,6 +19,7 @@ export function ProfileHeaderDisplayName({
|
||||
return (
|
||||
<View pointerEvents="none">
|
||||
<Text
|
||||
emoji
|
||||
testID="profileHeaderDisplayName"
|
||||
style={[t.atoms.text, a.text_4xl, a.self_start, {fontWeight: '600'}]}>
|
||||
{sanitizeDisplayName(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {isInvalidHandle} from 'lib/strings/handles'
|
||||
import {isIOS} from 'platform/detection'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {NewskieDialog} from '#/components/NewskieDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -18,6 +19,7 @@ export function ProfileHeaderHandle({
|
||||
disableTaps?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const invalidHandle = isInvalidHandle(profile.handle)
|
||||
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
|
||||
return (
|
||||
@@ -33,6 +35,7 @@ export function ProfileHeaderHandle({
|
||||
</View>
|
||||
) : undefined}
|
||||
<Text
|
||||
emoji
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
invalidHandle
|
||||
@@ -47,7 +50,7 @@ export function ProfileHeaderHandle({
|
||||
: [a.text_md, a.leading_tight, t.atoms.text_contrast_medium],
|
||||
web({wordBreak: 'break-all'}),
|
||||
]}>
|
||||
{invalidHandle ? <Trans>⚠Invalid Handle</Trans> : `@${profile.handle}`}
|
||||
{invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
cacheDirectory,
|
||||
deleteAsync,
|
||||
makeDirectoryAsync,
|
||||
moveAsync,
|
||||
} from 'expo-file-system'
|
||||
import {
|
||||
Action,
|
||||
ActionCrop,
|
||||
manipulateAsync,
|
||||
SaveFormat,
|
||||
} from 'expo-image-manipulator'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {POST_IMG_MAX} from '#/lib/constants'
|
||||
import {getImageDim} from '#/lib/media/manip'
|
||||
import {openCropper} from '#/lib/media/picker'
|
||||
import {getDataUriSize} from '#/lib/media/util'
|
||||
import {isIOS, isNative} from '#/platform/detection'
|
||||
|
||||
export type ImageTransformation = {
|
||||
crop?: ActionCrop['crop']
|
||||
}
|
||||
|
||||
export type ImageMeta = {
|
||||
path: string
|
||||
width: number
|
||||
height: number
|
||||
mime: string
|
||||
}
|
||||
|
||||
export type ImageSource = ImageMeta & {
|
||||
id: string
|
||||
}
|
||||
|
||||
type ComposerImageBase = {
|
||||
alt: string
|
||||
source: ImageSource
|
||||
}
|
||||
type ComposerImageWithoutTransformation = ComposerImageBase & {
|
||||
transformed?: undefined
|
||||
manips?: undefined
|
||||
}
|
||||
type ComposerImageWithTransformation = ComposerImageBase & {
|
||||
transformed: ImageMeta
|
||||
manips?: ImageTransformation
|
||||
}
|
||||
|
||||
export type ComposerImage =
|
||||
| ComposerImageWithoutTransformation
|
||||
| ComposerImageWithTransformation
|
||||
|
||||
let _imageCacheDirectory: string
|
||||
|
||||
function getImageCacheDirectory(): string | null {
|
||||
if (isNative) {
|
||||
return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer'))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function createComposerImage(
|
||||
raw: ImageMeta,
|
||||
): Promise<ComposerImageWithoutTransformation> {
|
||||
return {
|
||||
alt: '',
|
||||
source: {
|
||||
id: nanoid(),
|
||||
path: await moveIfNecessary(raw.path),
|
||||
width: raw.width,
|
||||
height: raw.height,
|
||||
mime: raw.mime,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type InitialImage = {
|
||||
uri: string
|
||||
width: number
|
||||
height: number
|
||||
altText?: string
|
||||
}
|
||||
|
||||
export function createInitialImages(
|
||||
uris: InitialImage[] = [],
|
||||
): ComposerImageWithoutTransformation[] {
|
||||
return uris.map(({uri, width, height, altText = ''}) => {
|
||||
return {
|
||||
alt: altText,
|
||||
source: {
|
||||
id: nanoid(),
|
||||
path: uri,
|
||||
width: width,
|
||||
height: height,
|
||||
mime: 'image/jpeg',
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function pasteImage(
|
||||
uri: string,
|
||||
): Promise<ComposerImageWithoutTransformation> {
|
||||
const {width, height} = await getImageDim(uri)
|
||||
const match = /^data:(.+?);/.exec(uri)
|
||||
|
||||
return {
|
||||
alt: '',
|
||||
source: {
|
||||
id: nanoid(),
|
||||
path: uri,
|
||||
width: width,
|
||||
height: height,
|
||||
mime: match ? match[1] : 'image/jpeg',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function cropImage(img: ComposerImage): Promise<ComposerImage> {
|
||||
if (!isNative) {
|
||||
return img
|
||||
}
|
||||
|
||||
// NOTE
|
||||
// on ios, react-native-image-crop-picker gives really bad quality
|
||||
// without specifying width and height. on android, however, the
|
||||
// crop stretches incorrectly if you do specify it. these are
|
||||
// both separate bugs in the library. we deal with that by
|
||||
// providing width & height for ios only
|
||||
// -prf
|
||||
|
||||
const source = img.source
|
||||
const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
|
||||
|
||||
// @todo: we're always passing the original image here, does image-cropper
|
||||
// allows for setting initial crop dimensions? -mary
|
||||
try {
|
||||
const cropped = await openCropper({
|
||||
mediaType: 'photo',
|
||||
path: source.path,
|
||||
freeStyleCropEnabled: true,
|
||||
...(isIOS ? {width: w, height: h} : {}),
|
||||
})
|
||||
|
||||
return {
|
||||
alt: img.alt,
|
||||
source: source,
|
||||
transformed: {
|
||||
path: await moveIfNecessary(cropped.path),
|
||||
width: cropped.width,
|
||||
height: cropped.height,
|
||||
mime: cropped.mime,
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('User cancelled')) {
|
||||
return img
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function manipulateImage(
|
||||
img: ComposerImage,
|
||||
trans: ImageTransformation,
|
||||
): Promise<ComposerImage> {
|
||||
const rawActions: (Action | undefined)[] = [trans.crop && {crop: trans.crop}]
|
||||
|
||||
const actions = rawActions.filter((a): a is Action => a !== undefined)
|
||||
|
||||
if (actions.length === 0) {
|
||||
if (img.transformed === undefined) {
|
||||
return img
|
||||
}
|
||||
|
||||
return {alt: img.alt, source: img.source}
|
||||
}
|
||||
|
||||
const source = img.source
|
||||
const result = await manipulateAsync(source.path, actions, {
|
||||
format: SaveFormat.PNG,
|
||||
})
|
||||
|
||||
return {
|
||||
alt: img.alt,
|
||||
source: img.source,
|
||||
transformed: {
|
||||
path: await moveIfNecessary(result.uri),
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
mime: 'image/png',
|
||||
},
|
||||
manips: trans,
|
||||
}
|
||||
}
|
||||
|
||||
export function resetImageManipulation(
|
||||
img: ComposerImage,
|
||||
): ComposerImageWithoutTransformation {
|
||||
if (img.transformed !== undefined) {
|
||||
return {alt: img.alt, source: img.source}
|
||||
}
|
||||
|
||||
return img
|
||||
}
|
||||
|
||||
export async function compressImage(img: ComposerImage): Promise<ImageMeta> {
|
||||
const source = img.transformed || img.source
|
||||
|
||||
const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
|
||||
const cacheDir = isNative && getImageCacheDirectory()
|
||||
|
||||
for (let i = 10; i > 0; i--) {
|
||||
// Float precision
|
||||
const factor = i / 10
|
||||
|
||||
const res = await manipulateAsync(
|
||||
source.path,
|
||||
[{resize: {width: w, height: h}}],
|
||||
{
|
||||
compress: factor,
|
||||
format: SaveFormat.JPEG,
|
||||
base64: true,
|
||||
},
|
||||
)
|
||||
|
||||
const base64 = res.base64
|
||||
|
||||
if (base64 !== undefined && getDataUriSize(base64) <= POST_IMG_MAX.size) {
|
||||
return {
|
||||
path: await moveIfNecessary(res.uri),
|
||||
width: res.width,
|
||||
height: res.height,
|
||||
mime: 'image/jpeg',
|
||||
}
|
||||
}
|
||||
|
||||
if (cacheDir) {
|
||||
await deleteAsync(res.uri)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unable to compress image`)
|
||||
}
|
||||
|
||||
async function moveIfNecessary(from: string) {
|
||||
const cacheDir = isNative && getImageCacheDirectory()
|
||||
|
||||
if (cacheDir && from.startsWith(cacheDir)) {
|
||||
const to = joinPath(cacheDir, nanoid(36))
|
||||
|
||||
await makeDirectoryAsync(cacheDir, {intermediates: true})
|
||||
await moveAsync({from, to})
|
||||
|
||||
return to
|
||||
}
|
||||
|
||||
return from
|
||||
}
|
||||
|
||||
/** Purge files that were created to accomodate image manipulation */
|
||||
export async function purgeTemporaryImageFiles() {
|
||||
const cacheDir = isNative && getImageCacheDirectory()
|
||||
|
||||
if (cacheDir) {
|
||||
await deleteAsync(cacheDir, {idempotent: true})
|
||||
await makeDirectoryAsync(cacheDir)
|
||||
}
|
||||
}
|
||||
|
||||
function joinPath(a: string, b: string) {
|
||||
if (a.endsWith('/')) {
|
||||
if (b.startsWith('/')) {
|
||||
return a.slice(0, -1) + b
|
||||
}
|
||||
return a + b
|
||||
} else if (b.startsWith('/')) {
|
||||
return a + b
|
||||
}
|
||||
return a + '/' + b
|
||||
}
|
||||
|
||||
function containImageRes(
|
||||
w: number,
|
||||
h: number,
|
||||
{width: maxW, height: maxH}: {width: number; height: number},
|
||||
): [width: number, height: number] {
|
||||
let scale = 1
|
||||
|
||||
if (w > maxW || h > maxH) {
|
||||
scale = w > h ? maxW / w : maxH / h
|
||||
w = Math.floor(w * scale)
|
||||
h = Math.floor(h * scale)
|
||||
}
|
||||
|
||||
return [w, h]
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {GalleryModel} from '#/state/models/media/gallery'
|
||||
import {ImageModel} from '#/state/models/media/image'
|
||||
|
||||
export interface EditProfileModal {
|
||||
name: 'edit-profile'
|
||||
@@ -37,24 +35,15 @@ export interface ListAddRemoveUsersModal {
|
||||
) => void
|
||||
}
|
||||
|
||||
export interface EditImageModal {
|
||||
name: 'edit-image'
|
||||
image: ImageModel
|
||||
gallery: GalleryModel
|
||||
}
|
||||
|
||||
export interface CropImageModal {
|
||||
name: 'crop-image'
|
||||
uri: string
|
||||
dimensions?: {width: number; height: number}
|
||||
aspect?: number
|
||||
circular?: boolean
|
||||
onSelect: (img?: RNImage) => void
|
||||
}
|
||||
|
||||
export interface AltTextImageModal {
|
||||
name: 'alt-text-image'
|
||||
image: ImageModel
|
||||
}
|
||||
|
||||
export interface DeleteAccountModal {
|
||||
name: 'delete-account'
|
||||
}
|
||||
@@ -137,9 +126,7 @@ export type Modal =
|
||||
| ListAddRemoveUsersModal
|
||||
|
||||
// Posts
|
||||
| AltTextImageModal
|
||||
| CropImageModal
|
||||
| EditImageModal
|
||||
| SelfLabelModal
|
||||
|
||||
// Bluesky access
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
|
||||
import {getImageDim} from 'lib/media/manip'
|
||||
import {openPicker} from 'lib/media/picker'
|
||||
import {ImageInitOptions, ImageModel} from './image'
|
||||
|
||||
interface InitialImageUri {
|
||||
uri: string
|
||||
width: number
|
||||
height: number
|
||||
altText?: string
|
||||
}
|
||||
|
||||
export class GalleryModel {
|
||||
images: ImageModel[] = []
|
||||
|
||||
constructor(uris?: InitialImageUri[]) {
|
||||
makeAutoObservable(this)
|
||||
|
||||
if (uris) {
|
||||
this.addFromUris(uris)
|
||||
}
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.size === 0
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.images.length
|
||||
}
|
||||
|
||||
get needsAltText() {
|
||||
return this.images.some(image => image.altText.trim() === '')
|
||||
}
|
||||
|
||||
*add(image_: ImageInitOptions) {
|
||||
if (this.size >= 4) {
|
||||
return
|
||||
}
|
||||
|
||||
// Temporarily enforce uniqueness but can eventually also use index
|
||||
if (!this.images.some(i => i.path === image_.path)) {
|
||||
const image = new ImageModel(image_)
|
||||
|
||||
// Initial resize
|
||||
image.manipulate({})
|
||||
this.images.push(image)
|
||||
}
|
||||
}
|
||||
|
||||
async paste(uri: string) {
|
||||
if (this.size >= 4) {
|
||||
return
|
||||
}
|
||||
|
||||
const {width, height} = await getImageDim(uri)
|
||||
|
||||
const image = {
|
||||
path: uri,
|
||||
height,
|
||||
width,
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.add(image)
|
||||
})
|
||||
}
|
||||
|
||||
setAltText(image: ImageModel, altText: string) {
|
||||
image.setAltText(altText)
|
||||
}
|
||||
|
||||
crop(image: ImageModel) {
|
||||
image.crop()
|
||||
}
|
||||
|
||||
remove(image: ImageModel) {
|
||||
const index = this.images.findIndex(image_ => image_.path === image.path)
|
||||
this.images.splice(index, 1)
|
||||
}
|
||||
|
||||
async previous(image: ImageModel) {
|
||||
image.previous()
|
||||
}
|
||||
|
||||
async pick() {
|
||||
const images = await openPicker({
|
||||
selectionLimit: 4 - this.size,
|
||||
allowsMultipleSelection: true,
|
||||
})
|
||||
|
||||
return await Promise.all(
|
||||
images.map(image => {
|
||||
this.add(image)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async addFromUris(uris: InitialImageUri[]) {
|
||||
for (const uriObj of uris) {
|
||||
this.add({
|
||||
height: uriObj.height,
|
||||
width: uriObj.width,
|
||||
path: uriObj.uri,
|
||||
altText: uriObj.altText,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
import {ActionCrop} from 'expo-image-manipulator'
|
||||
import {Position} from 'react-avatar-editor'
|
||||
import {Dimensions} from 'lib/media/types'
|
||||
|
||||
export interface ImageManipulationAttributes {
|
||||
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
|
||||
rotate?: number
|
||||
scale?: number
|
||||
position?: Position
|
||||
flipHorizontal?: boolean
|
||||
flipVertical?: boolean
|
||||
}
|
||||
|
||||
export class ImageModel implements Omit<RNImage, 'size'> {
|
||||
path: string
|
||||
mime = 'image/jpeg'
|
||||
width: number
|
||||
height: number
|
||||
altText = ''
|
||||
cropped?: RNImage = undefined
|
||||
compressed?: RNImage = undefined
|
||||
|
||||
// Web manipulation
|
||||
prev?: RNImage
|
||||
attributes: ImageManipulationAttributes = {
|
||||
aspectRatio: 'None',
|
||||
scale: 1,
|
||||
flipHorizontal: false,
|
||||
flipVertical: false,
|
||||
rotate: 0,
|
||||
}
|
||||
prevAttributes: ImageManipulationAttributes = {}
|
||||
|
||||
constructor(image: Omit<RNImage, 'size'>) {
|
||||
makeAutoObservable(this)
|
||||
|
||||
this.path = image.path
|
||||
this.width = image.width
|
||||
this.height = image.height
|
||||
}
|
||||
|
||||
setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
|
||||
this.attributes.aspectRatio = aspectRatio
|
||||
}
|
||||
|
||||
setRotate(degrees: number) {
|
||||
this.attributes.rotate = degrees
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
flipVertical() {
|
||||
this.attributes.flipVertical = !this.attributes.flipVertical
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
flipHorizontal() {
|
||||
this.attributes.flipHorizontal = !this.attributes.flipHorizontal
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
get ratioMultipliers() {
|
||||
return {
|
||||
'4:3': 4 / 3,
|
||||
'1:1': 1,
|
||||
'3:4': 3 / 4,
|
||||
None: this.width / this.height,
|
||||
}
|
||||
}
|
||||
|
||||
getUploadDimensions(
|
||||
dimensions: Dimensions,
|
||||
maxDimensions: Dimensions = POST_IMG_MAX,
|
||||
as: ImageManipulationAttributes['aspectRatio'] = 'None',
|
||||
) {
|
||||
const {width, height} = dimensions
|
||||
const {width: maxWidth, height: maxHeight} = maxDimensions
|
||||
|
||||
return width < maxWidth && height < maxHeight
|
||||
? {
|
||||
width,
|
||||
height,
|
||||
}
|
||||
: this.getResizedDimensions(as, POST_IMG_MAX.width)
|
||||
}
|
||||
|
||||
getResizedDimensions(
|
||||
as: ImageManipulationAttributes['aspectRatio'] = 'None',
|
||||
maxSide: number,
|
||||
) {
|
||||
const ratioMultiplier = this.ratioMultipliers[as]
|
||||
|
||||
if (ratioMultiplier === 1) {
|
||||
return {
|
||||
height: maxSide,
|
||||
width: maxSide,
|
||||
}
|
||||
}
|
||||
|
||||
if (ratioMultiplier < 1) {
|
||||
return {
|
||||
width: maxSide * ratioMultiplier,
|
||||
height: maxSide,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width: maxSide,
|
||||
height: maxSide / ratioMultiplier,
|
||||
}
|
||||
}
|
||||
|
||||
setAltText(altText: string) {
|
||||
this.altText = altText.trim()
|
||||
}
|
||||
|
||||
// Only compress prior to upload
|
||||
async compress() {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
// Mobile
|
||||
async crop() {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
// Web manipulation
|
||||
async manipulate(
|
||||
_attributes: {
|
||||
crop?: ActionCrop['crop']
|
||||
} & ImageManipulationAttributes,
|
||||
) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
resetCropped() {
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
previous() {
|
||||
this.cropped = this.prev
|
||||
this.attributes = this.prevAttributes
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import * as ImageManipulator from 'expo-image-manipulator'
|
||||
import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator'
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {Position} from 'react-avatar-editor'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
import {openCropper} from 'lib/media/picker'
|
||||
import {Dimensions} from 'lib/media/types'
|
||||
import {getDataUriSize} from 'lib/media/util'
|
||||
import {isIOS} from 'platform/detection'
|
||||
|
||||
export interface ImageManipulationAttributes {
|
||||
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
|
||||
rotate?: number
|
||||
scale?: number
|
||||
position?: Position
|
||||
flipHorizontal?: boolean
|
||||
flipVertical?: boolean
|
||||
}
|
||||
|
||||
export interface ImageInitOptions {
|
||||
path: string
|
||||
width: number
|
||||
height: number
|
||||
altText?: string
|
||||
}
|
||||
|
||||
const MAX_IMAGE_SIZE_IN_BYTES = 976560
|
||||
|
||||
export class ImageModel implements Omit<RNImage, 'size'> {
|
||||
path: string
|
||||
mime = 'image/jpeg'
|
||||
width: number
|
||||
height: number
|
||||
altText = ''
|
||||
cropped?: RNImage = undefined
|
||||
compressed?: RNImage = undefined
|
||||
|
||||
// Web manipulation
|
||||
prev?: RNImage
|
||||
attributes: ImageManipulationAttributes = {
|
||||
aspectRatio: 'None',
|
||||
scale: 1,
|
||||
flipHorizontal: false,
|
||||
flipVertical: false,
|
||||
rotate: 0,
|
||||
}
|
||||
prevAttributes: ImageManipulationAttributes = {}
|
||||
|
||||
constructor(image: ImageInitOptions) {
|
||||
makeAutoObservable(this)
|
||||
|
||||
this.path = image.path
|
||||
this.width = image.width
|
||||
this.height = image.height
|
||||
if (image.altText !== undefined) {
|
||||
this.setAltText(image.altText)
|
||||
}
|
||||
}
|
||||
|
||||
setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
|
||||
this.attributes.aspectRatio = aspectRatio
|
||||
}
|
||||
|
||||
setRotate(degrees: number) {
|
||||
this.attributes.rotate = degrees
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
flipVertical() {
|
||||
this.attributes.flipVertical = !this.attributes.flipVertical
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
flipHorizontal() {
|
||||
this.attributes.flipHorizontal = !this.attributes.flipHorizontal
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
get ratioMultipliers() {
|
||||
return {
|
||||
'4:3': 4 / 3,
|
||||
'1:1': 1,
|
||||
'3:4': 3 / 4,
|
||||
None: this.width / this.height,
|
||||
}
|
||||
}
|
||||
|
||||
getUploadDimensions(
|
||||
dimensions: Dimensions,
|
||||
maxDimensions: Dimensions = POST_IMG_MAX,
|
||||
as: ImageManipulationAttributes['aspectRatio'] = 'None',
|
||||
) {
|
||||
const {width, height} = dimensions
|
||||
const {width: maxWidth, height: maxHeight} = maxDimensions
|
||||
|
||||
return width < maxWidth && height < maxHeight
|
||||
? {
|
||||
width,
|
||||
height,
|
||||
}
|
||||
: this.getResizedDimensions(as, POST_IMG_MAX.width)
|
||||
}
|
||||
|
||||
getResizedDimensions(
|
||||
as: ImageManipulationAttributes['aspectRatio'] = 'None',
|
||||
maxSide: number,
|
||||
) {
|
||||
const ratioMultiplier = this.ratioMultipliers[as]
|
||||
|
||||
if (ratioMultiplier === 1) {
|
||||
return {
|
||||
height: maxSide,
|
||||
width: maxSide,
|
||||
}
|
||||
}
|
||||
|
||||
if (ratioMultiplier < 1) {
|
||||
return {
|
||||
width: maxSide * ratioMultiplier,
|
||||
height: maxSide,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width: maxSide,
|
||||
height: maxSide / ratioMultiplier,
|
||||
}
|
||||
}
|
||||
|
||||
setAltText(altText: string) {
|
||||
this.altText = altText.trim()
|
||||
}
|
||||
|
||||
// Only compress prior to upload
|
||||
async compress() {
|
||||
for (let i = 10; i > 0; i--) {
|
||||
// Float precision
|
||||
const factor = Math.round(i) / 10
|
||||
const compressed = await ImageManipulator.manipulateAsync(
|
||||
this.cropped?.path ?? this.path,
|
||||
undefined,
|
||||
{
|
||||
compress: factor,
|
||||
base64: true,
|
||||
format: SaveFormat.JPEG,
|
||||
},
|
||||
)
|
||||
|
||||
if (compressed.base64 !== undefined) {
|
||||
const size = getDataUriSize(compressed.base64)
|
||||
|
||||
if (size < MAX_IMAGE_SIZE_IN_BYTES) {
|
||||
runInAction(() => {
|
||||
this.compressed = {
|
||||
mime: 'image/jpeg',
|
||||
path: compressed.uri,
|
||||
size,
|
||||
...compressed,
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compression fails when removing redundant information is not possible.
|
||||
// This can be tested with images that have high variance in noise.
|
||||
throw new Error('Failed to compress image')
|
||||
}
|
||||
|
||||
// Mobile
|
||||
async crop() {
|
||||
try {
|
||||
// NOTE
|
||||
// on ios, react-native-image-crop-picker gives really bad quality
|
||||
// without specifying width and height. on android, however, the
|
||||
// crop stretches incorrectly if you do specify it. these are
|
||||
// both separate bugs in the library. we deal with that by
|
||||
// providing width & height for ios only
|
||||
// -prf
|
||||
const {width, height} = this.getUploadDimensions({
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
})
|
||||
|
||||
const cropped = await openCropper({
|
||||
mediaType: 'photo',
|
||||
path: this.path,
|
||||
freeStyleCropEnabled: true,
|
||||
...(isIOS ? {width, height} : {}),
|
||||
})
|
||||
|
||||
runInAction(() => {
|
||||
this.cropped = cropped
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('Failed to crop photo', {message: err})
|
||||
}
|
||||
}
|
||||
|
||||
// Web manipulation
|
||||
async manipulate(
|
||||
attributes: {
|
||||
crop?: ActionCrop['crop']
|
||||
} & ImageManipulationAttributes,
|
||||
) {
|
||||
let uploadWidth: number | undefined
|
||||
let uploadHeight: number | undefined
|
||||
|
||||
const {aspectRatio, crop, position, scale} = attributes
|
||||
const modifiers = []
|
||||
|
||||
if (this.attributes.flipHorizontal) {
|
||||
modifiers.push({flip: FlipType.Horizontal})
|
||||
}
|
||||
|
||||
if (this.attributes.flipVertical) {
|
||||
modifiers.push({flip: FlipType.Vertical})
|
||||
}
|
||||
|
||||
if (this.attributes.rotate !== undefined) {
|
||||
modifiers.push({rotate: this.attributes.rotate})
|
||||
}
|
||||
|
||||
if (crop !== undefined) {
|
||||
const croppedHeight = crop.height * this.height
|
||||
const croppedWidth = crop.width * this.width
|
||||
modifiers.push({
|
||||
crop: {
|
||||
originX: crop.originX * this.width,
|
||||
originY: crop.originY * this.height,
|
||||
height: croppedHeight,
|
||||
width: croppedWidth,
|
||||
},
|
||||
})
|
||||
|
||||
const uploadDimensions = this.getUploadDimensions(
|
||||
{width: croppedWidth, height: croppedHeight},
|
||||
POST_IMG_MAX,
|
||||
aspectRatio,
|
||||
)
|
||||
|
||||
uploadWidth = uploadDimensions.width
|
||||
uploadHeight = uploadDimensions.height
|
||||
} else {
|
||||
const uploadDimensions = this.getUploadDimensions(
|
||||
{width: this.width, height: this.height},
|
||||
POST_IMG_MAX,
|
||||
aspectRatio,
|
||||
)
|
||||
|
||||
uploadWidth = uploadDimensions.width
|
||||
uploadHeight = uploadDimensions.height
|
||||
}
|
||||
|
||||
if (scale !== undefined) {
|
||||
this.attributes.scale = scale
|
||||
}
|
||||
|
||||
if (position !== undefined) {
|
||||
this.attributes.position = position
|
||||
}
|
||||
|
||||
if (aspectRatio !== undefined) {
|
||||
this.attributes.aspectRatio = aspectRatio
|
||||
}
|
||||
|
||||
const ratioMultiplier =
|
||||
this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1']
|
||||
|
||||
const result = await ImageManipulator.manipulateAsync(
|
||||
this.path,
|
||||
[
|
||||
...modifiers,
|
||||
{
|
||||
resize:
|
||||
ratioMultiplier > 1 ? {width: uploadWidth} : {height: uploadHeight},
|
||||
},
|
||||
],
|
||||
{
|
||||
base64: true,
|
||||
format: SaveFormat.JPEG,
|
||||
},
|
||||
)
|
||||
|
||||
runInAction(() => {
|
||||
this.cropped = {
|
||||
mime: 'image/jpeg',
|
||||
path: result.uri,
|
||||
size:
|
||||
result.base64 !== undefined
|
||||
? getDataUriSize(result.base64)
|
||||
: MAX_IMAGE_SIZE_IN_BYTES + 999, // shouldn't hit this unless manipulation fails
|
||||
...result,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
resetCropped() {
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
previous() {
|
||||
this.cropped = this.prev
|
||||
this.attributes = this.prevAttributes
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {purgeTemporaryImageFiles} from '#/state/gallery'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
|
||||
export interface ComposerOptsPostRef {
|
||||
@@ -77,7 +78,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
|
||||
const closeComposer = useNonReactiveCallback(() => {
|
||||
let wasOpen = !!state
|
||||
setState(undefined)
|
||||
if (wasOpen) {
|
||||
setState(undefined)
|
||||
purgeTemporaryImageFiles()
|
||||
}
|
||||
|
||||
return wasOpen
|
||||
})
|
||||
|
||||
|
||||
+51
-99
@@ -1,7 +1,15 @@
|
||||
/**
|
||||
* IMPORTANT
|
||||
*
|
||||
* Some of these styles are duplicated in the `web/index.html` and
|
||||
* `bskyweb/templates/base.html` files. Depending on what you're updating, you
|
||||
* may need to touch all three. Ask Eric if you aren't sure.
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter-Regular';
|
||||
src: local('Inter-Regular'),
|
||||
url(/static/media/Inter-Regular.1f5ed03b6dd9fd1f9982.otf) format('font/otf');
|
||||
url(/assets/fonts/inter/Inter-Regular.otf) format('font/otf');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -9,7 +17,7 @@
|
||||
@font-face {
|
||||
font-family: 'Inter-Italic';
|
||||
src: local('Inter-Italic'),
|
||||
url(/static/media/Inter-Italic.95778eb0c75dc956257e.otf) format('font/otf');
|
||||
url(/assets/fonts/inter/Inter-Italic.otf) format('font/otf');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
@@ -17,14 +25,14 @@
|
||||
/*
|
||||
@font-face {
|
||||
font-family: "Inter-Medium";
|
||||
src: local("Inter-Medium"), url(/static/media/Inter-Medium.296aa2d65964269836b3.otf) format("font/otf");
|
||||
src: local("Inter-Medium"), url(/assets/fonts/inter/Inter-Medium.otf) format("font/otf");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter-MediumItalic";
|
||||
src: local("Inter-MediumItalic"), url(/static/media/Inter-MediumItalic.0e57e17a6311368e2114.otf) format("font/otf");
|
||||
src: local("Inter-MediumItalic"), url(/assets/fonts/inter/Inter-MediumItalic.otf) format("font/otf");
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
@@ -33,8 +41,7 @@
|
||||
@font-face {
|
||||
font-family: 'Inter-SemiBold';
|
||||
src: local('Inter-SemiBold'),
|
||||
url(/static/media/Inter-SemiBold.2277990330981b8409bb.otf)
|
||||
format('font/otf');
|
||||
url(/assets/fonts/inter/Inter-SemiBold.otf) format('font/otf');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -42,8 +49,7 @@
|
||||
@font-face {
|
||||
font-family: 'Inter-SemiBoldItalic';
|
||||
src: local('Inter-SemiBoldItalic'),
|
||||
url(/static/media/Inter-SemiBoldItalic.f62fea3df3a521d6c8a7.otf)
|
||||
format('font/otf');
|
||||
url(/assets/fonts/inter/Inter-SemiBoldItalic.otf) format('font/otf');
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
@@ -51,14 +57,14 @@
|
||||
/*
|
||||
@font-face {
|
||||
font-family: "Inter-Bold";
|
||||
src: local("Inter-Bold"), url(/static/media/Inter-Bold.8d330503e1d034ad68de.otf) format("font/otf");
|
||||
src: local("Inter-Bold"), url(/assets/fonts/inter/Inter-Bold.otf) format("font/otf");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter-BoldItalic";
|
||||
src: local("Inter-BoldItalic"), url(/static/media/Inter-BoldItalic.bb17e63f9baa0d861a20.otf) format("font/otf");
|
||||
src: local("Inter-BoldItalic"), url(/assets/fonts/inter/Inter-BoldItalic.otf) format("font/otf");
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
@@ -67,8 +73,7 @@
|
||||
@font-face {
|
||||
font-family: 'Inter-ExtraBold';
|
||||
src: local('Inter-ExtraBold'),
|
||||
url(/static/media/Inter-ExtraBold.ff2581a193bf6b7e0b06.otf)
|
||||
format('font/otf');
|
||||
url(/assets/fonts/inter/Inter-ExtraBold.otf) format('font/otf');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -76,8 +81,7 @@
|
||||
@font-face {
|
||||
font-family: 'Inter-ExtraBoldItalic';
|
||||
src: local('Inter-ExtraBoldItalic'),
|
||||
url(/static/media/Inter-ExtraBoldItalic.0e50b40728d24d40fdf4.otf)
|
||||
format('font/otf');
|
||||
url(/assets/fonts/inter/Inter-ExtraBoldItalic.otf) format('font/otf');
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
@@ -85,14 +89,14 @@
|
||||
/*
|
||||
@font-face {
|
||||
font-family: "Inter-Black";
|
||||
src: local("Inter-Black"), url(/static/media/Inter-Black.66e9a87f1c921e844ed4.otf) format("font/otf");
|
||||
src: local("Inter-Black"), url(/assets/fonts/inter/Inter-Black.otf) format("font/otf");
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter-BlackItalic";
|
||||
src: local("Inter-BlackItalic"), url(/static/media/Inter-BlackItalic.27b9f0ad06fd13a7b9da.otf) format("font/otf");
|
||||
src: local("Inter-BlackItalic"), url(/assets/fonts/inter/Inter-BlackItalic.otf) format("font/otf");
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
@@ -100,46 +104,43 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extend the react-native-web reset:
|
||||
* https://github.com/necolas/react-native-web/blob/master/packages/react-native-web/src/exports/StyleSheet/initialRules.js
|
||||
* BEGIN STYLES
|
||||
*
|
||||
* HTML & BODY STYLES IN `web/index.html` and `bskyweb/templates/base.html`
|
||||
*/
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
/* To smooth any scrolling behavior */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
/* Allows content to fill the viewport and go beyond the bottom */
|
||||
min-height: 100%;
|
||||
:root {
|
||||
--text: black;
|
||||
--background: white;
|
||||
--backgroundLight: hsl(211, 20%, 95%);
|
||||
}
|
||||
#root {
|
||||
flex-shrink: 0;
|
||||
flex-basis: auto;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: hsl(211, 20%, 20%);
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
/* Prevent text size change on orientation change https://gist.github.com/tfausak/2222823#file-ios-8-web-app-html-L138 */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
height: calc(100% + env(safe-area-inset-top));
|
||||
scrollbar-gutter: stable both-edges;
|
||||
html.theme--light {
|
||||
--text: black;
|
||||
--background: white;
|
||||
--backgroundLight: hsl(211, 20%, 95%);
|
||||
background-color: white;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Liberation Sans', Helvetica, Arial, sans-serif;
|
||||
html.theme--dark {
|
||||
color-scheme: dark;
|
||||
background-color: black;
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: hsl(211, 20%, 20%);
|
||||
}
|
||||
|
||||
#preload {
|
||||
width: 100px;
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
html.theme--dim {
|
||||
color-scheme: dark;
|
||||
background-color: hsl(211, 28%, 12%);
|
||||
--text: white;
|
||||
--background: hsl(211, 20%, 4%);
|
||||
--backgroundLight: hsl(211, 20%, 10%);
|
||||
}
|
||||
|
||||
/* Buttons and inputs have a font set by UA, so we'll have to reset that */
|
||||
@@ -150,42 +151,6 @@ textarea {
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
/* Color theming */
|
||||
/* Default will always be white */
|
||||
:root {
|
||||
--text: black;
|
||||
--background: white;
|
||||
--backgroundLight: hsl(211, 20%, 95%);
|
||||
}
|
||||
/* This gives us a black background when system is dark and we have not loaded the theme/color scheme values in JS */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: hsl(211, 20%, 20%);
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
/* Overwrite those preferences with the selected theme */
|
||||
html.theme--light {
|
||||
--text: black;
|
||||
--background: white;
|
||||
--backgroundLight: hsl(211, 20%, 95%);
|
||||
}
|
||||
html.theme--dark {
|
||||
--text: white;
|
||||
--background: black;
|
||||
--backgroundLight: hsl(211, 20%, 20%);
|
||||
color-scheme: dark;
|
||||
}
|
||||
html.theme--dim {
|
||||
--text: white;
|
||||
--background: hsl(211, 20%, 4%);
|
||||
--backgroundLight: hsl(211, 20%, 10%);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Remove autofill styles on Webkit */
|
||||
input:autofill,
|
||||
input:-webkit-autofill,
|
||||
@@ -204,19 +169,6 @@ input::-webkit-date-and-time-value {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
/* Allows you to scroll below the viewport; default value is visible */
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
background-color: var(--background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-ms-overflow-style: scrollbar;
|
||||
font-synthesis-weight: none;
|
||||
}
|
||||
|
||||
/* Remove default link styling */
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
@@ -3,14 +3,15 @@ import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {BSKY_SERVICE} from '#/lib/constants'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {BSKY_SERVICE} from 'lib/constants'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {P, Text} from '#/components/Typography'
|
||||
|
||||
export function ServerInputDialog({
|
||||
@@ -153,9 +154,13 @@ export function ServerInputDialog({
|
||||
]}>
|
||||
<Trans>
|
||||
Bluesky is an open network where you can choose your hosting
|
||||
provider. Custom hosting is now available in beta for
|
||||
developers.
|
||||
</Trans>
|
||||
provider. If you're a developer, you can host your own server.
|
||||
</Trans>{' '}
|
||||
<InlineLinkText
|
||||
label={_(msg`Learn more about self hosting your PDS.`)}
|
||||
to="https://atproto.com/guides/self-hosting">
|
||||
<Trans>Learn more.</Trans>
|
||||
</InlineLinkText>
|
||||
</P>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ import {RichText} from '@atproto/api'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import * as apilib from '#/lib/api/index'
|
||||
@@ -68,9 +67,9 @@ import {logger} from '#/logger'
|
||||
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {emitPostCreated} from '#/state/events'
|
||||
import {ComposerImage, createInitialImages, pasteImage} from '#/state/gallery'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useModals} from '#/state/modals'
|
||||
import {GalleryModel} from '#/state/models/media/gallery'
|
||||
import {useRequireAltTextEnabled} from '#/state/preferences'
|
||||
import {
|
||||
toPostLanguages,
|
||||
@@ -122,12 +121,14 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text as NewText} from '#/components/Typography'
|
||||
|
||||
const MAX_IMAGES = 4
|
||||
|
||||
type CancelRef = {
|
||||
onPressCancel: () => void
|
||||
}
|
||||
|
||||
type Props = ComposerOpts
|
||||
export const ComposePost = observer(function ComposePost({
|
||||
export const ComposePost = ({
|
||||
replyTo,
|
||||
onPost,
|
||||
quote: initQuote,
|
||||
@@ -139,7 +140,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
cancelRef,
|
||||
}: Props & {
|
||||
cancelRef?: React.RefObject<CancelRef>
|
||||
}) {
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
|
||||
@@ -212,9 +213,8 @@ export const ComposePost = observer(function ComposePost({
|
||||
)
|
||||
const [postgate, setPostgate] = useState(createPostgateRecord({post: ''}))
|
||||
|
||||
const gallery = useMemo(
|
||||
() => new GalleryModel(initImageUris),
|
||||
[initImageUris],
|
||||
const [images, setImages] = useState<ComposerImage[]>(() =>
|
||||
createInitialImages(initImageUris),
|
||||
)
|
||||
const onClose = useCallback(() => {
|
||||
closeComposer()
|
||||
@@ -233,7 +233,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
const onPressCancel = useCallback(() => {
|
||||
if (
|
||||
graphemeLength > 0 ||
|
||||
!gallery.isEmpty ||
|
||||
images.length !== 0 ||
|
||||
extGif ||
|
||||
videoUploadState.status !== 'idle'
|
||||
) {
|
||||
@@ -246,7 +246,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
}, [
|
||||
extGif,
|
||||
graphemeLength,
|
||||
gallery.isEmpty,
|
||||
images.length,
|
||||
closeAllDialogs,
|
||||
discardPromptControl,
|
||||
onClose,
|
||||
@@ -299,22 +299,31 @@ export const ComposePost = observer(function ComposePost({
|
||||
[extLink, setExtLink],
|
||||
)
|
||||
|
||||
const onImageAdd = useCallback(
|
||||
(next: ComposerImage[]) => {
|
||||
setImages(prev => prev.concat(next.slice(0, MAX_IMAGES - prev.length)))
|
||||
},
|
||||
[setImages],
|
||||
)
|
||||
|
||||
const onPhotoPasted = useCallback(
|
||||
async (uri: string) => {
|
||||
track('Composer:PastedPhotos')
|
||||
if (uri.startsWith('data:video/')) {
|
||||
selectVideo({uri, type: 'video', height: 0, width: 0})
|
||||
} else {
|
||||
await gallery.paste(uri)
|
||||
const res = await pasteImage(uri)
|
||||
onImageAdd([res])
|
||||
}
|
||||
},
|
||||
[gallery, track, selectVideo],
|
||||
[track, selectVideo, onImageAdd],
|
||||
)
|
||||
|
||||
const isAltTextRequiredAndMissing = useMemo(() => {
|
||||
if (!requireAltTextEnabled) return false
|
||||
|
||||
if (gallery.needsAltText) return true
|
||||
if (images.some(img => img.alt === '')) return true
|
||||
|
||||
if (extGif) {
|
||||
if (!extLink?.meta?.description) return true
|
||||
|
||||
@@ -322,7 +331,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
if (!parsedAlt.isPreferred) return true
|
||||
}
|
||||
return false
|
||||
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
|
||||
}, [images, extLink, extGif, requireAltTextEnabled])
|
||||
|
||||
const onPressPublish = React.useCallback(
|
||||
async (finishedUploading?: boolean) => {
|
||||
@@ -347,7 +356,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
|
||||
if (
|
||||
richtext.text.trim().length === 0 &&
|
||||
gallery.isEmpty &&
|
||||
images.length === 0 &&
|
||||
!extLink &&
|
||||
!quote &&
|
||||
videoUploadState.status === 'idle'
|
||||
@@ -368,7 +377,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
await apilib.post(agent, {
|
||||
rawText: richtext.text,
|
||||
replyTo: replyTo?.uri,
|
||||
images: gallery.images,
|
||||
images,
|
||||
quote,
|
||||
extLink,
|
||||
labels,
|
||||
@@ -405,7 +414,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
} catch (e: any) {
|
||||
logger.error(e, {
|
||||
message: `Composer: create post failed`,
|
||||
hasImages: gallery.size > 0,
|
||||
hasImages: images.length > 0,
|
||||
})
|
||||
|
||||
if (extLink) {
|
||||
@@ -427,7 +436,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
} finally {
|
||||
if (postUri) {
|
||||
logEvent('post:create', {
|
||||
imageCount: gallery.size,
|
||||
imageCount: images.length,
|
||||
isReply: replyTo != null,
|
||||
hasLink: extLink != null,
|
||||
hasQuote: quote != null,
|
||||
@@ -436,7 +445,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
})
|
||||
}
|
||||
track('Create Post', {
|
||||
imageCount: gallery.size,
|
||||
imageCount: images.length,
|
||||
})
|
||||
if (replyTo && replyTo.uri) track('Post:Reply')
|
||||
}
|
||||
@@ -472,9 +481,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
agent,
|
||||
captions,
|
||||
extLink,
|
||||
gallery.images,
|
||||
gallery.isEmpty,
|
||||
gallery.size,
|
||||
images,
|
||||
graphemeLength,
|
||||
isAltTextRequiredAndMissing,
|
||||
isProcessing,
|
||||
@@ -516,12 +523,12 @@ export const ComposePost = observer(function ComposePost({
|
||||
: _(msg`What's up?`)
|
||||
|
||||
const canSelectImages =
|
||||
gallery.size < 4 &&
|
||||
images.length < MAX_IMAGES &&
|
||||
!extLink &&
|
||||
videoUploadState.status === 'idle' &&
|
||||
!videoUploadState.video
|
||||
const hasMedia =
|
||||
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
|
||||
images.length > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
|
||||
|
||||
const onEmojiButtonPress = useCallback(() => {
|
||||
openEmojiPicker?.(textInput.current?.getCursorPosition())
|
||||
@@ -716,8 +723,8 @@ export const ComposePost = observer(function ComposePost({
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Gallery gallery={gallery} />
|
||||
{gallery.isEmpty && extLink && (
|
||||
<Gallery images={images} onChange={setImages} />
|
||||
{images.length === 0 && extLink && (
|
||||
<View style={a.relative}>
|
||||
<ExternalEmbed
|
||||
link={extLink}
|
||||
@@ -801,13 +808,17 @@ export const ComposePost = observer(function ComposePost({
|
||||
<VideoUploadToolbar state={videoUploadState} />
|
||||
) : (
|
||||
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
<SelectPhotoBtn
|
||||
size={images.length}
|
||||
disabled={!canSelectImages}
|
||||
onAdd={onImageAdd}
|
||||
/>
|
||||
<SelectVideoBtn
|
||||
onSelectVideo={selectVideo}
|
||||
disabled={!canSelectImages}
|
||||
setError={setError}
|
||||
/>
|
||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
<OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} />
|
||||
<SelectGifBtn
|
||||
onClose={focusTextInput}
|
||||
onSelectGif={onSelectGif}
|
||||
@@ -842,7 +853,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function useComposerCancelRef() {
|
||||
return useRef<CancelRef>(null)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
|
||||
import {ExternalEmbedDraft} from 'lib/api/index'
|
||||
import {Gif} from 'state/queries/tenor'
|
||||
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
||||
import {ExternalLinkEmbed} from 'view/com/util/post-embeds/ExternalLinkEmbed'
|
||||
import {ExternalEmbedDraft} from '#/lib/api/index'
|
||||
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 {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -26,7 +26,7 @@ export const ExternalEmbed = ({
|
||||
title: link.meta?.title ?? link.uri,
|
||||
uri: link.uri,
|
||||
description: link.meta?.description ?? '',
|
||||
thumb: link.localThumb?.path,
|
||||
thumb: link.localThumb?.source.path,
|
||||
},
|
||||
[link],
|
||||
)
|
||||
|
||||
@@ -43,7 +43,7 @@ export function GifAltText({
|
||||
title: linkProp.meta?.title ?? linkProp.uri,
|
||||
uri: linkProp.uri,
|
||||
description: linkProp.meta?.description ?? '',
|
||||
thumb: linkProp.localThumb?.path,
|
||||
thumb: linkProp.localThumb?.source.path,
|
||||
},
|
||||
params: parseEmbedPlayerFromUrl(linkProp.uri),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
|
||||
import {ComposerImage} from '#/state/gallery'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
|
||||
export type EditImageDialogProps = {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
image: ComposerImage
|
||||
onChange: (next: ComposerImage) => void
|
||||
}
|
||||
|
||||
export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'react-image-crop/dist/ReactCrop.css'
|
||||
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import ReactCrop, {PercentCrop} from 'react-image-crop'
|
||||
|
||||
import {
|
||||
ImageSource,
|
||||
ImageTransformation,
|
||||
manipulateImage,
|
||||
} from '#/state/gallery'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {EditImageDialogProps} from './EditImageDialog'
|
||||
|
||||
export const EditImageDialog = (props: EditImageDialogProps) => {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<EditImageInner key={props.image.source.id} {...props} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
|
||||
const {_} = useLingui()
|
||||
|
||||
const source = image.source
|
||||
|
||||
const initialCrop = getInitialCrop(source, image.manips)
|
||||
const [crop, setCrop] = React.useState(initialCrop)
|
||||
|
||||
const isEmpty = !crop || (crop.width || crop.height) === 0
|
||||
const isNew = initialCrop ? true : !isEmpty
|
||||
|
||||
const onPressSubmit = React.useCallback(async () => {
|
||||
const result = await manipulateImage(image, {
|
||||
crop:
|
||||
crop && (crop.width || crop.height) !== 0
|
||||
? {
|
||||
originX: (crop.x * source.width) / 100,
|
||||
originY: (crop.y * source.height) / 100,
|
||||
width: (crop.width * source.width) / 100,
|
||||
height: (crop.height * source.height) / 100,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
onChange(result)
|
||||
control.close()
|
||||
}, [crop, image, source, control, onChange])
|
||||
|
||||
return (
|
||||
<Dialog.Inner label={_(msg`Edit image`)}>
|
||||
<Dialog.Close />
|
||||
|
||||
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
|
||||
<Trans>Edit image</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.align_center]}>
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
|
||||
className="ReactCrop--no-animate">
|
||||
<img src={source.path} style={{maxHeight: `50vh`}} />
|
||||
</ReactCrop>
|
||||
</View>
|
||||
|
||||
<View style={[a.mt_md, a.gap_md]}>
|
||||
<Button
|
||||
disabled={!isNew}
|
||||
label={_(msg`Save`)}
|
||||
size="large"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
onPress={onPressSubmit}>
|
||||
<ButtonText>
|
||||
<Trans>Save</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</Dialog.Inner>
|
||||
)
|
||||
}
|
||||
|
||||
const getInitialCrop = (
|
||||
source: ImageSource,
|
||||
manips: ImageTransformation | undefined,
|
||||
): PercentCrop | undefined => {
|
||||
const initialArea = manips?.crop
|
||||
|
||||
if (initialArea) {
|
||||
return {
|
||||
unit: '%',
|
||||
x: (initialArea.originX / source.width) * 100,
|
||||
y: (initialArea.originY / source.height) * 100,
|
||||
width: (initialArea.width / source.width) * 100,
|
||||
height: (initialArea.height / source.height) * 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,38 @@
|
||||
import React, {useState} from 'react'
|
||||
import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import React from 'react'
|
||||
import {
|
||||
ImageStyle,
|
||||
Keyboard,
|
||||
LayoutChangeEvent,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {Dimensions} from '#/lib/media/types'
|
||||
import {colors, s} from '#/lib/styles'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {GalleryModel} from '#/state/models/media/gallery'
|
||||
import {ComposerImage, cropImage} from '#/state/gallery'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {EditImageDialog} from './EditImageDialog'
|
||||
import {ImageAltTextDialog} from './ImageAltTextDialog'
|
||||
|
||||
const IMAGE_GAP = 8
|
||||
|
||||
interface GalleryProps {
|
||||
gallery: GalleryModel
|
||||
images: ComposerImage[]
|
||||
onChange: (next: ComposerImage[]) => void
|
||||
}
|
||||
|
||||
export const Gallery = (props: GalleryProps) => {
|
||||
const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>()
|
||||
export let Gallery = (props: GalleryProps): React.ReactNode => {
|
||||
const [containerInfo, setContainerInfo] = React.useState<Dimensions>()
|
||||
|
||||
const onLayout = (evt: LayoutChangeEvent) => {
|
||||
const {width, height} = evt.nativeEvent.layout
|
||||
@@ -41,177 +50,200 @@ export const Gallery = (props: GalleryProps) => {
|
||||
</View>
|
||||
)
|
||||
}
|
||||
Gallery = React.memo(Gallery)
|
||||
|
||||
interface GalleryInnerProps extends GalleryProps {
|
||||
containerInfo: Dimensions
|
||||
}
|
||||
|
||||
const GalleryInner = observer(function GalleryImpl({
|
||||
gallery,
|
||||
containerInfo,
|
||||
}: GalleryInnerProps) {
|
||||
const {_} = useLingui()
|
||||
const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => {
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const {openModal} = useModalControls()
|
||||
const t = useTheme()
|
||||
|
||||
let side: number
|
||||
const {altTextControlStyle, imageControlsStyle, imageStyle} =
|
||||
React.useMemo(() => {
|
||||
const side =
|
||||
images.length === 1
|
||||
? 250
|
||||
: (containerInfo.width - IMAGE_GAP * (images.length - 1)) /
|
||||
images.length
|
||||
|
||||
if (gallery.size === 1) {
|
||||
side = 250
|
||||
} else {
|
||||
side = (containerInfo.width - IMAGE_GAP * (gallery.size - 1)) / gallery.size
|
||||
}
|
||||
const isOverflow = isMobile && images.length > 2
|
||||
|
||||
const imageStyle = {
|
||||
height: side,
|
||||
width: side,
|
||||
}
|
||||
|
||||
const isOverflow = isMobile && gallery.size > 2
|
||||
|
||||
const altTextControlStyle = isOverflow
|
||||
? {
|
||||
left: 4,
|
||||
bottom: 4,
|
||||
}
|
||||
: !isMobile && gallery.size < 3
|
||||
? {
|
||||
left: 8,
|
||||
top: 8,
|
||||
}
|
||||
: {
|
||||
left: 4,
|
||||
top: 4,
|
||||
return {
|
||||
altTextControlStyle: isOverflow
|
||||
? {left: 4, bottom: 4}
|
||||
: !isMobile && images.length < 3
|
||||
? {left: 8, top: 8}
|
||||
: {left: 4, top: 4},
|
||||
imageControlsStyle: {
|
||||
display: 'flex' as const,
|
||||
flexDirection: 'row' as const,
|
||||
position: 'absolute' as const,
|
||||
...(isOverflow
|
||||
? {top: 4, right: 4, gap: 4}
|
||||
: !isMobile && images.length < 3
|
||||
? {top: 8, right: 8, gap: 8}
|
||||
: {top: 4, right: 4, gap: 4}),
|
||||
zIndex: 1,
|
||||
},
|
||||
imageStyle: {
|
||||
height: side,
|
||||
width: side,
|
||||
},
|
||||
}
|
||||
}, [images.length, containerInfo, isMobile])
|
||||
|
||||
const imageControlsStyle = {
|
||||
display: 'flex' as const,
|
||||
flexDirection: 'row' as const,
|
||||
position: 'absolute' as const,
|
||||
...(isOverflow
|
||||
? {
|
||||
top: 4,
|
||||
right: 4,
|
||||
gap: 4,
|
||||
}
|
||||
: !isMobile && gallery.size < 3
|
||||
? {
|
||||
top: 8,
|
||||
right: 8,
|
||||
gap: 8,
|
||||
}
|
||||
: {
|
||||
top: 4,
|
||||
right: 4,
|
||||
gap: 4,
|
||||
}),
|
||||
zIndex: 1,
|
||||
}
|
||||
|
||||
return !gallery.isEmpty ? (
|
||||
return images.length !== 0 ? (
|
||||
<>
|
||||
<View testID="selectedPhotosView" style={styles.gallery}>
|
||||
{gallery.images.map(image => (
|
||||
<View key={`selected-image-${image.path}`} style={[imageStyle]}>
|
||||
<TouchableOpacity
|
||||
testID="altTextButton"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Add alt text`)}
|
||||
accessibilityHint=""
|
||||
onPress={() => {
|
||||
Keyboard.dismiss()
|
||||
openModal({
|
||||
name: 'alt-text-image',
|
||||
image,
|
||||
})
|
||||
{images.map((image, index) => {
|
||||
return (
|
||||
<GalleryItem
|
||||
key={image.source.id}
|
||||
image={image}
|
||||
altTextControlStyle={altTextControlStyle}
|
||||
imageControlsStyle={imageControlsStyle}
|
||||
imageStyle={imageStyle}
|
||||
onChange={next => {
|
||||
onChange(
|
||||
images.map(i => (i.source === image.source ? next : i)),
|
||||
)
|
||||
}}
|
||||
style={[styles.altTextControl, altTextControlStyle]}>
|
||||
{image.altText.length > 0 ? (
|
||||
<FontAwesomeIcon
|
||||
icon="check"
|
||||
size={10}
|
||||
style={{color: t.palette.white}}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon="plus"
|
||||
size={10}
|
||||
style={{color: t.palette.white}}
|
||||
/>
|
||||
)}
|
||||
<Text style={styles.altTextControlLabel} accessible={false}>
|
||||
<Trans>ALT</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={imageControlsStyle}>
|
||||
<TouchableOpacity
|
||||
testID="editPhotoButton"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Edit image`)}
|
||||
accessibilityHint=""
|
||||
onPress={() => {
|
||||
if (isNative) {
|
||||
gallery.crop(image)
|
||||
} else {
|
||||
openModal({
|
||||
name: 'edit-image',
|
||||
image,
|
||||
gallery,
|
||||
})
|
||||
}
|
||||
}}
|
||||
style={styles.imageControl}>
|
||||
<FontAwesomeIcon
|
||||
icon="pen"
|
||||
size={12}
|
||||
style={{color: colors.white}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
testID="removePhotoButton"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Remove image`)}
|
||||
accessibilityHint=""
|
||||
onPress={() => gallery.remove(image)}
|
||||
style={styles.imageControl}>
|
||||
<FontAwesomeIcon
|
||||
icon="xmark"
|
||||
size={16}
|
||||
style={{color: colors.white}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Add alt text`)}
|
||||
accessibilityHint=""
|
||||
onPress={() => {
|
||||
Keyboard.dismiss()
|
||||
openModal({
|
||||
name: 'alt-text-image',
|
||||
image,
|
||||
})
|
||||
}}
|
||||
style={styles.altTextHiddenRegion}
|
||||
/>
|
||||
onRemove={() => {
|
||||
const next = images.slice()
|
||||
next.splice(index, 1)
|
||||
|
||||
<Image
|
||||
testID="selectedPhotoImage"
|
||||
style={[styles.image, imageStyle] as ImageStyle}
|
||||
source={{
|
||||
uri: image.cropped?.path ?? image.path,
|
||||
onChange(next)
|
||||
}}
|
||||
accessible={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
<AltTextReminder />
|
||||
</>
|
||||
) : null
|
||||
})
|
||||
}
|
||||
|
||||
type GalleryItemProps = {
|
||||
image: ComposerImage
|
||||
altTextControlStyle?: ViewStyle
|
||||
imageControlsStyle?: ViewStyle
|
||||
imageStyle?: ViewStyle
|
||||
onChange: (next: ComposerImage) => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
const GalleryItem = ({
|
||||
image,
|
||||
altTextControlStyle,
|
||||
imageControlsStyle,
|
||||
imageStyle,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: GalleryItemProps): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const altTextControl = Dialog.useDialogControl()
|
||||
const editControl = Dialog.useDialogControl()
|
||||
|
||||
const onImageEdit = () => {
|
||||
if (isNative) {
|
||||
cropImage(image).then(next => {
|
||||
onChange(next)
|
||||
})
|
||||
} else {
|
||||
editControl.open()
|
||||
}
|
||||
}
|
||||
|
||||
const onAltTextEdit = () => {
|
||||
Keyboard.dismiss()
|
||||
altTextControl.open()
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={imageStyle}>
|
||||
<TouchableOpacity
|
||||
testID="altTextButton"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Add alt text`)}
|
||||
accessibilityHint=""
|
||||
onPress={onAltTextEdit}
|
||||
style={[styles.altTextControl, altTextControlStyle]}>
|
||||
{image.alt.length !== 0 ? (
|
||||
<FontAwesomeIcon
|
||||
icon="check"
|
||||
size={10}
|
||||
style={{color: t.palette.white}}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon="plus"
|
||||
size={10}
|
||||
style={{color: t.palette.white}}
|
||||
/>
|
||||
)}
|
||||
<Text style={styles.altTextControlLabel} accessible={false}>
|
||||
<Trans>ALT</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={imageControlsStyle}>
|
||||
<TouchableOpacity
|
||||
testID="editPhotoButton"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Edit image`)}
|
||||
accessibilityHint=""
|
||||
onPress={onImageEdit}
|
||||
style={styles.imageControl}>
|
||||
<FontAwesomeIcon icon="pen" size={12} style={{color: colors.white}} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
testID="removePhotoButton"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Remove image`)}
|
||||
accessibilityHint=""
|
||||
onPress={onRemove}
|
||||
style={styles.imageControl}>
|
||||
<FontAwesomeIcon
|
||||
icon="xmark"
|
||||
size={16}
|
||||
style={{color: colors.white}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Add alt text`)}
|
||||
accessibilityHint=""
|
||||
onPress={onAltTextEdit}
|
||||
style={styles.altTextHiddenRegion}
|
||||
/>
|
||||
|
||||
<Image
|
||||
testID="selectedPhotoImage"
|
||||
style={[styles.image, imageStyle] as ImageStyle}
|
||||
source={{
|
||||
uri: (image.transformed ?? image.source).path,
|
||||
}}
|
||||
accessible={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
|
||||
<ImageAltTextDialog
|
||||
control={altTextControl}
|
||||
image={image}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<EditImageDialog
|
||||
control={editControl}
|
||||
image={image}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function AltTextReminder() {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import React from 'react'
|
||||
import {ImageStyle, useWindowDimensions, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {MAX_ALT_TEXT} from '#/lib/constants'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {ComposerImage} from '#/state/gallery'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
type Props = {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
image: ComposerImage
|
||||
onChange: (next: ComposerImage) => void
|
||||
}
|
||||
|
||||
export const ImageAltTextDialog = (props: Props): React.ReactNode => {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<ImageAltTextInner {...props} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
const ImageAltTextInner = ({
|
||||
control,
|
||||
image,
|
||||
onChange,
|
||||
}: Props): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const windim = useWindowDimensions()
|
||||
|
||||
const [altText, setAltText] = React.useState(image.alt)
|
||||
|
||||
const onPressSubmit = React.useCallback(() => {
|
||||
control.close()
|
||||
onChange({...image, alt: altText.trim()})
|
||||
}, [control, image, altText, onChange])
|
||||
|
||||
const imageStyle = React.useMemo<ImageStyle>(() => {
|
||||
const maxWidth = isWeb ? 450 : windim.width
|
||||
const source = image.transformed ?? image.source
|
||||
|
||||
if (source.height > source.width) {
|
||||
return {
|
||||
resizeMode: 'contain',
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
borderRadius: 8,
|
||||
}
|
||||
}
|
||||
return {
|
||||
width: '100%',
|
||||
height: (maxWidth / source.width) * source.height,
|
||||
borderRadius: 8,
|
||||
}
|
||||
}, [image, windim])
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label={_(msg`Add alt text`)}>
|
||||
<Dialog.Close />
|
||||
|
||||
<View>
|
||||
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
|
||||
<Trans>Add alt text</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[t.atoms.bg_contrast_50, a.rounded_sm, a.overflow_hidden]}>
|
||||
<Image
|
||||
style={imageStyle}
|
||||
source={{
|
||||
uri: (image.transformed ?? image.source).path,
|
||||
}}
|
||||
contentFit="contain"
|
||||
accessible={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
enableLiveTextInteraction
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[a.mt_md, a.gap_md]}>
|
||||
<View>
|
||||
<TextField.LabelText>
|
||||
<Trans>Descriptive alt text</Trans>
|
||||
</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<Dialog.Input
|
||||
label={_(msg`Alt text`)}
|
||||
onChangeText={text => setAltText(text)}
|
||||
value={altText}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
autoFocus
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Save`)}
|
||||
disabled={altText.length > MAX_ALT_TEXT || altText === image.alt}
|
||||
size="large"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
onPress={onPressSubmit}>
|
||||
<ButtonText>
|
||||
<Trans>Save</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -9,17 +9,17 @@ import {useCameraPermission} from '#/lib/hooks/usePermissions'
|
||||
import {openCamera} from '#/lib/media/picker'
|
||||
import {logger} from '#/logger'
|
||||
import {isMobileWeb, isNative} from '#/platform/detection'
|
||||
import {GalleryModel} from '#/state/models/media/gallery'
|
||||
import {ComposerImage, createComposerImage} from '#/state/gallery'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera'
|
||||
|
||||
type Props = {
|
||||
gallery: GalleryModel
|
||||
disabled?: boolean
|
||||
onAdd: (next: ComposerImage[]) => void
|
||||
}
|
||||
|
||||
export function OpenCameraBtn({gallery, disabled}: Props) {
|
||||
export function OpenCameraBtn({disabled, onAdd}: Props) {
|
||||
const {track} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||
@@ -48,13 +48,16 @@ export function OpenCameraBtn({gallery, disabled}: Props) {
|
||||
if (mediaPermissionRes) {
|
||||
await MediaLibrary.createAssetAsync(img.path)
|
||||
}
|
||||
gallery.add(img)
|
||||
|
||||
const res = await createComposerImage(img)
|
||||
|
||||
onAdd([res])
|
||||
} catch (err: any) {
|
||||
// ignore
|
||||
logger.warn('Error using camera', {error: err})
|
||||
}
|
||||
}, [
|
||||
gallery,
|
||||
onAdd,
|
||||
track,
|
||||
requestCameraAccessIfNeeded,
|
||||
mediaPermissionRes,
|
||||
|
||||
@@ -5,18 +5,20 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
|
||||
import {openPicker} from '#/lib/media/picker'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {GalleryModel} from '#/state/models/media/gallery'
|
||||
import {ComposerImage, createComposerImage} from '#/state/gallery'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
|
||||
|
||||
type Props = {
|
||||
gallery: GalleryModel
|
||||
size: number
|
||||
disabled?: boolean
|
||||
onAdd: (next: ComposerImage[]) => void
|
||||
}
|
||||
|
||||
export function SelectPhotoBtn({gallery, disabled}: Props) {
|
||||
export function SelectPhotoBtn({size, disabled, onAdd}: Props) {
|
||||
const {track} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||
@@ -29,8 +31,17 @@ export function SelectPhotoBtn({gallery, disabled}: Props) {
|
||||
return
|
||||
}
|
||||
|
||||
gallery.pick()
|
||||
}, [track, requestPhotoAccessIfNeeded, gallery])
|
||||
const images = await openPicker({
|
||||
selectionLimit: 4 - size,
|
||||
allowsMultipleSelection: true,
|
||||
})
|
||||
|
||||
const results = await Promise.all(
|
||||
images.map(img => createComposerImage(img)),
|
||||
)
|
||||
|
||||
onAdd(results)
|
||||
}, [track, requestPhotoAccessIfNeeded, size, onAdd])
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -8,7 +8,7 @@ import React, {
|
||||
} from 'react'
|
||||
import {
|
||||
NativeSyntheticEvent,
|
||||
StyleSheet,
|
||||
Text as RNText,
|
||||
TextInput as RNTextInput,
|
||||
TextInputSelectionChangeEventData,
|
||||
View,
|
||||
@@ -20,18 +20,16 @@ import PasteInput, {
|
||||
} from '@haileyok/react-native-paste-input'
|
||||
|
||||
import {POST_IMG_MAX} from '#/lib/constants'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {downloadAndResize} from '#/lib/media/manip'
|
||||
import {isUriImage} from '#/lib/media/util'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {getMentionAt, insertMentionAt} from '#/lib/strings/mention-manip'
|
||||
import {useTheme} from '#/lib/ThemeContext'
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {isAndroid, isNative} from '#/platform/detection'
|
||||
import {
|
||||
LinkFacetMatch,
|
||||
suggestLinkCardUri,
|
||||
} from '#/view/com/composer/text-input/text-input-util'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {atoms as a, useAlf} from '#/alf'
|
||||
import {normalizeTextStyles} from '#/components/Typography'
|
||||
import {Autocomplete} from './mobile/Autocomplete'
|
||||
@@ -70,7 +68,6 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
ref,
|
||||
) {
|
||||
const {theme: t, fonts} = useAlf()
|
||||
const pal = usePalette('default')
|
||||
const textInput = useRef<PasteInputRef>(null)
|
||||
const textInputSelection = useRef<Selection>({start: 0, end: 0})
|
||||
const theme = useTheme()
|
||||
@@ -193,10 +190,12 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
},
|
||||
)
|
||||
|
||||
/*
|
||||
* `PasteInput` appears to prefer no `lineHeight`
|
||||
/**
|
||||
* PasteInput doesn't like `lineHeight`, results in jumpiness
|
||||
*/
|
||||
style.lineHeight = undefined
|
||||
if (isNative) {
|
||||
style.lineHeight = undefined
|
||||
}
|
||||
|
||||
/*
|
||||
* Android impl of `PasteInput` doesn't support the array syntax for `fontVariant`
|
||||
@@ -215,17 +214,23 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
|
||||
return Array.from(richtext.segments()).map(segment => {
|
||||
return (
|
||||
<Text
|
||||
<RNText
|
||||
key={i++}
|
||||
style={[inputTextStyle, segment.facet ? pal.link : pal.text]}>
|
||||
style={[
|
||||
inputTextStyle,
|
||||
{
|
||||
color: segment.facet ? t.palette.primary_500 : t.atoms.text.color,
|
||||
marginTop: -1,
|
||||
},
|
||||
]}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
</RNText>
|
||||
)
|
||||
})
|
||||
}, [richtext, pal.link, pal.text, inputTextStyle])
|
||||
}, [t, richtext, inputTextStyle])
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[a.flex_1, a.pl_md, a.pb_2xl]}>
|
||||
<PasteInput
|
||||
testID="composerTextInput"
|
||||
ref={textInput}
|
||||
@@ -233,14 +238,14 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
onPaste={onPaste}
|
||||
onSelectionChange={onSelectionChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
placeholderTextColor={t.atoms.text_contrast_medium.color}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
autoFocus={true}
|
||||
allowFontScaling
|
||||
multiline
|
||||
scrollEnabled={false}
|
||||
numberOfLines={4}
|
||||
style={[inputTextStyle, styles.textInput, {textAlignVertical: 'top'}]}
|
||||
style={[inputTextStyle, a.w_full, {textAlignVertical: 'top'}]}
|
||||
{...props}>
|
||||
{textDecorated}
|
||||
</PasteInput>
|
||||
@@ -251,17 +256,3 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
textInput: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
padding: 5,
|
||||
paddingBottom: 20,
|
||||
marginLeft: 8,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,19 +5,20 @@ import React, {
|
||||
useState,
|
||||
} from 'react'
|
||||
import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {ReactRenderer} from '@tiptap/react'
|
||||
import tippy, {Instance as TippyInstance} from 'tippy.js'
|
||||
import {
|
||||
SuggestionKeyDownProps,
|
||||
SuggestionOptions,
|
||||
SuggestionProps,
|
||||
SuggestionKeyDownProps,
|
||||
} from '@tiptap/suggestion'
|
||||
import tippy, {Instance as TippyInstance} from 'tippy.js'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {useGrapheme} from '../hooks/useGrapheme'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
interface MentionListRef {
|
||||
onKeyDown: (props: SuggestionKeyDownProps) => boolean
|
||||
@@ -180,7 +181,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
|
||||
size={26}
|
||||
type={item.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<Text style={pal.text} numberOfLines={1}>
|
||||
<Text emoji style={pal.text} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -2,22 +2,18 @@ import {useEffect, useState} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useFetchDid} from '#/state/queries/handle'
|
||||
import {useGetPost} from '#/state/queries/post'
|
||||
import {useAgent} from '#/state/session'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
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'
|
||||
} 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,
|
||||
@@ -25,9 +21,13 @@ import {
|
||||
isBskyStarterPackUrl,
|
||||
isBskyStartUrl,
|
||||
isShortLink,
|
||||
} from 'lib/strings/url-helpers'
|
||||
import {ImageModel} from 'state/models/media/image'
|
||||
import {ComposerOpts} from 'state/shell/composer'
|
||||
} 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,
|
||||
@@ -161,14 +161,15 @@ export function useExternalLinkFetch({
|
||||
timeout: 15e3,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.then(localThumb => {
|
||||
.then(thumb => (thumb ? createComposerImage(thumb) : undefined))
|
||||
.then(thumb => {
|
||||
if (aborted) {
|
||||
return
|
||||
}
|
||||
setExtLink({
|
||||
...extLink,
|
||||
isLoading: false, // done
|
||||
localThumb: localThumb ? new ImageModel(localThumb) : undefined,
|
||||
localThumb: thumb,
|
||||
})
|
||||
})
|
||||
return cleanup
|
||||
|
||||
@@ -12,6 +12,10 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {s} from '#/lib/styles'
|
||||
import {logger} from '#/logger'
|
||||
import {shouldClickOpenNewTab} from '#/platform/urls'
|
||||
import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
|
||||
@@ -21,12 +25,8 @@ import {
|
||||
UsePreferencesQueryResponse,
|
||||
useRemoveFeedMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {s} from 'lib/styles'
|
||||
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
@@ -242,7 +242,7 @@ export function FeedSourceCardLoaded({
|
||||
<UserAvatar type="algo" size={36} avatar={feed.avatar} />
|
||||
</View>
|
||||
<View style={[styles.headerTextContainer]}>
|
||||
<Text style={[pal.text, s.bold]} numberOfLines={1}>
|
||||
<Text emoji style={[pal.text, s.bold]} numberOfLines={1}>
|
||||
{feed.displayName}
|
||||
</Text>
|
||||
<Text style={[pal.textLight]} numberOfLines={1}>
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import React, {useCallback, useMemo, useState} from 'react'
|
||||
import {
|
||||
ImageStyle,
|
||||
ScrollView as RNScrollView,
|
||||
StyleSheet,
|
||||
TextInput as RNTextInput,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {MAX_ALT_TEXT} from 'lib/constants'
|
||||
import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {enforceLen} from 'lib/strings/helpers'
|
||||
import {gradients, s} from 'lib/styles'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {isAndroid, isWeb} from 'platform/detection'
|
||||
import {ImageModel} from 'state/models/media/image'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {ScrollView, TextInput} from './util'
|
||||
|
||||
export const snapPoints = ['100%']
|
||||
|
||||
interface Props {
|
||||
image: ImageModel
|
||||
}
|
||||
|
||||
export function Component({image}: Props) {
|
||||
const pal = usePalette('default')
|
||||
const theme = useTheme()
|
||||
const {_} = useLingui()
|
||||
const [altText, setAltText] = useState(image.altText)
|
||||
const windim = useWindowDimensions()
|
||||
const {closeModal} = useModalControls()
|
||||
const inputRef = React.useRef<RNTextInput>(null)
|
||||
const scrollViewRef = React.useRef<RNScrollView>(null)
|
||||
const keyboardShown = useIsKeyboardVisible()
|
||||
|
||||
// Autofocus hack when we open the modal. We have to wait for the animation to complete first
|
||||
React.useEffect(() => {
|
||||
if (isAndroid) return
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, 500)
|
||||
}, [])
|
||||
|
||||
// We'd rather be at the bottom here so that we can easily dismiss the modal instead of having to scroll
|
||||
// (especially on android, it acts weird)
|
||||
React.useEffect(() => {
|
||||
if (keyboardShown[0]) {
|
||||
scrollViewRef.current?.scrollToEnd()
|
||||
}
|
||||
}, [keyboardShown])
|
||||
|
||||
const imageStyles = useMemo<ImageStyle>(() => {
|
||||
const maxWidth = isWeb ? 450 : windim.width
|
||||
if (image.height > image.width) {
|
||||
return {
|
||||
resizeMode: 'contain',
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
borderRadius: 8,
|
||||
}
|
||||
}
|
||||
return {
|
||||
width: '100%',
|
||||
height: (maxWidth / image.width) * image.height,
|
||||
borderRadius: 8,
|
||||
}
|
||||
}, [image, windim])
|
||||
|
||||
const onUpdate = useCallback(
|
||||
(v: string) => {
|
||||
v = enforceLen(v, MAX_ALT_TEXT)
|
||||
setAltText(v)
|
||||
image.setAltText(v)
|
||||
},
|
||||
[setAltText, image],
|
||||
)
|
||||
|
||||
const onPressSave = useCallback(() => {
|
||||
image.setAltText(altText)
|
||||
closeModal()
|
||||
}, [closeModal, image, altText])
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
testID="altTextImageModal"
|
||||
style={[pal.view, styles.scrollContainer]}
|
||||
keyboardShouldPersistTaps="always"
|
||||
ref={scrollViewRef}
|
||||
nativeID="imageAltText">
|
||||
<View style={styles.scrollInner}>
|
||||
<View style={[pal.viewLight, styles.imageContainer]}>
|
||||
<Image
|
||||
testID="selectedPhotoImage"
|
||||
style={imageStyles}
|
||||
source={{
|
||||
uri: image.cropped?.path ?? image.path,
|
||||
}}
|
||||
contentFit="contain"
|
||||
accessible={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
enableLiveTextInteraction
|
||||
/>
|
||||
</View>
|
||||
<TextInput
|
||||
testID="altTextImageInput"
|
||||
style={[styles.textArea, pal.border, pal.text]}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
multiline
|
||||
placeholder={_(msg`Add alt text`)}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
value={altText}
|
||||
onChangeText={onUpdate}
|
||||
accessibilityLabel={_(msg`Image alt text`)}
|
||||
accessibilityHint=""
|
||||
accessibilityLabelledBy="imageAltText"
|
||||
// @ts-ignore This is fine, type is weird on the BottomSheetTextInput
|
||||
ref={inputRef}
|
||||
/>
|
||||
<View style={styles.buttonControls}>
|
||||
<TouchableOpacity
|
||||
testID="altTextImageSaveBtn"
|
||||
onPress={onPressSave}
|
||||
accessibilityLabel={_(msg`Save alt text`)}
|
||||
accessibilityHint=""
|
||||
accessibilityRole="button">
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[styles.button]}>
|
||||
<Text type="button-lg" style={[s.white, s.bold]}>
|
||||
<Trans>Done</Trans>
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
paddingHorizontal: isWeb ? 0 : 12,
|
||||
paddingVertical: isWeb ? 0 : 24,
|
||||
},
|
||||
scrollInner: {
|
||||
gap: 12,
|
||||
paddingTop: isWeb ? 0 : 12,
|
||||
},
|
||||
imageContainer: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
textArea: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 6,
|
||||
paddingTop: 10,
|
||||
paddingHorizontal: 12,
|
||||
fontSize: 16,
|
||||
height: 100,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
borderRadius: 32,
|
||||
padding: 10,
|
||||
},
|
||||
buttonControls: {
|
||||
gap: 8,
|
||||
paddingBottom: isWeb ? 0 : 50,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import ReactCrop, {PercentCrop} from 'react-image-crop'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {getDataUriSize} from '#/lib/media/util'
|
||||
import {gradients, s} from '#/lib/styles'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
|
||||
export const snapPoints = ['0%']
|
||||
|
||||
export function Component({
|
||||
uri,
|
||||
aspect,
|
||||
circular,
|
||||
onSelect,
|
||||
}: {
|
||||
uri: string
|
||||
aspect?: number
|
||||
circular?: boolean
|
||||
onSelect: (img?: RNImage) => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
|
||||
const {closeModal} = useModalControls()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
|
||||
const imageRef = React.useRef<HTMLImageElement>(null)
|
||||
const [crop, setCrop] = React.useState<PercentCrop>()
|
||||
|
||||
const isEmpty = !crop || (crop.width || crop.height) === 0
|
||||
|
||||
const onPressCancel = () => {
|
||||
onSelect(undefined)
|
||||
closeModal()
|
||||
}
|
||||
const onPressDone = async () => {
|
||||
const img = imageRef.current!
|
||||
|
||||
const result = await manipulateAsync(
|
||||
uri,
|
||||
isEmpty
|
||||
? []
|
||||
: [
|
||||
{
|
||||
crop: {
|
||||
originX: (crop.x * img.naturalWidth) / 100,
|
||||
originY: (crop.y * img.naturalHeight) / 100,
|
||||
width: (crop.width * img.naturalWidth) / 100,
|
||||
height: (crop.height * img.naturalHeight) / 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
base64: true,
|
||||
format: SaveFormat.JPEG,
|
||||
},
|
||||
)
|
||||
|
||||
onSelect({
|
||||
path: result.uri,
|
||||
mime: 'image/jpeg',
|
||||
size: result.base64 !== undefined ? getDataUriSize(result.base64) : 0,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
})
|
||||
|
||||
closeModal()
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={[styles.cropper, pal.borderDark]}>
|
||||
<ReactCrop
|
||||
aspect={aspect}
|
||||
crop={crop}
|
||||
onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
|
||||
circularCrop={circular}>
|
||||
<img ref={imageRef} src={uri} style={{maxHeight: '75vh'}} />
|
||||
</ReactCrop>
|
||||
</View>
|
||||
<View style={[styles.btns, isMobile && {paddingHorizontal: 16}]}>
|
||||
<TouchableOpacity
|
||||
onPress={onPressCancel}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Cancel image crop`)}
|
||||
accessibilityHint={_(msg`Exits image cropping process`)}>
|
||||
<Text type="xl" style={pal.link}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.flex1} />
|
||||
<TouchableOpacity
|
||||
onPress={onPressDone}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Save image crop`)}
|
||||
accessibilityHint={_(msg`Saves image crop settings`)}>
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[styles.btn]}>
|
||||
<Text type="xl-medium" style={s.white}>
|
||||
<Trans>Done</Trans>
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
cropper: {
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
alignItems: 'center',
|
||||
},
|
||||
ctrls: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 10,
|
||||
},
|
||||
btns: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 10,
|
||||
},
|
||||
btn: {
|
||||
borderRadius: 4,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
})
|
||||
@@ -1,380 +0,0 @@
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import {useWindowDimensions} from 'react-native'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Slider} from '@miblanchard/react-native-slider'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import ImageEditor, {Position} from 'react-avatar-editor'
|
||||
|
||||
import {MAX_ALT_TEXT} from '#/lib/constants'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {enforceLen} from '#/lib/strings/helpers'
|
||||
import {gradients, s} from '#/lib/styles'
|
||||
import {useTheme} from '#/lib/ThemeContext'
|
||||
import {getKeys} from '#/lib/type-assertions'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {GalleryModel} from '#/state/models/media/gallery'
|
||||
import {ImageModel} from '#/state/models/media/image'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {
|
||||
AspectRatio11_Stroke2_Corner0_Rounded as A11,
|
||||
AspectRatio34_Stroke2_Corner0_Rounded as A34,
|
||||
AspectRatio43_Stroke2_Corner0_Rounded as A43,
|
||||
} from '#/components/icons/AspectRatio'
|
||||
import {CircleBanSign_Stroke2_Corner0_Rounded as Ban} from '#/components/icons/CircleBanSign'
|
||||
import {
|
||||
FlipHorizontal_Stroke2_Corner0_Rounded as FlipHorizontal,
|
||||
FlipVertical_Stroke2_Corner0_Rounded as FlipVertical,
|
||||
} from '#/components/icons/FlipImage'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {TextInput} from './util'
|
||||
|
||||
export const snapPoints = ['80%']
|
||||
|
||||
const RATIOS = {
|
||||
'4:3': {
|
||||
icon: A43,
|
||||
},
|
||||
'1:1': {
|
||||
icon: A11,
|
||||
},
|
||||
'3:4': {
|
||||
icon: A34,
|
||||
},
|
||||
None: {
|
||||
icon: Ban,
|
||||
},
|
||||
} as const
|
||||
|
||||
type AspectRatio = keyof typeof RATIOS
|
||||
|
||||
interface Props {
|
||||
image: ImageModel
|
||||
gallery: GalleryModel
|
||||
}
|
||||
|
||||
export const Component = observer(function EditImageImpl({
|
||||
image,
|
||||
gallery,
|
||||
}: Props) {
|
||||
const pal = usePalette('default')
|
||||
const theme = useTheme()
|
||||
const {_} = useLingui()
|
||||
const windowDimensions = useWindowDimensions()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const {closeModal} = useModalControls()
|
||||
|
||||
const {
|
||||
aspectRatio,
|
||||
// rotate = 0
|
||||
} = image.attributes
|
||||
|
||||
const editorRef = useRef<ImageEditor>(null)
|
||||
const [scale, setScale] = useState<number>(image.attributes.scale ?? 1)
|
||||
const [position, setPosition] = useState<Position | undefined>(
|
||||
image.attributes.position,
|
||||
)
|
||||
const [altText, setAltText] = useState(image?.altText ?? '')
|
||||
|
||||
const onFlipHorizontal = useCallback(() => {
|
||||
image.flipHorizontal()
|
||||
}, [image])
|
||||
|
||||
const onFlipVertical = useCallback(() => {
|
||||
image.flipVertical()
|
||||
}, [image])
|
||||
|
||||
// const onSetRotate = useCallback(
|
||||
// (direction: 'left' | 'right') => {
|
||||
// const rotation = (rotate + 90 * (direction === 'left' ? -1 : 1)) % 360
|
||||
// image.setRotate(rotation)
|
||||
// },
|
||||
// [rotate, image],
|
||||
// )
|
||||
|
||||
const onSetRatio = useCallback(
|
||||
(ratio: AspectRatio) => {
|
||||
image.setRatio(ratio)
|
||||
},
|
||||
[image],
|
||||
)
|
||||
|
||||
const adjustments = useMemo(
|
||||
() => [
|
||||
// {
|
||||
// name: 'rotate-left' as const,
|
||||
// label: 'Rotate left',
|
||||
// onPress: () => {
|
||||
// onSetRotate('left')
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'rotate-right' as const,
|
||||
// label: 'Rotate right',
|
||||
// onPress: () => {
|
||||
// onSetRotate('right')
|
||||
// },
|
||||
// },
|
||||
{
|
||||
icon: FlipHorizontal,
|
||||
label: _(msg`Flip horizontal`),
|
||||
onPress: onFlipHorizontal,
|
||||
},
|
||||
{
|
||||
icon: FlipVertical,
|
||||
label: _(msg`Flip vertically`),
|
||||
onPress: onFlipVertical,
|
||||
},
|
||||
],
|
||||
[onFlipHorizontal, onFlipVertical, _],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
image.prev = image.cropped
|
||||
image.prevAttributes = image.attributes
|
||||
image.resetCropped()
|
||||
}, [image])
|
||||
|
||||
const onCloseModal = useCallback(() => {
|
||||
closeModal()
|
||||
}, [closeModal])
|
||||
|
||||
const onPressCancel = useCallback(async () => {
|
||||
await gallery.previous(image)
|
||||
onCloseModal()
|
||||
}, [onCloseModal, gallery, image])
|
||||
|
||||
const onPressSave = useCallback(async () => {
|
||||
image.setAltText(altText)
|
||||
|
||||
const crop = editorRef.current?.getCroppingRect()
|
||||
|
||||
await image.manipulate({
|
||||
...(crop !== undefined
|
||||
? {
|
||||
crop: {
|
||||
originX: crop.x,
|
||||
originY: crop.y,
|
||||
width: crop.width,
|
||||
height: crop.height,
|
||||
},
|
||||
...(scale !== 1 ? {scale} : {}),
|
||||
...(position !== undefined ? {position} : {}),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
|
||||
image.prev = image.cropped
|
||||
image.prevAttributes = image.attributes
|
||||
onCloseModal()
|
||||
}, [altText, image, position, scale, onCloseModal])
|
||||
|
||||
if (image.cropped === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const computedWidth =
|
||||
windowDimensions.width > 500 ? 410 : windowDimensions.width - 80
|
||||
const sideLength = isMobile ? computedWidth : 300
|
||||
|
||||
const dimensions = image.getResizedDimensions(aspectRatio, sideLength)
|
||||
const imgContainerStyles = {width: sideLength, height: sideLength}
|
||||
|
||||
const imgControlStyles = {
|
||||
alignItems: 'center' as const,
|
||||
flexDirection: isMobile ? ('column' as const) : ('row' as const),
|
||||
gap: isMobile ? 0 : 5,
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
testID="editImageModal"
|
||||
style={[
|
||||
pal.view,
|
||||
styles.container,
|
||||
s.flex1,
|
||||
{
|
||||
paddingHorizontal: isMobile ? 16 : undefined,
|
||||
},
|
||||
]}>
|
||||
<Text style={[styles.title, pal.text]}>
|
||||
<Trans>Edit image</Trans>
|
||||
</Text>
|
||||
<View style={[styles.gap18, s.flexRow]}>
|
||||
<View>
|
||||
<View
|
||||
style={[styles.imgContainer, pal.borderDark, imgContainerStyles]}>
|
||||
<ImageEditor
|
||||
ref={editorRef}
|
||||
style={styles.imgEditor}
|
||||
image={image.cropped.path}
|
||||
scale={scale}
|
||||
border={0}
|
||||
position={position}
|
||||
onPositionChange={setPosition}
|
||||
{...dimensions}
|
||||
/>
|
||||
</View>
|
||||
<Slider
|
||||
value={scale}
|
||||
onValueChange={(v: number | number[]) =>
|
||||
setScale(Array.isArray(v) ? v[0] : v)
|
||||
}
|
||||
minimumValue={1}
|
||||
maximumValue={3}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.gap_sm]}>
|
||||
{!isMobile ? (
|
||||
<Text type="sm-bold" style={pal.text}>
|
||||
<Trans>Ratios</Trans>
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={imgControlStyles}>
|
||||
{getKeys(RATIOS).map(ratio => {
|
||||
const {icon} = RATIOS[ratio]
|
||||
const isSelected = aspectRatio === ratio
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={ratio}
|
||||
label={ratio}
|
||||
size="large"
|
||||
shape="square"
|
||||
variant="outline"
|
||||
color={isSelected ? 'primary' : 'secondary'}
|
||||
onPress={() => {
|
||||
onSetRatio(ratio)
|
||||
}}>
|
||||
<View style={[a.align_center, a.gap_2xs]}>
|
||||
<ButtonIcon icon={icon} />
|
||||
<ButtonText style={[a.text_xs]}>{ratio}</ButtonText>
|
||||
</View>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
{!isMobile ? (
|
||||
<Text type="sm-bold" style={[pal.text, styles.subsection]}>
|
||||
<Trans>Transformations</Trans>
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={imgControlStyles}>
|
||||
{adjustments.map(({label, icon, onPress}) => (
|
||||
<Button
|
||||
key={label}
|
||||
label={label}
|
||||
size="large"
|
||||
shape="square"
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
onPress={onPress}>
|
||||
<ButtonIcon icon={icon} />
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.gap18, styles.bottomSection, pal.border]}>
|
||||
<Text type="sm-bold" style={pal.text} nativeID="alt-text">
|
||||
<Trans>Accessibility</Trans>
|
||||
</Text>
|
||||
<TextInput
|
||||
testID="altTextImageInput"
|
||||
style={[
|
||||
styles.textArea,
|
||||
pal.border,
|
||||
pal.text,
|
||||
{
|
||||
maxHeight: isMobile ? 50 : undefined,
|
||||
},
|
||||
]}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
multiline
|
||||
value={altText}
|
||||
onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
|
||||
accessibilityLabel={_(msg`Alt text`)}
|
||||
accessibilityHint=""
|
||||
accessibilityLabelledBy="alt-text"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.btns}>
|
||||
<Pressable onPress={onPressCancel} accessibilityRole="button">
|
||||
<Text type="xl" style={pal.link}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={onPressSave} accessibilityRole="button">
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[styles.btn]}>
|
||||
<Text type="xl-medium" style={s.white}>
|
||||
<Trans context="action">Done</Trans>
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 18,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
},
|
||||
subsection: {marginTop: 12},
|
||||
gap18: {gap: 18},
|
||||
title: {
|
||||
fontWeight: '600',
|
||||
fontSize: 24,
|
||||
},
|
||||
btns: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
btn: {
|
||||
borderRadius: 4,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
imgEditor: {
|
||||
maxWidth: '100%',
|
||||
},
|
||||
imgContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
marginBottom: 4,
|
||||
},
|
||||
flipVertical: {
|
||||
transform: [{rotate: '90deg'}],
|
||||
},
|
||||
flipBtn: {
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
textArea: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 6,
|
||||
paddingTop: 10,
|
||||
paddingHorizontal: 12,
|
||||
fontSize: 16,
|
||||
height: 100,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
bottomSection: {
|
||||
borderTopWidth: 1,
|
||||
paddingTop: 18,
|
||||
},
|
||||
})
|
||||
@@ -3,13 +3,11 @@ import {StyleSheet} from 'react-native'
|
||||
import {SafeAreaView} from 'react-native-safe-area-context'
|
||||
import BottomSheet from '@discord/bottom-sheet/src'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useModalControls, useModals} from '#/state/modals'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
|
||||
import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
|
||||
import * as AddAppPassword from './AddAppPasswords'
|
||||
import * as AltImageModal from './AltImage'
|
||||
import * as EditImageModal from './AltImage'
|
||||
import * as ChangeEmailModal from './ChangeEmail'
|
||||
import * as ChangeHandleModal from './ChangeHandle'
|
||||
import * as ChangePasswordModal from './ChangePassword'
|
||||
@@ -75,12 +73,6 @@ export function ModalsContainer() {
|
||||
} else if (activeModal?.name === 'self-label') {
|
||||
snapPoints = SelfLabelModal.snapPoints
|
||||
element = <SelfLabelModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'alt-text-image') {
|
||||
snapPoints = AltImageModal.snapPoints
|
||||
element = <AltImageModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'edit-image') {
|
||||
snapPoints = AltImageModal.snapPoints
|
||||
element = <EditImageModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'change-handle') {
|
||||
snapPoints = ChangeHandleModal.snapPoints
|
||||
element = <ChangeHandleModal.Component {...activeModal} />
|
||||
|
||||
@@ -2,20 +2,18 @@ import React from 'react'
|
||||
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
|
||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import type {Modal as ModalIface} from '#/state/modals'
|
||||
import {useModalControls, useModals} from '#/state/modals'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import * as AddAppPassword from './AddAppPasswords'
|
||||
import * as AltTextImageModal from './AltImage'
|
||||
import * as ChangeEmailModal from './ChangeEmail'
|
||||
import * as ChangeHandleModal from './ChangeHandle'
|
||||
import * as ChangePasswordModal from './ChangePassword'
|
||||
import * as CreateOrEditListModal from './CreateOrEditList'
|
||||
import * as CropImageModal from './crop-image/CropImage.web'
|
||||
import * as CropImageModal from './CropImage.web'
|
||||
import * as DeleteAccountModal from './DeleteAccount'
|
||||
import * as EditImageModal from './EditImage'
|
||||
import * as EditProfileModal from './EditProfile'
|
||||
import * as InviteCodesModal from './InviteCodes'
|
||||
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||
@@ -54,11 +52,7 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||
}
|
||||
|
||||
const onPressMask = () => {
|
||||
if (
|
||||
modal.name === 'crop-image' ||
|
||||
modal.name === 'edit-image' ||
|
||||
modal.name === 'alt-text-image'
|
||||
) {
|
||||
if (modal.name === 'crop-image') {
|
||||
return // dont close on mask presses during crop
|
||||
}
|
||||
closeModal()
|
||||
@@ -93,10 +87,6 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||
element = <ContentLanguagesSettingsModal.Component />
|
||||
} else if (modal.name === 'post-languages-settings') {
|
||||
element = <PostLanguagesSettingsModal.Component />
|
||||
} else if (modal.name === 'alt-text-image') {
|
||||
element = <AltTextImageModal.Component {...modal} />
|
||||
} else if (modal.name === 'edit-image') {
|
||||
element = <EditImageModal.Component {...modal} />
|
||||
} else if (modal.name === 'verify-email') {
|
||||
element = <VerifyEmailModal.Component {...modal} />
|
||||
} else if (modal.name === 'change-email') {
|
||||
|
||||
@@ -65,21 +65,27 @@ export function Component({
|
||||
return [pal.border, {flex: 1, borderTopWidth: StyleSheet.hairlineWidth}]
|
||||
}, [pal.border, screenHeight])
|
||||
|
||||
const headerStyles = [
|
||||
{
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
fontSize: 20,
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 12,
|
||||
} as const,
|
||||
pal.text,
|
||||
]
|
||||
|
||||
return (
|
||||
<View testID="userAddRemoveListsModal" style={s.hContentRegion}>
|
||||
<Text
|
||||
style={[
|
||||
{
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
fontSize: 20,
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
pal.text,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
<Trans>Update {displayName} in Lists</Trans>
|
||||
<Text style={headerStyles} numberOfLines={1}>
|
||||
<Trans>
|
||||
Update{' '}
|
||||
<Text style={headerStyles} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>{' '}
|
||||
in Lists
|
||||
</Trans>
|
||||
</Text>
|
||||
<MyLists
|
||||
filter="all"
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Slider} from '@miblanchard/react-native-slider'
|
||||
import ImageEditor from 'react-avatar-editor'
|
||||
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {RectTallIcon, RectWideIcon, SquareIcon} from 'lib/icons'
|
||||
import {Dimensions} from 'lib/media/types'
|
||||
import {getDataUriSize} from 'lib/media/util'
|
||||
import {gradients, s} from 'lib/styles'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {calculateDimensions} from './cropImageUtil'
|
||||
|
||||
enum AspectRatio {
|
||||
Square = 'square',
|
||||
Wide = 'wide',
|
||||
Tall = 'tall',
|
||||
Custom = 'custom',
|
||||
}
|
||||
|
||||
const DIMS: Record<string, Dimensions> = {
|
||||
[AspectRatio.Square]: {width: 1000, height: 1000},
|
||||
[AspectRatio.Wide]: {width: 1000, height: 750},
|
||||
[AspectRatio.Tall]: {width: 750, height: 1000},
|
||||
}
|
||||
|
||||
export const snapPoints = ['0%']
|
||||
|
||||
export function Component({
|
||||
uri,
|
||||
dimensions,
|
||||
onSelect,
|
||||
}: {
|
||||
uri: string
|
||||
dimensions?: Dimensions
|
||||
onSelect: (img?: RNImage) => void
|
||||
}) {
|
||||
const {closeModal} = useModalControls()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const defaultAspectStyle = dimensions
|
||||
? AspectRatio.Custom
|
||||
: AspectRatio.Square
|
||||
const [as, setAs] = React.useState<AspectRatio>(defaultAspectStyle)
|
||||
const [scale, setScale] = React.useState<number>(1)
|
||||
const editorRef = React.useRef<ImageEditor>(null)
|
||||
const imageEditorWidth = dimensions ? dimensions.width : DIMS[as].width
|
||||
const imageEditorHeight = dimensions ? dimensions.height : DIMS[as].height
|
||||
|
||||
const doSetAs = (v: AspectRatio) => () => setAs(v)
|
||||
|
||||
const onPressCancel = () => {
|
||||
onSelect(undefined)
|
||||
closeModal()
|
||||
}
|
||||
const onPressDone = () => {
|
||||
const canvas = editorRef.current?.getImageScaledToCanvas()
|
||||
if (canvas) {
|
||||
const dataUri = canvas.toDataURL('image/jpeg')
|
||||
onSelect({
|
||||
path: dataUri,
|
||||
mime: 'image/jpeg',
|
||||
size: getDataUriSize(dataUri),
|
||||
width: imageEditorWidth,
|
||||
height: imageEditorHeight,
|
||||
})
|
||||
} else {
|
||||
onSelect(undefined)
|
||||
}
|
||||
closeModal()
|
||||
}
|
||||
|
||||
let cropperStyle
|
||||
if (as === AspectRatio.Square) {
|
||||
cropperStyle = styles.cropperSquare
|
||||
} else if (as === AspectRatio.Wide) {
|
||||
cropperStyle = styles.cropperWide
|
||||
} else if (as === AspectRatio.Tall) {
|
||||
cropperStyle = styles.cropperTall
|
||||
} else if (as === AspectRatio.Custom) {
|
||||
const cropperDimensions = calculateDimensions(
|
||||
550,
|
||||
imageEditorHeight,
|
||||
imageEditorWidth,
|
||||
)
|
||||
cropperStyle = {
|
||||
width: cropperDimensions.width,
|
||||
height: cropperDimensions.height,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={[styles.cropper, pal.borderDark, cropperStyle]}>
|
||||
<ImageEditor
|
||||
ref={editorRef}
|
||||
style={styles.imageEditor}
|
||||
image={uri}
|
||||
width={imageEditorWidth}
|
||||
height={imageEditorHeight}
|
||||
scale={scale}
|
||||
border={0}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.ctrls}>
|
||||
<Slider
|
||||
value={scale}
|
||||
onValueChange={(v: number | number[]) =>
|
||||
setScale(Array.isArray(v) ? v[0] : v)
|
||||
}
|
||||
minimumValue={1}
|
||||
maximumValue={3}
|
||||
containerStyle={styles.slider}
|
||||
/>
|
||||
{as === AspectRatio.Custom ? null : (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={doSetAs(AspectRatio.Wide)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Wide`)}
|
||||
accessibilityHint={_(msg`Sets image aspect ratio to wide`)}>
|
||||
<RectWideIcon
|
||||
size={24}
|
||||
style={as === AspectRatio.Wide ? s.blue3 : pal.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={doSetAs(AspectRatio.Tall)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Tall`)}
|
||||
accessibilityHint={_(msg`Sets image aspect ratio to tall`)}>
|
||||
<RectTallIcon
|
||||
size={24}
|
||||
style={as === AspectRatio.Tall ? s.blue3 : pal.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={doSetAs(AspectRatio.Square)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Square`)}
|
||||
accessibilityHint={_(msg`Sets image aspect ratio to square`)}>
|
||||
<SquareIcon
|
||||
size={24}
|
||||
style={as === AspectRatio.Square ? s.blue3 : pal.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.btns}>
|
||||
<TouchableOpacity
|
||||
onPress={onPressCancel}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Cancel image crop`)}
|
||||
accessibilityHint={_(msg`Exits image cropping process`)}>
|
||||
<Text type="xl" style={pal.link}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.flex1} />
|
||||
<TouchableOpacity
|
||||
onPress={onPressDone}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Save image crop`)}
|
||||
accessibilityHint={_(msg`Saves image crop settings`)}>
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[styles.btn]}>
|
||||
<Text type="xl-medium" style={s.white}>
|
||||
<Trans>Done</Trans>
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
cropper: {
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
cropperSquare: {
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
cropperWide: {
|
||||
width: 400,
|
||||
height: 300,
|
||||
},
|
||||
cropperTall: {
|
||||
width: 300,
|
||||
height: 400,
|
||||
},
|
||||
imageEditor: {
|
||||
maxWidth: '100%',
|
||||
},
|
||||
ctrls: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 10,
|
||||
},
|
||||
slider: {
|
||||
flex: 1,
|
||||
marginRight: 10,
|
||||
},
|
||||
btns: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 10,
|
||||
},
|
||||
btn: {
|
||||
borderRadius: 4,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
})
|
||||
@@ -1,13 +0,0 @@
|
||||
export const calculateDimensions = (
|
||||
maxWidth: number,
|
||||
originalHeight: number,
|
||||
originalWidth: number,
|
||||
) => {
|
||||
const aspectRatio = originalWidth / originalHeight
|
||||
const newHeight = maxWidth / aspectRatio
|
||||
const newWidth = maxWidth
|
||||
return {
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
}
|
||||
}
|
||||
@@ -310,7 +310,11 @@ let FeedItem = ({
|
||||
key={authors[0].href}
|
||||
style={[pal.text, s.bold]}
|
||||
href={authors[0].href}
|
||||
text={forceLTR(firstAuthorName)}
|
||||
text={
|
||||
<Text emoji style={[pal.text, s.bold]}>
|
||||
{forceLTR(firstAuthorName)}
|
||||
</Text>
|
||||
}
|
||||
disableMismatchWarning
|
||||
/>
|
||||
{authors.length > 1 ? (
|
||||
@@ -570,12 +574,13 @@ function ExpandedAuthorsList({
|
||||
numberOfLines={1}
|
||||
style={pal.text}
|
||||
lineHeight={1.2}>
|
||||
{sanitizeDisplayName(
|
||||
author.profile.displayName || author.profile.handle,
|
||||
)}
|
||||
|
||||
<Text emoji type="lg-bold" style={pal.text} lineHeight={1.2}>
|
||||
{sanitizeDisplayName(
|
||||
author.profile.displayName || author.profile.handle,
|
||||
)}
|
||||
</Text>{' '}
|
||||
<Text style={[pal.textLight]} lineHeight={1.2}>
|
||||
{sanitizeHandle(author.profile.handle)}
|
||||
{sanitizeHandle(author.profile.handle, '@')}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
@@ -592,7 +597,11 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
|
||||
{text?.length > 0 && (
|
||||
<Text emoji style={pal.textLight}>
|
||||
{text}
|
||||
</Text>
|
||||
)}
|
||||
<MediaPreview.Embed
|
||||
embed={post.embed}
|
||||
style={styles.additionalPostImages}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {PressableWithHover} from '../util/PressableWithHover'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {DraggableScrollView} from './DraggableScrollView'
|
||||
@@ -131,13 +131,14 @@ export function TabBar({
|
||||
<PressableWithHover
|
||||
testID={`${testID}-selector-${i}`}
|
||||
key={`${item}-${i}`}
|
||||
ref={node => (itemRefs.current[i] = node)}
|
||||
ref={node => (itemRefs.current[i] = node as any)}
|
||||
onLayout={e => onItemLayout(e, i)}
|
||||
style={styles.item}
|
||||
hoverStyle={pal.viewLight}
|
||||
onPress={() => onPressItem(i)}>
|
||||
<View style={[styles.itemInner, selected && indicatorStyle]}>
|
||||
<Text
|
||||
emoji
|
||||
type={isDesktop || isTablet ? 'xl-bold' : 'lg-bold'}
|
||||
testID={testID ? `${testID}-${item}` : undefined}
|
||||
style={[
|
||||
|
||||
@@ -12,24 +12,24 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {MAX_POST_LINES} from '#/lib/constants'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {countLines} from '#/lib/strings/helpers'
|
||||
import {niceDate} from '#/lib/strings/time'
|
||||
import {s} from '#/lib/styles'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {useOpenLink} from '#/state/preferences/in-app-browser'
|
||||
import {ThreadPost} from '#/state/queries/post-thread'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||
import {MAX_POST_LINES} from 'lib/constants'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {countLines} from 'lib/strings/helpers'
|
||||
import {niceDate} from 'lib/strings/time'
|
||||
import {s} from 'lib/styles'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useSession} from 'state/session'
|
||||
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
|
||||
import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {AppModerationCause} from '#/components/Pills'
|
||||
import {RichText} from '#/components/RichText'
|
||||
@@ -308,6 +308,7 @@ let PostThreadItemLoaded = ({
|
||||
style={[styles.meta, styles.metaExpandedLine1, {zIndex: 1}]}>
|
||||
<Link style={s.flex1} href={authorHref} title={authorTitle}>
|
||||
<Text
|
||||
emoji
|
||||
type="xl-bold"
|
||||
style={[pal.text, a.self_start]}
|
||||
numberOfLines={1}
|
||||
@@ -322,7 +323,11 @@ let PostThreadItemLoaded = ({
|
||||
</View>
|
||||
<View style={styles.meta}>
|
||||
<Link style={s.flex1} href={authorHref} title={authorTitle}>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
<Text
|
||||
emoji
|
||||
type="md"
|
||||
style={[pal.textLight]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeHandle(post.author.handle, '@')}
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
@@ -316,11 +316,19 @@ let FeedItemInner = ({
|
||||
style={pal.textLight}
|
||||
lineHeight={1.2}
|
||||
numberOfLines={1}
|
||||
text={sanitizeDisplayName(
|
||||
reason.by.displayName ||
|
||||
sanitizeHandle(reason.by.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
text={
|
||||
<Text
|
||||
emoji
|
||||
type="sm-bold"
|
||||
style={pal.textLight}
|
||||
lineHeight={1.2}>
|
||||
{sanitizeDisplayName(
|
||||
reason.by.displayName ||
|
||||
sanitizeHandle(reason.by.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
}
|
||||
href={makeProfileLink(reason.by)}
|
||||
onBeforePress={onOpenReposter}
|
||||
/>
|
||||
@@ -527,9 +535,11 @@ function ReplyToLabel({
|
||||
numberOfLines={1}
|
||||
href={makeProfileLink(profile)}
|
||||
text={
|
||||
profile.displayName
|
||||
? sanitizeDisplayName(profile.displayName)
|
||||
: sanitizeHandle(profile.handle)
|
||||
<Text emoji type="md" style={pal.textLight} lineHeight={1.2}>
|
||||
{profile.displayName
|
||||
? sanitizeDisplayName(profile.displayName)
|
||||
: sanitizeHandle(profile.handle)}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</ProfileHoverCard>
|
||||
|
||||
@@ -7,17 +7,17 @@ import {
|
||||
} from '@atproto/api'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {getModerationCauseKey, isJustAMute} from '#/lib/moderation'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {s} from '#/lib/styles'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {precacheProfile} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {getModerationCauseKey, isJustAMute} from 'lib/moderation'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {s} from 'lib/styles'
|
||||
import {precacheProfile} from 'state/queries/profile'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {
|
||||
KnownFollowers,
|
||||
@@ -103,6 +103,7 @@ export function ProfileCard({
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<Text
|
||||
emoji
|
||||
type="lg"
|
||||
style={[s.bold, pal.text, a.self_start]}
|
||||
numberOfLines={1}
|
||||
@@ -112,7 +113,7 @@ export function ProfileCard({
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
<Text emoji type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
{sanitizeHandle(profile.handle, '@')}
|
||||
</Text>
|
||||
<ProfileCardPills
|
||||
@@ -128,7 +129,7 @@ export function ProfileCard({
|
||||
{profile.description || knownFollowersVisible ? (
|
||||
<View style={styles.details}>
|
||||
{profile.description ? (
|
||||
<Text style={pal.text} numberOfLines={4}>
|
||||
<Text emoji style={pal.text} numberOfLines={4}>
|
||||
{profile.description as string}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -4,16 +4,16 @@ import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {forceLTR} from '#/lib/strings/bidi'
|
||||
import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {niceDate} from '#/lib/strings/time'
|
||||
import {TypographyVariant} from '#/lib/ThemeContext'
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {precacheProfile} from '#/state/queries/profile'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {forceLTR} from 'lib/strings/bidi'
|
||||
import {NON_BREAKING_SPACE} from 'lib/strings/constants'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {niceDate} from 'lib/strings/time'
|
||||
import {TypographyVariant} from 'lib/ThemeContext'
|
||||
import {isAndroid} from 'platform/detection'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
import {TextLinkOnWebOnly} from './Link'
|
||||
import {Text} from './text/Text'
|
||||
@@ -73,12 +73,20 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
||||
style={[pal.text]}
|
||||
lineHeight={1.2}
|
||||
disableMismatchWarning
|
||||
text={forceLTR(
|
||||
sanitizeDisplayName(
|
||||
displayName,
|
||||
opts.moderation?.ui('displayName'),
|
||||
),
|
||||
)}
|
||||
text={
|
||||
<Text
|
||||
type={opts.displayNameType || 'lg-bold'}
|
||||
emoji
|
||||
style={[pal.text]}
|
||||
lineHeight={1.2}>
|
||||
{forceLTR(
|
||||
sanitizeDisplayName(
|
||||
displayName,
|
||||
opts.moderation?.ui('displayName'),
|
||||
),
|
||||
)}
|
||||
</Text>
|
||||
}
|
||||
href={profileLink}
|
||||
onBeforePress={onBeforePressAuthor}
|
||||
/>
|
||||
@@ -86,7 +94,11 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
||||
type="md"
|
||||
disableMismatchWarning
|
||||
style={[pal.textLight, {flexShrink: 4}]}
|
||||
text={NON_BREAKING_SPACE + sanitizeHandle(handle, '@')}
|
||||
text={
|
||||
<Text emoji style={[pal.textLight, {flexShrink: 4}]}>
|
||||
{NON_BREAKING_SPACE + sanitizeHandle(handle, '@')}
|
||||
</Text>
|
||||
}
|
||||
href={profileLink}
|
||||
onBeforePress={onBeforePressAuthor}
|
||||
anchorNoUnderline
|
||||
|
||||
@@ -1,39 +1,35 @@
|
||||
import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
PropsWithChildren,
|
||||
forwardRef,
|
||||
Ref,
|
||||
} from 'react'
|
||||
import React, {forwardRef, PropsWithChildren} from 'react'
|
||||
import {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native'
|
||||
import {addStyle} from 'lib/styles'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {addStyle} from '#/lib/styles'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
|
||||
interface PressableWithHover extends PressableProps {
|
||||
hoverStyle: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
export const PressableWithHover = forwardRef(function PressableWithHoverImpl(
|
||||
{
|
||||
children,
|
||||
style,
|
||||
hoverStyle,
|
||||
...props
|
||||
}: PropsWithChildren<PressableWithHover>,
|
||||
ref: Ref<any>,
|
||||
export const PressableWithHover = forwardRef<
|
||||
View,
|
||||
PropsWithChildren<PressableWithHover>
|
||||
>(function PressableWithHoverImpl(
|
||||
{children, style, hoverStyle, ...props},
|
||||
ref,
|
||||
) {
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
|
||||
const onHoverIn = useCallback(() => setIsHovering(true), [setIsHovering])
|
||||
const onHoverOut = useCallback(() => setIsHovering(false), [setIsHovering])
|
||||
style =
|
||||
typeof style !== 'function' && isHovering
|
||||
? addStyle(style, hoverStyle)
|
||||
: style
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
style={style}
|
||||
style={
|
||||
typeof style !== 'function' && hovered
|
||||
? addStyle(style, hoverStyle)
|
||||
: style
|
||||
}
|
||||
onHoverIn={onHoverIn}
|
||||
onHoverOut={onHoverOut}
|
||||
ref={ref}>
|
||||
|
||||
@@ -8,17 +8,17 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {
|
||||
useCameraPermission,
|
||||
usePhotoLibraryPermission,
|
||||
} from 'lib/hooks/usePermissions'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {colors} from 'lib/styles'
|
||||
import {isAndroid, isNative, isWeb} from 'platform/detection'
|
||||
import {precacheProfile} from 'state/queries/profile'
|
||||
import {HighPriorityImage} from 'view/com/util/images/Image'
|
||||
} from '#/lib/hooks/usePermissions'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {colors} from '#/lib/styles'
|
||||
import {logger} from '#/logger'
|
||||
import {isAndroid, isNative, isWeb} from '#/platform/detection'
|
||||
import {precacheProfile} from '#/state/queries/profile'
|
||||
import {HighPriorityImage} from '#/view/com/util/images/Image'
|
||||
import {tokens, useTheme} from '#/alf'
|
||||
import {
|
||||
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
|
||||
@@ -321,6 +321,8 @@ let EditableUserAvatar = ({
|
||||
height: 1000,
|
||||
width: 1000,
|
||||
path: item.path,
|
||||
webAspectRatio: 1,
|
||||
webCircularCrop: true,
|
||||
})
|
||||
|
||||
onSelectNewAvatar(croppedImage)
|
||||
|
||||
@@ -6,16 +6,16 @@ import {ModerationUI} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {
|
||||
useCameraPermission,
|
||||
usePhotoLibraryPermission,
|
||||
} from 'lib/hooks/usePermissions'
|
||||
import {colors} from 'lib/styles'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {isAndroid, isNative} from 'platform/detection'
|
||||
import {EventStopper} from 'view/com/util/EventStopper'
|
||||
} from '#/lib/hooks/usePermissions'
|
||||
import {colors} from '#/lib/styles'
|
||||
import {useTheme} from '#/lib/ThemeContext'
|
||||
import {logger} from '#/logger'
|
||||
import {isAndroid, isNative} from '#/platform/detection'
|
||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||
import {tokens, useTheme as useAlfTheme} from '#/alf'
|
||||
import {
|
||||
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
|
||||
@@ -72,6 +72,7 @@ export function UserBanner({
|
||||
path: items[0].path,
|
||||
width: 3000,
|
||||
height: 1000,
|
||||
webAspectRatio: 3,
|
||||
}),
|
||||
)
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React from 'react'
|
||||
import {AppBskyActorGetProfile as GetProfile} from '@atproto/api'
|
||||
import {StyleProp, StyleSheet, TextStyle} from 'react-native'
|
||||
import {TextLinkOnWebOnly} from './Link'
|
||||
import {Text} from './text/Text'
|
||||
import {LoadingPlaceholder} from './LoadingPlaceholder'
|
||||
import {TypographyVariant} from 'lib/ThemeContext'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {AppBskyActorGetProfile as GetProfile} from '@atproto/api'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {TypographyVariant} from '#/lib/ThemeContext'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {TextLinkOnWebOnly} from './Link'
|
||||
import {LoadingPlaceholder} from './LoadingPlaceholder'
|
||||
import {Text} from './text/Text'
|
||||
|
||||
export function UserInfoText({
|
||||
type = 'md',
|
||||
@@ -50,11 +51,15 @@ export function UserInfoText({
|
||||
lineHeight={1.2}
|
||||
numberOfLines={1}
|
||||
href={makeProfileLink(profile)}
|
||||
text={`${prefix || ''}${sanitizeDisplayName(
|
||||
typeof profile[attr] === 'string' && profile[attr]
|
||||
? (profile[attr] as string)
|
||||
: sanitizeHandle(profile.handle),
|
||||
)}`}
|
||||
text={
|
||||
<Text emoji type={type} style={style} lineHeight={1.2}>
|
||||
{`${prefix || ''}${sanitizeDisplayName(
|
||||
typeof profile[attr] === 'string' && profile[attr]
|
||||
? (profile[attr] as string)
|
||||
: sanitizeHandle(profile.handle),
|
||||
)}`}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -4,11 +4,13 @@ import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {gradients} from '#/lib/styles'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useHapticsDisabled} from '#/state/preferences'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
|
||||
export interface FABProps
|
||||
@@ -17,9 +19,11 @@ export interface FABProps
|
||||
icon: JSX.Element
|
||||
}
|
||||
|
||||
export function FABInner({testID, icon, ...props}: FABProps) {
|
||||
export function FABInner({testID, icon, onPress, ...props}: FABProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const {isMobile, isTablet} = useWebMediaQueries()
|
||||
const playHaptic = useHaptics()
|
||||
const isHapticsDisabled = useHapticsDisabled()
|
||||
const fabMinimalShellTransform = useMinimalShellFabTransform()
|
||||
const {
|
||||
state: pressed,
|
||||
@@ -42,6 +46,15 @@ export function FABInner({testID, icon, ...props}: FABProps) {
|
||||
testID={testID}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onPress={e => {
|
||||
playHaptic('Light')
|
||||
setTimeout(
|
||||
() => {
|
||||
onPress?.(e)
|
||||
},
|
||||
isHapticsDisabled ? 0 : 75,
|
||||
)
|
||||
}}
|
||||
{...props}>
|
||||
<Animated.View
|
||||
style={[
|
||||
|
||||
@@ -5,21 +5,21 @@ import {AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {shareUrl} from 'lib/sharing'
|
||||
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
|
||||
import {
|
||||
getStarterPackOgCard,
|
||||
parseStarterPackUri,
|
||||
} from 'lib/strings/starter-pack'
|
||||
import {toNiceDomain} from 'lib/strings/url-helpers'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {useExternalEmbedsPrefs} from 'state/preferences'
|
||||
import {Link} from 'view/com/util/Link'
|
||||
import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed'
|
||||
import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
|
||||
import {GifEmbed} from 'view/com/util/post-embeds/GifEmbed'
|
||||
} from '#/lib/strings/starter-pack'
|
||||
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useExternalEmbedsPrefs} from '#/state/preferences'
|
||||
import {Link} from '#/view/com/util/Link'
|
||||
import {ExternalGifEmbed} from '#/view/com/util/post-embeds/ExternalGifEmbed'
|
||||
import {ExternalPlayer} from '#/view/com/util/post-embeds/ExternalPlayerEmbed'
|
||||
import {GifEmbed} from '#/view/com/util/post-embeds/GifEmbed'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Text} from '../text/Text'
|
||||
@@ -115,12 +115,13 @@ export const ExternalLinkEmbed = ({
|
||||
</Text>
|
||||
|
||||
{!embedPlayerParams?.isGif && !embedPlayerParams?.dimensions && (
|
||||
<Text type="lg-bold" numberOfLines={3} style={[pal.text]}>
|
||||
<Text emoji type="lg-bold" numberOfLines={3} style={[pal.text]}>
|
||||
{link.title || link.uri}
|
||||
</Text>
|
||||
)}
|
||||
{link.description ? (
|
||||
<Text
|
||||
emoji
|
||||
type="md"
|
||||
numberOfLines={link.thumb ? 2 : 4}
|
||||
style={[pal.text, a.mt_xs]}>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, {useEffect, useId, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||
import Hls from 'hls.js'
|
||||
import Hls, {Events, FragChangedData, Fragment} from 'hls.js'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Controls} from './web-controls/VideoControls'
|
||||
@@ -19,7 +20,7 @@ export function VideoEmbedInnerWeb({
|
||||
onScreen: boolean
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
|
||||
const figId = useId()
|
||||
@@ -30,64 +31,24 @@ export function VideoEmbedInnerWeb({
|
||||
throw error
|
||||
}
|
||||
|
||||
const hlsRef = useRef<Hls | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
if (!Hls.isSupported()) throw new HLSUnsupportedError()
|
||||
|
||||
const hls = new Hls({
|
||||
capLevelToPlayerSize: true,
|
||||
maxMaxBufferLength: 10, // only load 10s ahead
|
||||
// note: the amount buffered is affected by both maxBufferLength and maxBufferSize
|
||||
// it will buffer until it it's greater than *both* of those values
|
||||
// so we use maxMaxBufferLength to set the actual maximum amount of buffering instead
|
||||
})
|
||||
hlsRef.current = hls
|
||||
|
||||
hls.attachMedia(ref.current)
|
||||
hls.loadSource(embed.playlist)
|
||||
|
||||
// initial value, later on it's managed by Controls
|
||||
hls.autoLevelCapping = 0
|
||||
|
||||
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => {
|
||||
if (data.subtitleTracks.length > 0) {
|
||||
setHasSubtitleTrack(true)
|
||||
}
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
if (
|
||||
data.details === 'manifestLoadError' &&
|
||||
data.response?.code === 404
|
||||
) {
|
||||
setError(new VideoNotFoundError())
|
||||
} else {
|
||||
setError(data.error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
hlsRef.current = undefined
|
||||
hls.detachMedia()
|
||||
hls.destroy()
|
||||
}
|
||||
}, [embed.playlist])
|
||||
const hlsRef = useHLS({
|
||||
focused,
|
||||
playlist: embed.playlist,
|
||||
setHasSubtitleTrack,
|
||||
setError,
|
||||
videoRef,
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, a.rounded_md, a.overflow_hidden]}>
|
||||
<div ref={containerRef} style={{height: '100%', width: '100%'}}>
|
||||
<figure style={{margin: 0, position: 'absolute', inset: 0}}>
|
||||
<video
|
||||
ref={ref}
|
||||
ref={videoRef}
|
||||
poster={embed.thumbnail}
|
||||
style={{width: '100%', height: '100%', objectFit: 'contain'}}
|
||||
playsInline
|
||||
preload="none"
|
||||
loop
|
||||
muted={!focused}
|
||||
aria-labelledby={embed.alt ? figId : undefined}
|
||||
/>
|
||||
@@ -110,7 +71,7 @@ export function VideoEmbedInnerWeb({
|
||||
)}
|
||||
</figure>
|
||||
<Controls
|
||||
videoRef={ref}
|
||||
videoRef={videoRef}
|
||||
hlsRef={hlsRef}
|
||||
active={active}
|
||||
setActive={setActive}
|
||||
@@ -137,3 +98,120 @@ export class VideoNotFoundError extends Error {
|
||||
super('Video not found')
|
||||
}
|
||||
}
|
||||
|
||||
function useHLS({
|
||||
focused,
|
||||
playlist,
|
||||
setHasSubtitleTrack,
|
||||
setError,
|
||||
videoRef,
|
||||
}: {
|
||||
focused: boolean
|
||||
playlist: string
|
||||
setHasSubtitleTrack: (v: boolean) => void
|
||||
setError: (v: Error | null) => void
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
}) {
|
||||
const hlsRef = useRef<Hls | undefined>(undefined)
|
||||
const [lowQualityFragments, setLowQualityFragments] = useState<Fragment[]>([])
|
||||
|
||||
// purge low quality segments from buffer on next frag change
|
||||
const handleFragChange = useNonReactiveCallback(
|
||||
(_event: Events.FRAG_CHANGED, {frag}: FragChangedData) => {
|
||||
if (!hlsRef.current) return
|
||||
const hls = hlsRef.current
|
||||
|
||||
if (focused && hls.nextAutoLevel > 0) {
|
||||
// if the current quality level goes above 0, flush the low quality segments
|
||||
const flushed: Fragment[] = []
|
||||
|
||||
for (const lowQualFrag of lowQualityFragments) {
|
||||
// avoid if close to the current fragment
|
||||
if (Math.abs(frag.start - lowQualFrag.start) < 0.1) {
|
||||
continue
|
||||
}
|
||||
|
||||
hls.trigger(Hls.Events.BUFFER_FLUSHING, {
|
||||
startOffset: lowQualFrag.start,
|
||||
endOffset: lowQualFrag.end,
|
||||
type: 'video',
|
||||
})
|
||||
|
||||
flushed.push(lowQualFrag)
|
||||
}
|
||||
|
||||
setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f)))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return
|
||||
if (!Hls.isSupported()) throw new HLSUnsupportedError()
|
||||
|
||||
const hls = new Hls({
|
||||
maxMaxBufferLength: 10, // only load 10s ahead
|
||||
// note: the amount buffered is affected by both maxBufferLength and maxBufferSize
|
||||
// it will buffer until it it's greater than *both* of those values
|
||||
// so we use maxMaxBufferLength to set the actual maximum amount of buffering instead
|
||||
})
|
||||
hlsRef.current = hls
|
||||
|
||||
hls.attachMedia(videoRef.current)
|
||||
hls.loadSource(playlist)
|
||||
|
||||
// initial value, later on it's managed by Controls
|
||||
hls.autoLevelCapping = 0
|
||||
|
||||
// manually loop, so if we've flushed the first buffer it doesn't get confused
|
||||
const abortController = new AbortController()
|
||||
const {signal} = abortController
|
||||
const videoNode = videoRef.current
|
||||
videoNode.addEventListener(
|
||||
'ended',
|
||||
function () {
|
||||
videoNode.currentTime = 0
|
||||
videoNode.play()
|
||||
},
|
||||
{signal},
|
||||
)
|
||||
|
||||
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => {
|
||||
if (data.subtitleTracks.length > 0) {
|
||||
setHasSubtitleTrack(true)
|
||||
}
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => {
|
||||
if (frag.level === 0) {
|
||||
setLowQualityFragments(prev => [...prev, frag])
|
||||
}
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
if (
|
||||
data.details === 'manifestLoadError' &&
|
||||
data.response?.code === 404
|
||||
) {
|
||||
setError(new VideoNotFoundError())
|
||||
} else {
|
||||
setError(data.error)
|
||||
}
|
||||
} else {
|
||||
console.error(data.error)
|
||||
}
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.FRAG_CHANGED, handleFragChange)
|
||||
|
||||
return () => {
|
||||
hlsRef.current = undefined
|
||||
hls.detachMedia()
|
||||
hls.destroy()
|
||||
abortController.abort()
|
||||
}
|
||||
}, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange])
|
||||
|
||||
return hlsRef
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
import {SvgProps} from 'react-native-svg'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {PressableWithHover} from '../../../PressableWithHover'
|
||||
|
||||
export function ControlButton({
|
||||
active,
|
||||
@@ -21,19 +21,21 @@ export function ControlButton({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Button
|
||||
label={active ? activeLabel : inactiveLabel}
|
||||
<PressableWithHover
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={active ? activeLabel : inactiveLabel}
|
||||
onPress={onPress}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
size="large"
|
||||
style={a.p_2xs}
|
||||
hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.1)'}}>
|
||||
style={[
|
||||
a.p_xs,
|
||||
a.rounded_full,
|
||||
web({transition: 'background-color 0.1s'}),
|
||||
]}
|
||||
hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.2)'}}>
|
||||
{active ? (
|
||||
<ActiveIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<InactiveIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
</PressableWithHover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -358,9 +358,8 @@ export function Controls({
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.px_xs,
|
||||
a.pt_2xs,
|
||||
a.pb_md,
|
||||
a.gap_md,
|
||||
a.pb_sm,
|
||||
a.gap_sm,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
]}>
|
||||
@@ -373,7 +372,11 @@ export function Controls({
|
||||
onPress={onPressPlayPause}
|
||||
/>
|
||||
<View style={a.flex_1} />
|
||||
<Text style={{color: t.palette.white, fontVariant: ['tabular-nums']}}>
|
||||
<Text
|
||||
style={[
|
||||
a.px_xs,
|
||||
{color: t.palette.white, fontVariant: ['tabular-nums']},
|
||||
]}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</Text>
|
||||
{hasSubtitleTrack && (
|
||||
|
||||
@@ -2,27 +2,40 @@ import React from 'react'
|
||||
import {StyleSheet, Text as RNText, TextProps} from 'react-native'
|
||||
import {UITextView} from 'react-native-uitextview'
|
||||
|
||||
import {lh, s} from 'lib/styles'
|
||||
import {TypographyVariant, useTheme} from 'lib/ThemeContext'
|
||||
import {isIOS, isWeb} from 'platform/detection'
|
||||
import {lh, s} from '#/lib/styles'
|
||||
import {TypographyVariant, useTheme} from '#/lib/ThemeContext'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {applyFonts, useAlf} from '#/alf'
|
||||
import {
|
||||
childHasEmoji,
|
||||
childIsString,
|
||||
renderChildrenWithEmoji,
|
||||
StringChild,
|
||||
} from '#/components/Typography'
|
||||
import {IS_DEV} from '#/env'
|
||||
|
||||
export type CustomTextProps = TextProps & {
|
||||
export type CustomTextProps = Omit<TextProps, 'children'> & {
|
||||
type?: TypographyVariant
|
||||
lineHeight?: number
|
||||
title?: string
|
||||
dataSet?: Record<string, string | number>
|
||||
selectable?: boolean
|
||||
}
|
||||
|
||||
const fontFamilyStyle = {
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Liberation Sans", Helvetica, Arial, sans-serif',
|
||||
}
|
||||
} & (
|
||||
| {
|
||||
emoji: true
|
||||
children: StringChild
|
||||
}
|
||||
| {
|
||||
emoji?: false
|
||||
children: TextProps['children']
|
||||
}
|
||||
)
|
||||
|
||||
export function Text({
|
||||
type = 'md',
|
||||
children,
|
||||
emoji,
|
||||
lineHeight,
|
||||
style,
|
||||
title,
|
||||
@@ -35,6 +48,18 @@ export function Text({
|
||||
const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined
|
||||
const {fonts} = useAlf()
|
||||
|
||||
if (IS_DEV) {
|
||||
if (!emoji && childHasEmoji(children)) {
|
||||
logger.warn(
|
||||
`Text: emoji detected but emoji not enabled: "${children}"\n\nPlease add <Text emoji />'`,
|
||||
)
|
||||
}
|
||||
|
||||
if (emoji && !childIsString(children)) {
|
||||
logger.error('Text: when <Text emoji />, children can only be strings.')
|
||||
}
|
||||
}
|
||||
|
||||
if (selectable && isIOS) {
|
||||
const flattened = StyleSheet.flatten([
|
||||
s.black,
|
||||
@@ -58,7 +83,7 @@ export function Text({
|
||||
selectable={selectable}
|
||||
uiTextView
|
||||
{...props}>
|
||||
{children}
|
||||
{isIOS && emoji ? renderChildrenWithEmoji(children) : children}
|
||||
</UITextView>
|
||||
)
|
||||
}
|
||||
@@ -66,7 +91,6 @@ export function Text({
|
||||
const flattened = StyleSheet.flatten([
|
||||
s.black,
|
||||
typography,
|
||||
isWeb && fontFamilyStyle,
|
||||
lineHeightStyle,
|
||||
style,
|
||||
])
|
||||
@@ -87,7 +111,7 @@ export function Text({
|
||||
dataSet={Object.assign({tooltip: title}, dataSet || {})}
|
||||
selectable={selectable}
|
||||
{...props}>
|
||||
{children}
|
||||
{isIOS && emoji ? renderChildrenWithEmoji(children) : children}
|
||||
</RNText>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import React from 'react'
|
||||
import {CustomTextProps, Text} from './Text'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {addStyle} from 'lib/styles'
|
||||
|
||||
export type ThemedTextProps = CustomTextProps & {
|
||||
fg?: 'default' | 'light' | 'error' | 'inverted' | 'inverted-light'
|
||||
bg?: 'default' | 'light' | 'error' | 'inverted' | 'inverted-light'
|
||||
border?: 'default' | 'dark' | 'error' | 'inverted' | 'inverted-dark'
|
||||
lineHeight?: number
|
||||
}
|
||||
|
||||
export function ThemedText({
|
||||
fg,
|
||||
bg,
|
||||
border,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<ThemedTextProps>) {
|
||||
const pal = usePalette('default')
|
||||
const palInverted = usePalette('inverted')
|
||||
const palError = usePalette('error')
|
||||
switch (fg) {
|
||||
case 'default':
|
||||
style = addStyle(style, pal.text)
|
||||
break
|
||||
case 'light':
|
||||
style = addStyle(style, pal.textLight)
|
||||
break
|
||||
case 'error':
|
||||
style = addStyle(style, {color: palError.colors.background})
|
||||
break
|
||||
case 'inverted':
|
||||
style = addStyle(style, palInverted.text)
|
||||
break
|
||||
case 'inverted-light':
|
||||
style = addStyle(style, palInverted.textLight)
|
||||
break
|
||||
}
|
||||
switch (bg) {
|
||||
case 'default':
|
||||
style = addStyle(style, pal.view)
|
||||
break
|
||||
case 'light':
|
||||
style = addStyle(style, pal.viewLight)
|
||||
break
|
||||
case 'error':
|
||||
style = addStyle(style, palError.view)
|
||||
break
|
||||
case 'inverted':
|
||||
style = addStyle(style, palInverted.view)
|
||||
break
|
||||
case 'inverted-light':
|
||||
style = addStyle(style, palInverted.viewLight)
|
||||
break
|
||||
}
|
||||
switch (border) {
|
||||
case 'default':
|
||||
style = addStyle(style, pal.border)
|
||||
break
|
||||
case 'dark':
|
||||
style = addStyle(style, pal.borderDark)
|
||||
break
|
||||
case 'error':
|
||||
style = addStyle(style, palError.border)
|
||||
break
|
||||
case 'inverted':
|
||||
style = addStyle(style, palInverted.border)
|
||||
break
|
||||
case 'inverted-dark':
|
||||
style = addStyle(style, palInverted.borderDark)
|
||||
break
|
||||
}
|
||||
return (
|
||||
<Text style={style} {...props}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -16,9 +16,18 @@ import {
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {useSetTitle} from '#/lib/hooks/useSetTitle'
|
||||
import {ComposeIcon2} from '#/lib/icons'
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||
import {combinedDisplayName} from '#/lib/strings/display-names'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||
import {colors, s} from '#/lib/styles'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {listenSoftReset} from '#/state/events'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useActorStarterPacksQuery} from '#/state/queries/actor-starter-packs'
|
||||
import {useLabelerInfoQuery} from '#/state/queries/labeler'
|
||||
import {resetProfilePostsQueries} from '#/state/queries/post-feed'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
@@ -26,29 +35,21 @@ import {useResolveDidQuery} from '#/state/queries/resolve-uri'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useSetTitle} from 'lib/hooks/useSetTitle'
|
||||
import {ComposeIcon2} from 'lib/icons'
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
||||
import {combinedDisplayName} from 'lib/strings/display-names'
|
||||
import {isInvalidHandle} from 'lib/strings/handles'
|
||||
import {colors, s} from 'lib/styles'
|
||||
import {listenSoftReset} from 'state/events'
|
||||
import {useActorStarterPacksQuery} from 'state/queries/actor-starter-packs'
|
||||
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
|
||||
import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens'
|
||||
import {ProfileLists} from '#/view/com/lists/ProfileLists'
|
||||
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
|
||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||
import {FAB} from '#/view/com/util/fab/FAB'
|
||||
import {ListRef} from '#/view/com/util/List'
|
||||
import {CenteredView} from '#/view/com/util/Views'
|
||||
import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header'
|
||||
import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
|
||||
import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels'
|
||||
import {web} from '#/alf'
|
||||
import {ScreenHider} from '#/components/moderation/ScreenHider'
|
||||
import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks'
|
||||
import {navigate} from '#/Navigation'
|
||||
import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder'
|
||||
import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens'
|
||||
import {ProfileLists} from '../com/lists/ProfileLists'
|
||||
import {ErrorScreen} from '../com/util/error/ErrorScreen'
|
||||
import {FAB} from '../com/util/fab/FAB'
|
||||
import {ListRef} from '../com/util/List'
|
||||
import {CenteredView} from '../com/util/Views'
|
||||
|
||||
interface SectionRef {
|
||||
scrollToTop: () => void
|
||||
@@ -107,7 +108,7 @@ export function ProfileScreen({route}: Props) {
|
||||
// Most pushes will happen here, since we will have only placeholder data
|
||||
if (isLoadingDid || isLoadingProfile || starterPacksQuery.isLoading) {
|
||||
return (
|
||||
<CenteredView>
|
||||
<CenteredView sideBorders style={web({height: '100vh'})}>
|
||||
<ProfileHeaderLoading />
|
||||
</CenteredView>
|
||||
)
|
||||
|
||||
@@ -959,6 +959,7 @@ function SearchHistory({
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
<Text
|
||||
emoji
|
||||
style={[pal.text, styles.profileName]}
|
||||
numberOfLines={1}>
|
||||
{profile.displayName || profile.handle}
|
||||
|
||||
@@ -32,6 +32,27 @@ export function Forms() {
|
||||
label="Text field"
|
||||
/>
|
||||
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
width: '50%',
|
||||
},
|
||||
]}>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Globe} />
|
||||
<TextField.Input
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
label="Text field"
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
<Button label="Submit" size="large" variant="solid" color="primary">
|
||||
<ButtonText>Submit</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Globe} />
|
||||
<TextField.Input
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useSetThemePrefs} from '#/state/shell'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {CenteredView} from '#/view/com/util/Views'
|
||||
import {ListContained} from 'view/screens/Storybook/ListContained'
|
||||
import {ListContained} from '#/view/screens/Storybook/ListContained'
|
||||
import {atoms as a, ThemeProvider, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Breakpoints} from './Breakpoints'
|
||||
@@ -80,9 +80,8 @@ function StorybookInner() {
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Buttons />
|
||||
<Forms />
|
||||
|
||||
<Dialogs />
|
||||
<ThemeProvider theme="light">
|
||||
<Theming />
|
||||
</ThemeProvider>
|
||||
@@ -93,16 +92,17 @@ function StorybookInner() {
|
||||
<Theming />
|
||||
</ThemeProvider>
|
||||
|
||||
<Buttons />
|
||||
<Typography />
|
||||
<Spacing />
|
||||
<Shadows />
|
||||
<Buttons />
|
||||
<Icons />
|
||||
<Links />
|
||||
<Forms />
|
||||
<Dialogs />
|
||||
<Menus />
|
||||
<Breakpoints />
|
||||
<Dialogs />
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
|
||||
@@ -2,16 +2,13 @@ import React, {useLayoutEffect} from 'react'
|
||||
import {Modal, View} from 'react-native'
|
||||
import {StatusBar} from 'expo-status-bar'
|
||||
import * as SystemUI from 'expo-system-ui'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
|
||||
import {useComposerState} from '#/state/shell/composer'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {getBackgroundColor, useThemeName} from '#/alf/util/useColorModeTheme'
|
||||
import {ComposePost, useComposerCancelRef} from '../com/composer/Composer'
|
||||
|
||||
export const Composer = observer(function ComposerImpl({}: {
|
||||
winHeight: number
|
||||
}) {
|
||||
export function Composer({}: {winHeight: number}) {
|
||||
const t = useTheme()
|
||||
const state = useComposerState()
|
||||
const ref = useComposerCancelRef()
|
||||
@@ -42,7 +39,7 @@ export const Composer = observer(function ComposerImpl({}: {
|
||||
</View>
|
||||
</Modal>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function Providers({
|
||||
children,
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import React, {useEffect} from 'react'
|
||||
import {Animated, Easing, StyleSheet, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useComposerState} from 'state/shell/composer'
|
||||
import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useComposerState} from '#/state/shell/composer'
|
||||
import {ComposePost} from '../com/composer/Composer'
|
||||
|
||||
export const Composer = observer(function ComposerImpl({
|
||||
winHeight,
|
||||
}: {
|
||||
winHeight: number
|
||||
}) {
|
||||
export function Composer({winHeight}: {winHeight: number}) {
|
||||
const state = useComposerState()
|
||||
const pal = usePalette('default')
|
||||
const initInterp = useAnimatedValue(0)
|
||||
@@ -62,7 +57,7 @@ export const Composer = observer(function ComposerImpl({
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
|
||||
@@ -16,19 +16,19 @@ import {useLingui} from '@lingui/react'
|
||||
import {StackActions, useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {s} from '#/lib/styles'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {precacheProfile} from 'state/queries/profile'
|
||||
import {precacheProfile} from '#/state/queries/profile'
|
||||
import {SearchInput} from '#/view/com/util/forms/SearchInput'
|
||||
import {Link} from '#/view/com/util/Link'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {SearchInput} from 'view/com/util/forms/SearchInput'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
let SearchLinkCard = ({
|
||||
@@ -126,6 +126,7 @@ let SearchProfileCard = ({
|
||||
/>
|
||||
<View style={{flex: 1}}>
|
||||
<Text
|
||||
emoji
|
||||
type="lg"
|
||||
style={[s.bold, pal.text, a.self_start]}
|
||||
numberOfLines={1}
|
||||
|
||||
Reference in New Issue
Block a user