a51fc8e434
* Bump SDK * Use consistent type in profile query * Omit from constraint for profile shadow * Replace isRecord with isValidRecord in QuoteEmbed * Omit type from constraint for old ProfileCard * Omit type from constraint in profile queries where appropriate * Use correct type for update profile mutation * Conslidate and fix check for isValidRecord in Post.tsx * Replace isRecord with isValidRecord in PostThreadItem * Remove redundant cast in PostThreadFollowBtn * Ignore errors in DebugMod screen * Use matching type in ProfileFollows screen * Use matching type in ProfileFollowers screen * Migrate to isValidRecord in PostFeedItem * Use matching type if PostRepostedBy * Omit type from constraint in avatar props * Use matching types in NotificationFeedItem * Todo * Use isValidRecord in NotfyFeedItem * Improve MediaPreview types * Migrate another isValidRecord in NotificationFeedItem * Migrate to isValidView in queries/util * Migrate to isValidRecord in threadgate/util * Fix types in threadgates * Fix up types in starter-packs queries * Todo * Specify exact types in search-posts * Use internal type util to align types * Ditto last * Migrate postgate/index * Specify exact types in post-thread * Use correct type in post-quotes * FIX potential bug in post-thread * Use correct type in post-feed * Add correct type guards to notifications/feed * Migrate a guard in notifications/util * Migrate guard in Wizard/State * [@next] Profile handling, migrate `ProfileCard` (#7347) * Introduce new utils for profiles, migrate old ProfileCard * Rename, reorg * Add parseEmbed utils * Expand AnyProfileView to include chat profile view, update post shadow to reflect this * Cast for perf reasons * Tighten up types now that we have AnyProfileView * Add fastIsType util * Use `assertDid` Co-authored-by: Matthieu Sieben <matthieusieben@users.noreply.github.com> * Use util types * Comment * Use fastIsType where no validation was happening before * suggestions (#7382) * suggestions * Revert unneeded changes --------- Co-authored-by: Eric Bailey <git@esb.lol> * Use new util * Rename to dangerousIsType * Convert object shape * Use dangerous util * Use dangerous util * Use dangerous util, we can trust post records * Use dangerous util * Use AnyProfileVIew * Convert object shape * Clean up handling * Patch moderateProfile to accept known profile views, to discuss * Add AnyStarterPackView and related, implement in first usage * Remove validation, fix type, fix ref * Migrate over list-conversations * Clarify intent behind precacheProfile and its unstable query cache * Clean up unstable profile cache * Fix types during label creation in PwiOptOut (#7346) * Tighten types in queries/list * Chat: use correct profile views * Chat: fix log type check * Chat: construct lexically correct shape, even though it's only internal usage * Chat: use correct profile types * Chat: fix type check in logs * Starter: use correct profile types * Starter: use correct profile types * Starter: tighten types to match lex * Any profile type will work in blocked-and-muted * Use dangerous util * Use dangerousIsType * Update new ProfileCard to use AnyProfileView * Use dangerousIsType * Remove outdated todo * Use correct profile type * Use correct profile types * Tighten up types * Use dangerousIsType * Chat: more type fixes * Remove unused file * Add a few utils * Remove unused file * Ignore feedPost.__source * Clean up types, leave validation in critical path * Use dangerousIstype * Use ANyProfileView * Use isValidRecord * Use dangerousIsType * Fix types in ListCard * Fix FeedInterstitials types * Fix types in FeedCard * Fix types in dms ReportDialog * Fix types in SearchablePeopleList * Fix bad type in composer opts * Starter: ok these need to be loose too * Clarify docs Co-authored-by: Matthieu Sieben <matthieusieben@users.noreply.github.com> * Less code Co-authored-by: Matthieu Sieben <matthieusieben@users.noreply.github.com> * Use package exports Co-authored-by: Matthieu Sieben <matthieusieben@users.noreply.github.com> * Use package exports * Bump sdk, update $Typed imports * Format * Format * Fix weird TS error * Remove patch * Beter name * It's memo, can validate * Tighten up parseEmbed, dogfood * Bump sdk * Use asPredicate * Loosen types a bit * use asPredicate * Fix types * Use asPredicate * Use asPredicate * Use asPredicate * Clean up upsertProfile types * Use asPredicate * Use Un util * Fix types * Use new AnyProfileView * Use dangerousIsType * Use asPredicate * Use asPredicate * Add fallback content-type to pass typecheck * Clean up upsertProfile types * Align types * Use dangerousIsType * Use dangerousIsType * Use asPredicate * Align types * Convert findLast * Align types * Just ignore type errors and use findLast * Rename atproto -> bsky * Add validate util * Fix type error * Loosen types * Export post * rename atp bsky * Remove unused code * minor changes * Bump deps * Fix types * Tighten back up loose check * Tighten back up loose check * Fix small bug * Update comment * Revert change --------- Co-authored-by: Matthieu Sieben <matthieusieben@users.noreply.github.com> Co-authored-by: Matthieu Sieben <matthieu.sieben@gmail.com>
547 lines
15 KiB
TypeScript
547 lines
15 KiB
TypeScript
import {useMemo} from 'react'
|
|
import {View} from 'react-native'
|
|
import {Image} from 'expo-image'
|
|
import {LinearGradient} from 'expo-linear-gradient'
|
|
import {
|
|
AppBskyActorDefs,
|
|
AppBskyEmbedVideo,
|
|
AppBskyFeedDefs,
|
|
AppBskyFeedPost,
|
|
ModerationDecision,
|
|
} from '@atproto/api'
|
|
import {msg} from '@lingui/macro'
|
|
import {useLingui} from '@lingui/react'
|
|
|
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
|
import {formatCount} from '#/view/com/util/numeric/format'
|
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
|
import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
|
|
import {atoms as a, useTheme} from '#/alf'
|
|
import {BLUE_HUE} from '#/alf/util/colorGeneration'
|
|
import {select} from '#/alf/util/themeSelector'
|
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
|
import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash'
|
|
import {Heart2_Stroke2_Corner0_Rounded as Heart} from '#/components/icons/Heart2'
|
|
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
|
|
import {Link} from '#/components/Link'
|
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
|
import * as Hider from '#/components/moderation/Hider'
|
|
import {Text} from '#/components/Typography'
|
|
import * as bsky from '#/types/bsky'
|
|
|
|
function getBlackColor(t: ReturnType<typeof useTheme>) {
|
|
return select(t.name, {
|
|
light: t.palette.black,
|
|
dark: t.atoms.bg_contrast_25.backgroundColor,
|
|
dim: `hsl(${BLUE_HUE}, 28%, 6%)`,
|
|
})
|
|
}
|
|
|
|
export function VideoPostCard({
|
|
post,
|
|
sourceContext,
|
|
moderation,
|
|
onInteract,
|
|
}: {
|
|
post: AppBskyFeedDefs.PostView
|
|
sourceContext: VideoFeedSourceContext
|
|
moderation: ModerationDecision
|
|
/**
|
|
* Callback for metrics etc
|
|
*/
|
|
onInteract?: () => void
|
|
}) {
|
|
const t = useTheme()
|
|
const {_, i18n} = useLingui()
|
|
const embed = post.embed
|
|
const {
|
|
state: pressed,
|
|
onIn: onPressIn,
|
|
onOut: onPressOut,
|
|
} = useInteractionState()
|
|
|
|
const listModUi = moderation.ui('contentList')
|
|
|
|
const mergedModui = useMemo(() => {
|
|
const modui = moderation.ui('contentList')
|
|
const mediaModui = moderation.ui('contentMedia')
|
|
modui.alerts = [...modui.alerts, ...mediaModui.alerts]
|
|
modui.blurs = [...modui.blurs, ...mediaModui.blurs]
|
|
modui.filters = [...modui.filters, ...mediaModui.filters]
|
|
modui.informs = [...modui.informs, ...mediaModui.informs]
|
|
return modui
|
|
}, [moderation])
|
|
|
|
/**
|
|
* Filtering should be done at a higher level, such as `PostFeed` or
|
|
* `PostFeedVideoGridRow`, but we need to protect here as well.
|
|
*/
|
|
if (!AppBskyEmbedVideo.isView(embed)) return null
|
|
|
|
const author = post.author
|
|
const text = bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
|
post.record,
|
|
AppBskyFeedPost.isRecord,
|
|
)
|
|
? post.record?.text
|
|
: ''
|
|
const likeCount = post?.likeCount ?? 0
|
|
const repostCount = post?.repostCount ?? 0
|
|
const {thumbnail} = embed
|
|
const black = getBlackColor(t)
|
|
|
|
const textAndAuthor = (
|
|
<View style={[a.pr_xs, {paddingTop: 6, gap: 4}]}>
|
|
{text && (
|
|
<Text style={[a.text_md, a.leading_snug]} numberOfLines={2} emoji>
|
|
{text}
|
|
</Text>
|
|
)}
|
|
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
|
|
<View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
|
|
<UserAvatar type="user" size={20} avatar={post.author.avatar} />
|
|
<MediaInsetBorder />
|
|
</View>
|
|
<Text
|
|
style={[
|
|
a.flex_1,
|
|
a.text_sm,
|
|
a.leading_tight,
|
|
t.atoms.text_contrast_medium,
|
|
]}
|
|
numberOfLines={1}>
|
|
{sanitizeHandle(post.author.handle, '@')}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
)
|
|
|
|
return (
|
|
<Link
|
|
accessibilityHint={_(msg`Views video in immersive mode`)}
|
|
label={_(msg`Video from ${author.handle}: ${text}`)}
|
|
to={{
|
|
screen: 'VideoFeed',
|
|
params: {
|
|
...sourceContext,
|
|
initialPostUri: post.uri,
|
|
},
|
|
}}
|
|
onPress={() => {
|
|
onInteract?.()
|
|
}}
|
|
onPressIn={onPressIn}
|
|
onPressOut={onPressOut}
|
|
style={[
|
|
a.flex_col,
|
|
{
|
|
alignItems: undefined,
|
|
justifyContent: undefined,
|
|
},
|
|
]}>
|
|
<Hider.Outer modui={mergedModui}>
|
|
<Hider.Mask>
|
|
<View
|
|
style={[
|
|
a.justify_center,
|
|
a.rounded_md,
|
|
a.overflow_hidden,
|
|
{
|
|
backgroundColor: black,
|
|
aspectRatio: 9 / 16,
|
|
},
|
|
]}>
|
|
<Image
|
|
source={{uri: thumbnail}}
|
|
style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
|
|
accessibilityIgnoresInvertColors
|
|
blurRadius={100}
|
|
/>
|
|
<MediaInsetBorder />
|
|
<View
|
|
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
|
<View
|
|
style={[
|
|
a.absolute,
|
|
a.inset_0,
|
|
a.justify_center,
|
|
a.align_center,
|
|
{
|
|
backgroundColor: 'black',
|
|
opacity: 0.2,
|
|
},
|
|
]}
|
|
/>
|
|
<View style={[a.align_center, a.gap_xs]}>
|
|
<Eye size="lg" fill="white" />
|
|
<Text style={[a.text_sm, {color: 'white'}]}>
|
|
{_(msg`Hidden`)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
{listModUi.blur ? (
|
|
<VideoPostCardTextPlaceholder author={post.author} />
|
|
) : (
|
|
textAndAuthor
|
|
)}
|
|
</Hider.Mask>
|
|
<Hider.Content>
|
|
<View
|
|
style={[
|
|
a.justify_center,
|
|
a.rounded_md,
|
|
a.overflow_hidden,
|
|
{
|
|
backgroundColor: black,
|
|
aspectRatio: 9 / 16,
|
|
},
|
|
]}>
|
|
<Image
|
|
source={{uri: thumbnail}}
|
|
style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
|
|
accessibilityIgnoresInvertColors
|
|
/>
|
|
<MediaInsetBorder />
|
|
|
|
<View style={[a.absolute, a.inset_0]}>
|
|
<View
|
|
style={[
|
|
a.absolute,
|
|
a.inset_0,
|
|
a.pt_2xl,
|
|
{
|
|
top: 'auto',
|
|
},
|
|
]}>
|
|
<LinearGradient
|
|
colors={[black, 'rgba(0, 0, 0, 0)']}
|
|
locations={[0.02, 1]}
|
|
start={{x: 0, y: 1}}
|
|
end={{x: 0, y: 0}}
|
|
style={[a.absolute, a.inset_0, {opacity: 0.9}]}
|
|
/>
|
|
|
|
<View
|
|
style={[a.relative, a.z_10, a.p_md, a.flex_row, a.gap_md]}>
|
|
{likeCount > 0 && (
|
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
|
<Heart size="sm" fill="white" />
|
|
<Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
|
|
{formatCount(i18n, likeCount)}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{repostCount > 0 && (
|
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
|
<Repost size="sm" fill="white" />
|
|
<Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
|
|
{formatCount(i18n, repostCount)}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
{textAndAuthor}
|
|
</Hider.Content>
|
|
</Hider.Outer>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
export function VideoPostCardPlaceholder() {
|
|
const t = useTheme()
|
|
const black = getBlackColor(t)
|
|
|
|
return (
|
|
<View style={[a.flex_1]}>
|
|
<View
|
|
style={[
|
|
a.rounded_md,
|
|
a.overflow_hidden,
|
|
{
|
|
backgroundColor: black,
|
|
aspectRatio: 9 / 16,
|
|
},
|
|
]}>
|
|
<MediaInsetBorder />
|
|
</View>
|
|
<VideoPostCardTextPlaceholder />
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export function VideoPostCardTextPlaceholder({
|
|
author,
|
|
}: {
|
|
author?: AppBskyActorDefs.ProfileViewBasic
|
|
}) {
|
|
const t = useTheme()
|
|
|
|
return (
|
|
<View style={[a.flex_1]}>
|
|
<View style={[a.pr_xs, {paddingTop: 8, gap: 6}]}>
|
|
<View
|
|
style={[
|
|
a.w_full,
|
|
a.rounded_xs,
|
|
t.atoms.bg_contrast_50,
|
|
{
|
|
height: 14,
|
|
},
|
|
]}
|
|
/>
|
|
<View
|
|
style={[
|
|
a.w_full,
|
|
a.rounded_xs,
|
|
t.atoms.bg_contrast_50,
|
|
{
|
|
height: 14,
|
|
width: '70%',
|
|
},
|
|
]}
|
|
/>
|
|
{author ? (
|
|
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
|
|
<View style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
|
|
<UserAvatar type="user" size={20} avatar={author.avatar} />
|
|
<MediaInsetBorder />
|
|
</View>
|
|
<Text
|
|
style={[
|
|
a.flex_1,
|
|
a.text_sm,
|
|
a.leading_tight,
|
|
t.atoms.text_contrast_medium,
|
|
]}
|
|
numberOfLines={1}>
|
|
{sanitizeHandle(author.handle, '@')}
|
|
</Text>
|
|
</View>
|
|
) : (
|
|
<View style={[a.flex_row, a.gap_xs, a.align_center]}>
|
|
<View
|
|
style={[
|
|
a.rounded_full,
|
|
t.atoms.bg_contrast_50,
|
|
{
|
|
width: 20,
|
|
height: 20,
|
|
},
|
|
]}
|
|
/>
|
|
<View
|
|
style={[
|
|
a.rounded_xs,
|
|
t.atoms.bg_contrast_25,
|
|
{
|
|
height: 12,
|
|
width: '75%',
|
|
},
|
|
]}
|
|
/>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export function CompactVideoPostCard({
|
|
post,
|
|
sourceContext,
|
|
moderation,
|
|
onInteract,
|
|
}: {
|
|
post: AppBskyFeedDefs.PostView
|
|
sourceContext: VideoFeedSourceContext
|
|
moderation: ModerationDecision
|
|
/**
|
|
* Callback for metrics etc
|
|
*/
|
|
onInteract?: () => void
|
|
}) {
|
|
const t = useTheme()
|
|
const {_, i18n} = useLingui()
|
|
const embed = post.embed
|
|
const {
|
|
state: pressed,
|
|
onIn: onPressIn,
|
|
onOut: onPressOut,
|
|
} = useInteractionState()
|
|
|
|
const mergedModui = useMemo(() => {
|
|
const modui = moderation.ui('contentList')
|
|
const mediaModui = moderation.ui('contentMedia')
|
|
modui.alerts = [...modui.alerts, ...mediaModui.alerts]
|
|
modui.blurs = [...modui.blurs, ...mediaModui.blurs]
|
|
modui.filters = [...modui.filters, ...mediaModui.filters]
|
|
modui.informs = [...modui.informs, ...mediaModui.informs]
|
|
return modui
|
|
}, [moderation])
|
|
|
|
/**
|
|
* Filtering should be done at a higher level, such as `PostFeed` or
|
|
* `PostFeedVideoGridRow`, but we need to protect here as well.
|
|
*/
|
|
if (!AppBskyEmbedVideo.isView(embed)) return null
|
|
|
|
const likeCount = post?.likeCount ?? 0
|
|
const {thumbnail} = embed
|
|
const black = getBlackColor(t)
|
|
|
|
return (
|
|
<Link
|
|
label={_(msg`View video`)}
|
|
to={{
|
|
screen: 'VideoFeed',
|
|
params: {
|
|
...sourceContext,
|
|
initialPostUri: post.uri,
|
|
},
|
|
}}
|
|
onPress={() => {
|
|
onInteract?.()
|
|
}}
|
|
onPressIn={onPressIn}
|
|
onPressOut={onPressOut}
|
|
style={[
|
|
a.flex_col,
|
|
{
|
|
alignItems: undefined,
|
|
justifyContent: undefined,
|
|
},
|
|
]}>
|
|
<Hider.Outer modui={mergedModui}>
|
|
<Hider.Mask>
|
|
<View
|
|
style={[
|
|
a.justify_center,
|
|
a.rounded_md,
|
|
a.overflow_hidden,
|
|
{
|
|
backgroundColor: black,
|
|
aspectRatio: 9 / 16,
|
|
},
|
|
]}>
|
|
<Image
|
|
source={{uri: thumbnail}}
|
|
style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
|
|
accessibilityIgnoresInvertColors
|
|
blurRadius={100}
|
|
/>
|
|
<MediaInsetBorder />
|
|
<View
|
|
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
|
<View
|
|
style={[
|
|
a.absolute,
|
|
a.inset_0,
|
|
a.justify_center,
|
|
a.align_center,
|
|
{
|
|
backgroundColor: 'black',
|
|
opacity: 0.2,
|
|
},
|
|
]}
|
|
/>
|
|
<View style={[a.align_center, a.gap_xs]}>
|
|
<Eye size="lg" fill="white" />
|
|
<Text style={[a.text_sm, {color: 'white'}]}>
|
|
{_(msg`Hidden`)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Hider.Mask>
|
|
<Hider.Content>
|
|
<View
|
|
style={[
|
|
a.justify_center,
|
|
a.rounded_md,
|
|
a.overflow_hidden,
|
|
{
|
|
backgroundColor: black,
|
|
aspectRatio: 9 / 16,
|
|
},
|
|
]}>
|
|
<Image
|
|
source={{uri: thumbnail}}
|
|
style={[a.w_full, a.h_full, {opacity: pressed ? 0.8 : 1}]}
|
|
accessibilityIgnoresInvertColors
|
|
/>
|
|
<MediaInsetBorder />
|
|
|
|
<View style={[a.absolute, a.inset_0]}>
|
|
<View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}>
|
|
<View
|
|
style={[a.relative, a.rounded_full, {width: 20, height: 20}]}>
|
|
<UserAvatar
|
|
type="user"
|
|
size={20}
|
|
avatar={post.author.avatar}
|
|
/>
|
|
<MediaInsetBorder />
|
|
</View>
|
|
</View>
|
|
<View
|
|
style={[
|
|
a.absolute,
|
|
a.inset_0,
|
|
a.pt_2xl,
|
|
{
|
|
top: 'auto',
|
|
},
|
|
]}>
|
|
<LinearGradient
|
|
colors={[black, 'rgba(0, 0, 0, 0)']}
|
|
locations={[0.02, 1]}
|
|
start={{x: 0, y: 1}}
|
|
end={{x: 0, y: 0}}
|
|
style={[a.absolute, a.inset_0, {opacity: 0.9}]}
|
|
/>
|
|
|
|
<View
|
|
style={[a.relative, a.z_10, a.p_sm, a.flex_row, a.gap_md]}>
|
|
{likeCount > 0 && (
|
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
|
<Heart size="sm" fill="white" />
|
|
<Text style={[a.text_sm, a.font_bold, {color: 'white'}]}>
|
|
{formatCount(i18n, likeCount)}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Hider.Content>
|
|
</Hider.Outer>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
export function CompactVideoPostCardPlaceholder() {
|
|
const t = useTheme()
|
|
const black = getBlackColor(t)
|
|
|
|
return (
|
|
<View style={[a.flex_1]}>
|
|
<View
|
|
style={[
|
|
a.rounded_md,
|
|
a.overflow_hidden,
|
|
{
|
|
backgroundColor: black,
|
|
aspectRatio: 9 / 16,
|
|
},
|
|
]}>
|
|
<MediaInsetBorder />
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|