[🐴] Record message (#4230)

* send record via link in text

* re-trim text after removing link

* record message

* only show copy text if message + add translate

* reduce padding

* adjust padding

* Tweak spacing

* Stop clickthrough for hidden content

* Update bg to show labels

---------

Co-authored-by: Eric Bailey <git@esb.lol>
zio/stable
Samuel Newman 2024-05-31 18:43:04 +03:00 committed by GitHub
parent 8eb3cebb36
commit 22e1eb18c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 225 additions and 58 deletions

View File

@ -6,7 +6,11 @@ import {
TextStyle, TextStyle,
View, View,
} from 'react-native' } from 'react-native'
import {ChatBskyConvoDefs, RichText as RichTextAPI} from '@atproto/api' import {
AppBskyEmbedRecord,
ChatBskyConvoDefs,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@ -18,6 +22,7 @@ import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {RichText} from '../RichText' import {RichText} from '../RichText'
import {MessageItemEmbed} from './MessageItemEmbed'
let MessageItem = ({ let MessageItem = ({
item, item,
@ -77,37 +82,44 @@ let MessageItem = ({
return ( return (
<View style={[isFromSelf ? a.mr_md : a.ml_md]}> <View style={[isFromSelf ? a.mr_md : a.ml_md]}>
<ActionsWrapper isFromSelf={isFromSelf} message={message}> <ActionsWrapper isFromSelf={isFromSelf} message={message}>
<View {AppBskyEmbedRecord.isMain(message.embed) && (
style={[ <MessageItemEmbed embed={message.embed} />
a.py_sm, )}
a.my_2xs, {rt.text.length > 0 && (
a.rounded_md, <View
{
paddingLeft: 14,
paddingRight: 14,
backgroundColor: isFromSelf
? isPending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50,
borderRadius: 17,
},
isFromSelf
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
]}>
<RichText
value={rt}
style={[ style={[
a.text_md, a.py_sm,
a.leading_snug, a.my_2xs,
isFromSelf && {color: t.palette.white}, a.rounded_md,
isPending && t.name !== 'light' && {color: t.palette.primary_300}, {
]} paddingLeft: 14,
interactiveStyle={a.underline} paddingRight: 14,
enableTags backgroundColor: isFromSelf
/> ? isPending
</View> ? pendingColor
: t.palette.primary_500
: t.palette.contrast_50,
borderRadius: 17,
},
isFromSelf ? a.self_end : a.self_start,
isFromSelf
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
]}>
<RichText
value={rt}
style={[
a.text_md,
a.leading_snug,
isFromSelf && {color: t.palette.white},
isPending &&
t.name !== 'light' && {color: t.palette.primary_300},
]}
interactiveStyle={a.underline}
enableTags
/>
</View>
)}
</ActionsWrapper> </ActionsWrapper>
{isLastInGroup && ( {isLastInGroup && (

View File

@ -0,0 +1,109 @@
import React, {useMemo} from 'react'
import {View} from 'react-native'
import {
AppBskyEmbedRecord,
AppBskyFeedPost,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {makeProfileLink} from '#/lib/routes/links'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {usePostQuery} from '#/state/queries/post'
import {PostEmbeds} from '#/view/com/util/post-embeds'
import {PostMeta} from '#/view/com/util/PostMeta'
import {atoms as a, useTheme} from '#/alf'
import {Link} from '#/components/Link'
import {ContentHider} from '#/components/moderation/ContentHider'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {RichText} from '#/components/RichText'
let MessageItemEmbed = ({
embed,
}: {
embed: AppBskyEmbedRecord.Main
}): React.ReactNode => {
const t = useTheme()
const {data: post} = usePostQuery(embed.record.uri)
const moderationOpts = useModerationOpts()
const moderation = useMemo(
() =>
moderationOpts && post ? moderatePost(post, moderationOpts) : undefined,
[moderationOpts, post],
)
const {rt, record} = useMemo(() => {
if (
post &&
AppBskyFeedPost.isRecord(post.record) &&
AppBskyFeedPost.validateRecord(post.record).success
) {
return {
rt: new RichTextAPI({
text: post.record.text,
facets: post.record.facets,
}),
record: post.record,
}
}
return {rt: undefined, record: undefined}
}, [post])
if (!post || !moderation || !rt || !record) {
return null
}
const itemUrip = new AtUri(post.uri)
const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
return (
<Link to={itemHref}>
<View
style={[
a.w_full,
t.atoms.bg,
t.atoms.border_contrast_low,
a.rounded_md,
a.border,
a.p_md,
a.my_xs,
]}>
<PostMeta
showAvatar
author={post.author}
moderation={moderation}
authorHasWarning={!!post.author.labels?.length}
timestamp={post.indexedAt}
postHref={itemHref}
/>
<ContentHider modui={moderation.ui('contentView')}>
<PostAlerts modui={moderation.ui('contentView')} style={a.py_xs} />
{rt.text && (
<View style={a.mt_xs}>
<RichText
enableTags
testID="postText"
value={rt}
style={[a.text_sm, t.atoms.text_contrast_high]}
authorHandle={post.author.handle}
/>
</View>
)}
{post.embed && (
<PostEmbeds
embed={post.embed}
moderation={moderation}
style={a.mt_xs}
quoteTextStyle={[a.text_sm, t.atoms.text_contrast_high]}
/>
)}
</ContentHider>
</View>
</Link>
)
}
MessageItemEmbed = React.memo(MessageItemEmbed)
export {MessageItemEmbed}

View File

@ -6,12 +6,16 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {richTextToString} from '#/lib/strings/rich-text-helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {useLanguagePrefs} from '#/state/preferences'
import {useOpenLink} from '#/state/preferences/in-app-browser'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {useConvoActive} from 'state/messages/convo' import {useConvoActive} from 'state/messages/convo'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {ReportDialog} from '#/components/dms/ReportDialog' import {ReportDialog} from '#/components/dms/ReportDialog'
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
@ -35,10 +39,12 @@ export let MessageMenu = ({
const convo = useConvoActive() const convo = useConvoActive()
const deleteControl = usePromptControl() const deleteControl = usePromptControl()
const reportControl = usePromptControl() const reportControl = usePromptControl()
const langPrefs = useLanguagePrefs()
const openLink = useOpenLink()
const isFromSelf = message.sender?.did === currentAccount?.did const isFromSelf = message.sender?.did === currentAccount?.did
const onCopyPostText = React.useCallback(() => { const onCopyMessage = React.useCallback(() => {
const str = richTextToString( const str = richTextToString(
new RichText({ new RichText({
text: message.text, text: message.text,
@ -51,6 +57,14 @@ export let MessageMenu = ({
Toast.show(_(msg`Copied to clipboard`)) Toast.show(_(msg`Copied to clipboard`))
}, [_, message.text, message.facets]) }, [_, message.text, message.facets])
const onPressTranslateMessage = React.useCallback(() => {
const translatorUrl = getTranslatorLink(
message.text,
langPrefs.primaryLanguage,
)
openLink(translatorUrl)
}, [langPrefs.primaryLanguage, message.text, openLink])
const onDelete = React.useCallback(() => { const onDelete = React.useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo convo
@ -81,16 +95,27 @@ export let MessageMenu = ({
)} )}
<Menu.Outer> <Menu.Outer>
<Menu.Group> {message.text.length > 0 && (
<Menu.Item <>
testID="messageDropdownCopyBtn" <Menu.Group>
label={_(msg`Copy message text`)} <Menu.Item
onPress={onCopyPostText}> testID="messageDropdownTranslateBtn"
<Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText> label={_(msg`Translate`)}
<Menu.ItemIcon icon={ClipboardIcon} position="right" /> onPress={onPressTranslateMessage}>
</Menu.Item> <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
</Menu.Group> <Menu.ItemIcon icon={Translate} position="right" />
<Menu.Divider /> </Menu.Item>
<Menu.Item
testID="messageDropdownCopyBtn"
label={_(msg`Copy message text`)}
onPress={onCopyMessage}>
<Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
</>
)}
<Menu.Group> <Menu.Group>
<Menu.Item <Menu.Item
testID="messageDropdownDeleteBtn" testID="messageDropdownDeleteBtn"

View File

@ -1,20 +1,19 @@
import React from 'react' import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {ModerationUI} from '@atproto/api' import {ModerationUI} from '@atproto/api'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {isJustAMute} from '#/lib/moderation' import {isJustAMute} from '#/lib/moderation'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {atoms as a, useTheme, useBreakpoints, web} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {Text} from '#/components/Typography'
import { import {
ModerationDetailsDialog, ModerationDetailsDialog,
useModerationDetailsDialogControl, useModerationDetailsDialogControl,
} from '#/components/moderation/ModerationDetailsDialog' } from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
export function ContentHider({ export function ContentHider({
testID, testID,
@ -52,7 +51,9 @@ export function ContentHider({
<ModerationDetailsDialog control={control} modcause={blur} /> <ModerationDetailsDialog control={control} modcause={blur} />
<Button <Button
onPress={() => { onPress={e => {
e.preventDefault()
e.stopPropagation()
if (!modui.noOverride) { if (!modui.noOverride) {
setOverride(v => !v) setOverride(v => !v)
} else { } else {
@ -121,7 +122,9 @@ export function ContentHider({
{desc.source && blur.type === 'label' && !override && ( {desc.source && blur.type === 'label' && !override && (
<Button <Button
onPress={() => { onPress={e => {
e.preventDefault()
e.stopPropagation()
control.open() control.open()
}} }}
label={_( label={_(

View File

@ -2,6 +2,7 @@ import React from 'react'
import { import {
StyleProp, StyleProp,
StyleSheet, StyleSheet,
TextStyle,
TouchableOpacity, TouchableOpacity,
View, View,
ViewStyle, ViewStyle,
@ -31,7 +32,7 @@ import {InfoCircleIcon} from 'lib/icons'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {precacheProfile} from 'state/queries/profile' import {precacheProfile} from 'state/queries/profile'
import {ComposerOptsQuote} from 'state/shell/composer' import {ComposerOptsQuote} from 'state/shell/composer'
import {atoms as a} from '#/alf' import {atoms as a, flatten} from '#/alf'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
import {ContentHider} from '../../../../components/moderation/ContentHider' import {ContentHider} from '../../../../components/moderation/ContentHider'
import {PostAlerts} from '../../../../components/moderation/PostAlerts' import {PostAlerts} from '../../../../components/moderation/PostAlerts'
@ -45,10 +46,12 @@ export function MaybeQuoteEmbed({
embed, embed,
onOpen, onOpen,
style, style,
textStyle,
}: { }: {
embed: AppBskyEmbedRecord.View embed: AppBskyEmbedRecord.View
onOpen?: () => void onOpen?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
textStyle?: StyleProp<TextStyle>
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
if ( if (
@ -62,6 +65,7 @@ export function MaybeQuoteEmbed({
postRecord={embed.record.value} postRecord={embed.record.value}
onOpen={onOpen} onOpen={onOpen}
style={style} style={style}
textStyle={textStyle}
/> />
) )
} else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) { } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
@ -91,11 +95,13 @@ function QuoteEmbedModerated({
postRecord, postRecord,
onOpen, onOpen,
style, style,
textStyle,
}: { }: {
viewRecord: AppBskyEmbedRecord.ViewRecord viewRecord: AppBskyEmbedRecord.ViewRecord
postRecord: AppBskyFeedPost.Record postRecord: AppBskyFeedPost.Record
onOpen?: () => void onOpen?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
textStyle?: StyleProp<TextStyle>
}) { }) {
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const moderation = React.useMemo(() => { const moderation = React.useMemo(() => {
@ -120,6 +126,7 @@ function QuoteEmbedModerated({
moderation={moderation} moderation={moderation}
onOpen={onOpen} onOpen={onOpen}
style={style} style={style}
textStyle={textStyle}
/> />
) )
} }
@ -129,11 +136,13 @@ export function QuoteEmbed({
moderation, moderation,
onOpen, onOpen,
style, style,
textStyle,
}: { }: {
quote: ComposerOptsQuote quote: ComposerOptsQuote
moderation?: ModerationDecision moderation?: ModerationDecision
onOpen?: () => void onOpen?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
textStyle?: StyleProp<TextStyle>
}) { }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const pal = usePalette('default') const pal = usePalette('default')
@ -192,7 +201,7 @@ export function QuoteEmbed({
{richText ? ( {richText ? (
<RichText <RichText
value={richText} value={richText}
style={[a.text_md]} style={[a.text_md, flatten(textStyle)]}
numberOfLines={20} numberOfLines={20}
disableLinks disableLinks
/> />
@ -250,11 +259,6 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
borderWidth: hairlineWidth, borderWidth: hairlineWidth,
}, },
quotePost: {
flex: 1,
paddingLeft: 13,
paddingRight: 8,
},
errorContainer: { errorContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

View File

@ -4,6 +4,7 @@ import {
StyleProp, StyleProp,
StyleSheet, StyleSheet,
Text, Text,
TextStyle,
View, View,
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
@ -41,11 +42,13 @@ export function PostEmbeds({
moderation, moderation,
onOpen, onOpen,
style, style,
quoteTextStyle,
}: { }: {
embed?: Embed embed?: Embed
moderation?: ModerationDecision moderation?: ModerationDecision
onOpen?: () => void onOpen?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
quoteTextStyle?: StyleProp<TextStyle>
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {openLightbox} = useLightboxControls() const {openLightbox} = useLightboxControls()
@ -60,7 +63,11 @@ export function PostEmbeds({
moderation={moderation} moderation={moderation}
onOpen={onOpen} onOpen={onOpen}
/> />
<MaybeQuoteEmbed embed={embed.record} onOpen={onOpen} /> <MaybeQuoteEmbed
embed={embed.record}
onOpen={onOpen}
textStyle={quoteTextStyle}
/>
</View> </View>
) )
} }
@ -87,7 +94,14 @@ export function PostEmbeds({
// quote post // quote post
// = // =
return <MaybeQuoteEmbed embed={embed} style={style} onOpen={onOpen} /> return (
<MaybeQuoteEmbed
embed={embed}
style={style}
textStyle={quoteTextStyle}
onOpen={onOpen}
/>
)
} }
// image embed // image embed