Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4e5782e1e | |||
| b2c9dc4e34 | |||
| 71aec717f5 | |||
| defd61e85b | |||
| c5c3ce7f04 | |||
| e55a2ac42b | |||
| b3cbce44fd | |||
| 89f57ff879 | |||
| d7ea522301 | |||
| 9f04504e11 | |||
| 751923cda2 | |||
| cdf022e48e | |||
| f576db3023 | |||
| a61bef8716 | |||
| 12a0164464 | |||
| 413e87f4c5 | |||
| 4b5952c08c | |||
| 15862a6f7f | |||
| 926595493d | |||
| 8c1c149d5d | |||
| 4615056f17 | |||
| bb912321d6 | |||
| cfbcd99552 | |||
| 19c600843d | |||
| 50b8e6c63c | |||
| 9be0d49e78 | |||
| a5676e5cb4 | |||
| dcb6aa41cc | |||
| 571d00cf41 | |||
| 380c4dbf24 |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 476 B |
@@ -138,7 +138,7 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
||||
t.atoms.border_contrast_low,
|
||||
{minHeight: 44, paddingVertical: 10},
|
||||
style,
|
||||
(focused || pressed) && [t.atoms.bg_contrast_50],
|
||||
(focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50],
|
||||
]}>
|
||||
{children}
|
||||
</Pressable>
|
||||
|
||||
@@ -239,12 +239,13 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
|
||||
a.rounded_xs,
|
||||
{minHeight: 32, paddingHorizontal: 10},
|
||||
web({outline: 0}),
|
||||
(hovered || focused) && [
|
||||
web({outline: '0 !important'}),
|
||||
t.name === 'light'
|
||||
? t.atoms.bg_contrast_25
|
||||
: t.atoms.bg_contrast_50,
|
||||
],
|
||||
(hovered || focused) &&
|
||||
!rest.disabled && [
|
||||
web({outline: '0 !important'}),
|
||||
t.name === 'light'
|
||||
? t.atoms.bg_contrast_25
|
||||
: t.atoms.bg_contrast_50,
|
||||
],
|
||||
])}
|
||||
{...web({
|
||||
onMouseEnter,
|
||||
@@ -268,9 +269,7 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Comp
|
||||
size="md"
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
<View
|
||||
style={[
|
||||
position === 'left' && {
|
||||
marginLeft: -2,
|
||||
@@ -279,8 +278,9 @@ export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
|
||||
marginRight: -2,
|
||||
marginLeft: 12,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
]}>
|
||||
<Comp size="md" fill={t.atoms.text_contrast_medium.color} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,15 @@ import {
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export type AppModerationCause =
|
||||
| ModerationCause
|
||||
| {
|
||||
type: 'reply-hidden'
|
||||
source: {type: 'user'}
|
||||
priority: 6
|
||||
downgraded?: boolean
|
||||
}
|
||||
|
||||
export type CommonProps = {
|
||||
size?: 'sm' | 'lg'
|
||||
}
|
||||
@@ -40,7 +49,7 @@ export function Row({
|
||||
}
|
||||
|
||||
export type LabelProps = {
|
||||
cause: ModerationCause
|
||||
cause: AppModerationCause
|
||||
disableDetailsDialog?: boolean
|
||||
noBg?: boolean
|
||||
} & CommonProps
|
||||
|
||||
@@ -11,16 +11,17 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {createThreadgate} from '#/lib/api'
|
||||
import {until} from '#/lib/async/until'
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
|
||||
import {updateThreadgateAllow} from '#/state/queries/threadgate'
|
||||
import {threadgateRecordQueryKeyRoot} from '#/state/queries/threadgate'
|
||||
import {
|
||||
ThreadgateSetting,
|
||||
threadgateViewToSettings,
|
||||
ThreadgateAllowUISetting,
|
||||
threadgateViewToAllowUISetting,
|
||||
} from '#/state/queries/threadgate'
|
||||
import {useAgent} from '#/state/session'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
@@ -51,7 +52,7 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const settings = React.useMemo(
|
||||
() => threadgateViewToSettings(post.threadgate),
|
||||
() => threadgateViewToAllowUISetting(post.threadgate),
|
||||
[post],
|
||||
)
|
||||
const isRootPost = !('reply' in post.record)
|
||||
@@ -82,24 +83,22 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const onEditConfirm = async (newSettings: ThreadgateSetting[]) => {
|
||||
const onEditConfirm = async (newSettings: ThreadgateAllowUISetting[]) => {
|
||||
if (JSON.stringify(settings) === JSON.stringify(newSettings)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (newSettings.length) {
|
||||
await createThreadgate(agent, post.uri, newSettings)
|
||||
} else {
|
||||
await agent.api.com.atproto.repo.deleteRecord({
|
||||
repo: agent.session!.did,
|
||||
collection: 'app.bsky.feed.threadgate',
|
||||
rkey: new AtUri(post.uri).rkey,
|
||||
})
|
||||
}
|
||||
await updateThreadgateAllow({
|
||||
agent,
|
||||
postUri: post.uri,
|
||||
allow: newSettings,
|
||||
})
|
||||
|
||||
// TODO
|
||||
await whenAppViewReady(agent, post.uri, res => {
|
||||
const thread = res.data.thread
|
||||
if (AppBskyFeedDefs.isThreadViewPost(thread)) {
|
||||
const fetchedSettings = threadgateViewToSettings(
|
||||
const fetchedSettings = threadgateViewToAllowUISetting(
|
||||
thread.post.threadgate,
|
||||
)
|
||||
return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
|
||||
@@ -110,6 +109,9 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [POST_THREAD_RQKEY_ROOT],
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [threadgateRecordQueryKeyRoot],
|
||||
})
|
||||
} catch (err) {
|
||||
Toast.show(
|
||||
_(
|
||||
@@ -174,7 +176,7 @@ function Icon({
|
||||
}: {
|
||||
color: string
|
||||
width?: number
|
||||
settings: ThreadgateSetting[]
|
||||
settings: ThreadgateAllowUISetting[]
|
||||
}) {
|
||||
const isEverybody = settings.length === 0
|
||||
const isNobody = !!settings.find(gate => gate.type === 'nobody')
|
||||
@@ -189,7 +191,7 @@ function WhoCanReplyDialog({
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
post: AppBskyFeedDefs.PostView
|
||||
settings: ThreadgateSetting[]
|
||||
settings: ThreadgateAllowUISetting[]
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
@@ -204,7 +206,7 @@ function WhoCanReplyDialogInner({
|
||||
settings,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
settings: ThreadgateSetting[]
|
||||
settings: ThreadgateAllowUISetting[]
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
@@ -226,7 +228,7 @@ function Rules({
|
||||
settings,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
settings: ThreadgateSetting[]
|
||||
settings: ThreadgateAllowUISetting[]
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
@@ -267,7 +269,7 @@ function Rule({
|
||||
post,
|
||||
lists,
|
||||
}: {
|
||||
rule: ThreadgateSetting
|
||||
rule: ThreadgateAllowUISetting
|
||||
post: AppBskyFeedDefs.PostView
|
||||
lists: AppBskyGraphDefs.ListViewBasic[] | undefined
|
||||
}) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {useLingui} from '@lingui/react'
|
||||
import isEqual from 'lodash.isequal'
|
||||
|
||||
import {useMyListsQuery} from '#/state/queries/my-lists'
|
||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
@@ -14,9 +14,9 @@ import {Text} from '#/components/Typography'
|
||||
|
||||
interface ThreadgateEditorDialogProps {
|
||||
control: Dialog.DialogControlProps
|
||||
threadgate: ThreadgateSetting[]
|
||||
onChange?: (v: ThreadgateSetting[]) => void
|
||||
onConfirm?: (v: ThreadgateSetting[]) => void
|
||||
threadgate: ThreadgateAllowUISetting[]
|
||||
onChange?: (v: ThreadgateAllowUISetting[]) => void
|
||||
onConfirm?: (v: ThreadgateAllowUISetting[]) => void
|
||||
}
|
||||
|
||||
export function ThreadgateEditorDialog({
|
||||
@@ -42,9 +42,9 @@ function DialogContent({
|
||||
onChange,
|
||||
onConfirm,
|
||||
}: {
|
||||
seedThreadgate: ThreadgateSetting[]
|
||||
onChange?: (v: ThreadgateSetting[]) => void
|
||||
onConfirm?: (v: ThreadgateSetting[]) => void
|
||||
seedThreadgate: ThreadgateAllowUISetting[]
|
||||
onChange?: (v: ThreadgateAllowUISetting[]) => void
|
||||
onConfirm?: (v: ThreadgateAllowUISetting[]) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const control = Dialog.useDialogContext()
|
||||
@@ -59,22 +59,22 @@ function DialogContent({
|
||||
setDraft(seedThreadgate) // Reset draft.
|
||||
}
|
||||
|
||||
function updateThreadgate(nextThreadgate: ThreadgateSetting[]) {
|
||||
function updateThreadgate(nextThreadgate: ThreadgateAllowUISetting[]) {
|
||||
setDraft(nextThreadgate)
|
||||
onChange?.(nextThreadgate)
|
||||
}
|
||||
|
||||
const onPressEverybody = () => {
|
||||
updateThreadgate([])
|
||||
updateThreadgate([{type: 'everybody'}])
|
||||
}
|
||||
|
||||
const onPressNobody = () => {
|
||||
updateThreadgate([{type: 'nobody'}])
|
||||
}
|
||||
|
||||
const onPressAudience = (setting: ThreadgateSetting) => {
|
||||
const onPressAudience = (setting: ThreadgateAllowUISetting) => {
|
||||
// remove nobody
|
||||
let newSelected: ThreadgateSetting[] = draft.filter(
|
||||
let newSelected: ThreadgateAllowUISetting[] = draft.filter(
|
||||
v => v.type !== 'nobody',
|
||||
)
|
||||
// toggle
|
||||
@@ -102,7 +102,7 @@ function DialogContent({
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<Selectable
|
||||
label={_(msg`Everybody`)}
|
||||
isSelected={draft.length === 0}
|
||||
isSelected={!!draft.find(v => v.type === 'everybody')}
|
||||
onPress={onPressEverybody}
|
||||
style={{flex: 1}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
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',
|
||||
})
|
||||
@@ -12,13 +12,14 @@ import {atoms as a, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {AppModerationCause} from '#/components/Pills'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog'
|
||||
|
||||
export interface ModerationDetailsDialogProps {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
modcause: ModerationCause
|
||||
modcause: ModerationCause | AppModerationCause
|
||||
}
|
||||
|
||||
export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
|
||||
@@ -105,6 +106,9 @@ function ModerationDetailsDialogInner({
|
||||
} else if (modcause.type === 'hidden') {
|
||||
name = _(msg`Post Hidden by You`)
|
||||
description = _(msg`You have hidden this post.`)
|
||||
} else if (modcause.type === 'reply-hidden') {
|
||||
name = _(msg`Post Hidden by Thread Author`)
|
||||
description = _(msg`The author of this thread has hidden this post.`)
|
||||
} else if (modcause.type === 'label') {
|
||||
name = desc.name
|
||||
description = desc.description
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, ViewStyle} from 'react-native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {ModerationCause, ModerationUI} from '@atproto/api'
|
||||
|
||||
import {getModerationCauseKey} from '#/lib/moderation'
|
||||
import * as Pills from '#/components/Pills'
|
||||
@@ -9,13 +9,15 @@ export function PostAlerts({
|
||||
modui,
|
||||
size = 'sm',
|
||||
style,
|
||||
additionalCauses,
|
||||
}: {
|
||||
modui: ModerationUI
|
||||
size?: Pills.CommonProps['size']
|
||||
includeMute?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
additionalCauses?: ModerationCause[] | Pills.AppModerationCause[]
|
||||
}) {
|
||||
if (!modui.alert && !modui.inform) {
|
||||
if (!modui.alert && !modui.inform && !additionalCauses?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -37,6 +39,14 @@ export function PostAlerts({
|
||||
noBg={size === 'sm'}
|
||||
/>
|
||||
))}
|
||||
{additionalCauses?.map(cause => (
|
||||
<Pills.Label
|
||||
key={getModerationCauseKey(cause)}
|
||||
cause={cause}
|
||||
size={size}
|
||||
noBg={size === 'sm'}
|
||||
/>
|
||||
))}
|
||||
</Pills.Row>
|
||||
)
|
||||
}
|
||||
|
||||
+15
-43
@@ -3,7 +3,6 @@ import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedThreadgate,
|
||||
BskyAgent,
|
||||
ComAtprotoLabelDefs,
|
||||
ComAtprotoRepoUploadBlob,
|
||||
@@ -12,7 +11,12 @@ import {
|
||||
import {AtUri} from '@atproto/api'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||
import {
|
||||
createThreadgateRecord,
|
||||
ThreadgateAllowUISetting,
|
||||
threadgateAllowUISettingToAllowRecordValue,
|
||||
writeThreadgateRecord,
|
||||
} from '#/state/queries/threadgate'
|
||||
import {isNetworkError} from 'lib/strings/errors'
|
||||
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
@@ -61,7 +65,7 @@ interface PostOpts {
|
||||
extLink?: ExternalEmbedDraft
|
||||
images?: ImageModel[]
|
||||
labels?: string[]
|
||||
threadgate?: ThreadgateSetting[]
|
||||
threadgate?: ThreadgateAllowUISetting[]
|
||||
onStateChange?: (state: string) => void
|
||||
langs?: string[]
|
||||
}
|
||||
@@ -262,7 +266,14 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
try {
|
||||
// TODO: this needs to be batch-created with the post!
|
||||
if (opts.threadgate?.length) {
|
||||
await createThreadgate(agent, res.uri, opts.threadgate)
|
||||
await writeThreadgateRecord({
|
||||
agent,
|
||||
postUri: res.uri,
|
||||
threadgate: createThreadgateRecord({
|
||||
post: res.uri,
|
||||
allow: threadgateAllowUISettingToAllowRecordValue(opts.threadgate),
|
||||
}),
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(`Failed to create threadgate: ${e.toString()}`)
|
||||
@@ -274,45 +285,6 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
return res
|
||||
}
|
||||
|
||||
export async function createThreadgate(
|
||||
agent: BskyAgent,
|
||||
postUri: string,
|
||||
threadgate: ThreadgateSetting[],
|
||||
) {
|
||||
let allow: (
|
||||
| AppBskyFeedThreadgate.MentionRule
|
||||
| AppBskyFeedThreadgate.FollowingRule
|
||||
| AppBskyFeedThreadgate.ListRule
|
||||
)[] = []
|
||||
if (!threadgate.find(v => v.type === 'nobody')) {
|
||||
for (const rule of threadgate) {
|
||||
if (rule.type === 'mention') {
|
||||
allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'})
|
||||
} else if (rule.type === 'following') {
|
||||
allow.push({$type: 'app.bsky.feed.threadgate#followingRule'})
|
||||
} else if (rule.type === 'list') {
|
||||
allow.push({
|
||||
$type: 'app.bsky.feed.threadgate#listRule',
|
||||
list: rule.list,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const postUrip = new AtUri(postUri)
|
||||
await agent.api.com.atproto.repo.putRecord({
|
||||
repo: agent.session!.did,
|
||||
collection: 'app.bsky.feed.threadgate',
|
||||
rkey: postUrip.rkey,
|
||||
record: {
|
||||
$type: 'app.bsky.feed.threadgate',
|
||||
post: postUri,
|
||||
allow,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// helpers
|
||||
// =
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import {
|
||||
ModerationCause,
|
||||
ModerationUI,
|
||||
InterpretedLabelValueDefinition,
|
||||
LABELS,
|
||||
AppBskyLabelerDefs,
|
||||
BskyAgent,
|
||||
InterpretedLabelValueDefinition,
|
||||
LABELS,
|
||||
ModerationCause,
|
||||
ModerationOpts,
|
||||
ModerationUI,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {AppModerationCause} from '#/components/Pills'
|
||||
|
||||
export function getModerationCauseKey(cause: ModerationCause): string {
|
||||
export function getModerationCauseKey(
|
||||
cause: ModerationCause | AppModerationCause,
|
||||
): string {
|
||||
const source =
|
||||
cause.source.type === 'labeler'
|
||||
? cause.source.did
|
||||
|
||||
@@ -13,6 +13,7 @@ import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/ico
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
import {AppModerationCause} from '#/components/Pills'
|
||||
import {useGlobalLabelStrings} from './useGlobalLabelStrings'
|
||||
import {getDefinition, getLabelStrings} from './useLabelInfo'
|
||||
|
||||
@@ -27,7 +28,7 @@ export interface ModerationCauseDescription {
|
||||
}
|
||||
|
||||
export function useModerationCauseDescription(
|
||||
cause: ModerationCause | undefined,
|
||||
cause: ModerationCause | AppModerationCause | undefined,
|
||||
): ModerationCauseDescription {
|
||||
const {_, i18n} = useLingui()
|
||||
const {labelDefs, labelers} = useLabelDefinitions()
|
||||
@@ -111,6 +112,13 @@ export function useModerationCauseDescription(
|
||||
description: _(msg`You have hidden this post`),
|
||||
}
|
||||
}
|
||||
if (cause.type === 'reply-hidden') {
|
||||
return {
|
||||
icon: EyeSlash,
|
||||
name: _(msg`Post Hidden by Thread Author`),
|
||||
description: _(msg`The author of this thread has hidden this post.`),
|
||||
}
|
||||
}
|
||||
if (cause.type === 'label') {
|
||||
const def = cause.labelDef || getDefinition(labelDefs, cause.label)
|
||||
const strings = getLabelStrings(i18n.locale, globalLabelStrings, def)
|
||||
|
||||
Vendored
+17
-1
@@ -1,5 +1,9 @@
|
||||
import {useEffect, useMemo, useState} from 'react'
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
} from '@atproto/api'
|
||||
import {QueryClient} from '@tanstack/react-query'
|
||||
import EventEmitter from 'eventemitter3'
|
||||
|
||||
@@ -15,6 +19,7 @@ export interface PostShadow {
|
||||
likeUri: string | undefined
|
||||
repostUri: string | undefined
|
||||
isDeleted: boolean
|
||||
embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined
|
||||
}
|
||||
|
||||
export const POST_TOMBSTONE = Symbol('PostTombstone')
|
||||
@@ -86,6 +91,17 @@ function mergeShadow(
|
||||
repostCount = Math.max(0, repostCount)
|
||||
}
|
||||
|
||||
if ('embed' in shadow) {
|
||||
if (
|
||||
(AppBskyEmbedRecord.isView(post.embed) &&
|
||||
AppBskyEmbedRecord.isView(shadow.embed)) ||
|
||||
(AppBskyEmbedRecordWithMedia.isView(post.embed) &&
|
||||
AppBskyEmbedRecordWithMedia.isView(shadow.embed))
|
||||
) {
|
||||
post.embed = shadow.embed
|
||||
}
|
||||
}
|
||||
|
||||
return castAsShadow({
|
||||
...post,
|
||||
likeCount: likeCount,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetPostThread,
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedThreadgate,
|
||||
AtUri,
|
||||
ModerationDecision,
|
||||
ModerationOpts,
|
||||
@@ -138,6 +139,7 @@ export function sortThread(
|
||||
modCache: ThreadModerationCache,
|
||||
currentDid: string | undefined,
|
||||
justPostedUris: Set<string>,
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record,
|
||||
): ThreadNode {
|
||||
if (node.type !== 'post') {
|
||||
return node
|
||||
@@ -151,6 +153,19 @@ export function sortThread(
|
||||
return -1
|
||||
}
|
||||
|
||||
/*
|
||||
* Hiding takes precendence over OP check below bc thread root author
|
||||
* can't hide their own posts, therefore this only applies to other
|
||||
* authors.
|
||||
*/
|
||||
const aHidden = threadgateRecord?.hiddenReplies?.includes(a.uri)
|
||||
const bHidden = threadgateRecord?.hiddenReplies?.includes(b.uri)
|
||||
if (aHidden && !bHidden) {
|
||||
return 1
|
||||
} else if (bHidden && !aHidden) {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (node.ctx.isHighlightedPost || opts.lab_treeViewEnabled) {
|
||||
const aIsJustPosted =
|
||||
a.post.author.did === currentDid && justPostedUris.has(a.post.uri)
|
||||
@@ -222,7 +237,14 @@ export function sortThread(
|
||||
return b.post.indexedAt.localeCompare(a.post.indexedAt)
|
||||
})
|
||||
node.replies.forEach(reply =>
|
||||
sortThread(reply, opts, modCache, currentDid, justPostedUris),
|
||||
sortThread(
|
||||
reply,
|
||||
opts,
|
||||
modCache,
|
||||
currentDid,
|
||||
justPostedUris,
|
||||
threadgateRecord,
|
||||
),
|
||||
)
|
||||
}
|
||||
return node
|
||||
|
||||
@@ -73,6 +73,30 @@ export function useGetPost() {
|
||||
)
|
||||
}
|
||||
|
||||
export function useGetPosts() {
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
return useCallback(
|
||||
async ({uris}: {uris: string[]}) => {
|
||||
return queryClient.fetchQuery({
|
||||
queryKey: RQKEY(uris.join(',') || ''),
|
||||
async queryFn() {
|
||||
const res = await agent.getPosts({
|
||||
uris,
|
||||
})
|
||||
|
||||
if (res.success) {
|
||||
return res.data.posts
|
||||
} else {
|
||||
throw new Error('useGetPosts failed')
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
[queryClient, agent],
|
||||
)
|
||||
}
|
||||
|
||||
export function usePostLikeMutationQueue(
|
||||
post: Shadow<AppBskyFeedDefs.PostView>,
|
||||
logContext: LogEvents['post:like']['logContext'] &
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPostgate,
|
||||
AtUri,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {networkRetry, retry} from '#/lib/async/retry'
|
||||
import {logger} from '#/logger'
|
||||
import {updatePostShadow} from '#/state/cache/post-shadow'
|
||||
import {useGetPosts} from '#/state/queries/post'
|
||||
import {
|
||||
createMaybeDetachedQuoteEmbed,
|
||||
createPostgateRecord,
|
||||
mergePostgateRecords,
|
||||
POSTGATE_COLLECTION,
|
||||
} from '#/state/queries/postgate/util'
|
||||
import {useAgent} from '#/state/session'
|
||||
|
||||
export async function getPostgateRecord({
|
||||
agent,
|
||||
postUri,
|
||||
}: {
|
||||
agent: BskyAgent
|
||||
postUri: string
|
||||
}): Promise<AppBskyFeedPostgate.Record | undefined> {
|
||||
const urip = new AtUri(postUri)
|
||||
|
||||
if (!urip.host.startsWith('did:')) {
|
||||
const res = await agent.resolveHandle({
|
||||
handle: urip.host,
|
||||
})
|
||||
urip.host = res.data.did
|
||||
}
|
||||
|
||||
try {
|
||||
const {data} = await retry(
|
||||
2,
|
||||
e => {
|
||||
/*
|
||||
* If the record doesn't exist, we want to return null instead of
|
||||
* throwing an error. NB: This will also catch reference errors, such as
|
||||
* a typo in the URI.
|
||||
*/
|
||||
if (e.message.includes(`Could not locate record:`)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
() =>
|
||||
agent.api.com.atproto.repo.getRecord({
|
||||
repo: urip.host,
|
||||
collection: POSTGATE_COLLECTION,
|
||||
rkey: urip.rkey,
|
||||
}),
|
||||
)
|
||||
|
||||
if (data.value && AppBskyFeedPostgate.isRecord(data.value)) {
|
||||
return data.value
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
} catch (e: any) {
|
||||
/*
|
||||
* If the record doesn't exist, we want to return null instead of
|
||||
* throwing an error. NB: This will also catch reference errors, such as
|
||||
* a typo in the URI.
|
||||
*/
|
||||
if (e.message.includes(`Could not locate record:`)) {
|
||||
return undefined
|
||||
} else {
|
||||
throw new Error(`Failed to get postgate record`, {cause: e})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function writePostgateRecord({
|
||||
agent,
|
||||
postUri,
|
||||
postgate,
|
||||
}: {
|
||||
agent: BskyAgent
|
||||
postUri: string
|
||||
postgate: AppBskyFeedPostgate.Record
|
||||
}) {
|
||||
const postUrip = new AtUri(postUri)
|
||||
|
||||
await networkRetry(2, () =>
|
||||
agent.api.com.atproto.repo.putRecord({
|
||||
repo: agent.session!.did,
|
||||
collection: POSTGATE_COLLECTION,
|
||||
rkey: postUrip.rkey,
|
||||
record: postgate,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function upsertPostgate(
|
||||
{
|
||||
agent,
|
||||
postUri,
|
||||
}: {
|
||||
agent: BskyAgent
|
||||
postUri: string
|
||||
},
|
||||
callback: (
|
||||
postgate: AppBskyFeedPostgate.Record | undefined,
|
||||
) => Promise<AppBskyFeedPostgate.Record | undefined>,
|
||||
) {
|
||||
const prev = await getPostgateRecord({
|
||||
agent,
|
||||
postUri,
|
||||
})
|
||||
const next = await callback(prev)
|
||||
if (!next) return
|
||||
await writePostgateRecord({
|
||||
agent,
|
||||
postUri,
|
||||
postgate: next,
|
||||
})
|
||||
}
|
||||
|
||||
export const createPostgateQueryKey = (postUri: string) => [
|
||||
'postgate-record',
|
||||
postUri,
|
||||
]
|
||||
export function usePostgateQuery({postUri}: {postUri: string}) {
|
||||
const agent = useAgent()
|
||||
return useQuery({
|
||||
queryKey: createPostgateQueryKey(postUri),
|
||||
queryFn() {
|
||||
return getPostgateRecord({agent, postUri})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useToggleQuoteDetachmentMutation() {
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const getPosts = useGetPosts()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
post,
|
||||
quoteUri,
|
||||
action,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
quoteUri: string
|
||||
action: 'detach' | 'reattach'
|
||||
}) => {
|
||||
await upsertPostgate({agent, postUri: quoteUri}, async prev => {
|
||||
if (prev) {
|
||||
if (action === 'detach') {
|
||||
return mergePostgateRecords(prev, {
|
||||
detachedQuotes: [post.uri],
|
||||
})
|
||||
} else if (action === 'reattach') {
|
||||
return {
|
||||
...prev,
|
||||
detachedQuotes:
|
||||
prev.detachedQuotes?.filter(uri => uri !== post.uri) || [],
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (action === 'detach') {
|
||||
return createPostgateRecord({
|
||||
post: quoteUri,
|
||||
detachedQuotes: [post.uri],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
async onSuccess(_data, {post, quoteUri, action}) {
|
||||
if (action === 'detach') {
|
||||
updatePostShadow(queryClient, post.uri, {
|
||||
embed: createMaybeDetachedQuoteEmbed({
|
||||
post,
|
||||
quote: undefined,
|
||||
quoteUri,
|
||||
detached: true,
|
||||
}),
|
||||
})
|
||||
} else if (action === 'reattach') {
|
||||
try {
|
||||
const [quote] = await getPosts({uris: [quoteUri]})
|
||||
updatePostShadow(queryClient, post.uri, {
|
||||
embed: createMaybeDetachedQuoteEmbed({
|
||||
post,
|
||||
quote,
|
||||
quoteUri: undefined,
|
||||
detached: false,
|
||||
}),
|
||||
})
|
||||
} catch (e: any) {
|
||||
// ok if this fails, it's just optimistic UI
|
||||
logger.error(`Postgate: failed to get quote post for re-attachment`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useToggleQuotepostEnabledMutation() {
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
postUri,
|
||||
action,
|
||||
}: {
|
||||
postUri: string
|
||||
action: 'enable' | 'disable'
|
||||
}) => {
|
||||
await upsertPostgate({agent, postUri: postUri}, async prev => {
|
||||
console.log({action})
|
||||
if (prev) {
|
||||
if (action === 'disable') {
|
||||
return mergePostgateRecords(prev, {
|
||||
quotepostRules: [{$type: 'app.bsky.feed.postgate#disableRule'}],
|
||||
})
|
||||
} else if (action === 'enable') {
|
||||
return {
|
||||
...prev,
|
||||
quotepostRules: [],
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (action === 'disable') {
|
||||
return createPostgateRecord({
|
||||
post: postUri,
|
||||
quotepostRules: [{$type: 'app.bsky.feed.postgate#disableRule'}],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPostgate,
|
||||
AtUri,
|
||||
} from '@atproto/api'
|
||||
import {ViewRemoved} from '@atproto/api/dist/client/types/app/bsky/embed/record'
|
||||
|
||||
export const POSTGATE_COLLECTION = 'app.bsky.feed.postgate'
|
||||
|
||||
export function createPostgateRecord(
|
||||
postgate: Partial<AppBskyFeedPostgate.Record>,
|
||||
): AppBskyFeedPostgate.Record {
|
||||
if (!postgate.post) {
|
||||
throw new Error(`Cannot create a postgate record without a post URI`)
|
||||
}
|
||||
|
||||
return {
|
||||
$type: POSTGATE_COLLECTION,
|
||||
createdAt: new Date().toISOString(),
|
||||
post: postgate.post,
|
||||
detachedQuotes: postgate.detachedQuotes || [],
|
||||
quotepostRules: postgate.quotepostRules || [],
|
||||
}
|
||||
}
|
||||
|
||||
export function mergePostgateRecords(
|
||||
prev: AppBskyFeedPostgate.Record,
|
||||
next: Partial<AppBskyFeedPostgate.Record>,
|
||||
) {
|
||||
const detachedQuotes = Array.from(
|
||||
new Set([...(prev.detachedQuotes || []), ...(next.detachedQuotes || [])]),
|
||||
)
|
||||
const quotepostRules = [
|
||||
...(prev.quotepostRules || []),
|
||||
...(next.quotepostRules || []),
|
||||
].filter(
|
||||
(rule, i, all) => all.findIndex(_rule => _rule.$type === rule.$type) === i,
|
||||
)
|
||||
return createPostgateRecord({
|
||||
post: prev.post,
|
||||
detachedQuotes,
|
||||
quotepostRules,
|
||||
})
|
||||
}
|
||||
|
||||
export function createEmbedViewRemovedRecord({uri}: {uri: string}) {
|
||||
const record: ViewRemoved = {
|
||||
$type: 'app.bsky.embed.record#viewRemoved',
|
||||
uri,
|
||||
removed: true,
|
||||
}
|
||||
return {
|
||||
$type: 'app.bsky.embed.record#view',
|
||||
record,
|
||||
}
|
||||
}
|
||||
|
||||
export function createMaybeDetachedQuoteEmbed({
|
||||
post,
|
||||
quote,
|
||||
quoteUri,
|
||||
detached,
|
||||
}:
|
||||
| {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
quote: AppBskyFeedDefs.PostView
|
||||
quoteUri: undefined
|
||||
detached: false
|
||||
}
|
||||
| {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
quote: undefined
|
||||
quoteUri: string
|
||||
detached: true
|
||||
}): AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined {
|
||||
if (AppBskyEmbedRecord.isView(post.embed)) {
|
||||
if (detached) {
|
||||
return createEmbedViewRemovedRecord({uri: quoteUri})
|
||||
} else {
|
||||
return createEmbedRecordView({post: quote})
|
||||
}
|
||||
} else if (AppBskyEmbedRecordWithMedia.isView(post.embed)) {
|
||||
if (detached) {
|
||||
return {
|
||||
...post.embed,
|
||||
record: createEmbedViewRemovedRecord({uri: quoteUri}),
|
||||
}
|
||||
} else {
|
||||
return createEmbedRecordWithMediaView({post, quote})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmbedViewRecordFromPost(
|
||||
post: AppBskyFeedDefs.PostView,
|
||||
): AppBskyEmbedRecord.ViewRecord {
|
||||
return {
|
||||
$type: 'app.bsky.embed.record#viewRecord',
|
||||
uri: post.uri,
|
||||
cid: post.cid,
|
||||
author: post.author,
|
||||
value: post.record,
|
||||
labels: post.labels,
|
||||
replyCount: post.replyCount,
|
||||
repostCount: post.repostCount,
|
||||
likeCount: post.likeCount,
|
||||
indexedAt: post.indexedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmbedRecordView({
|
||||
post,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
}): AppBskyEmbedRecord.View {
|
||||
return {
|
||||
$type: 'app.bsky.embed.record#view',
|
||||
record: createEmbedViewRecordFromPost(post),
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmbedRecordWithMediaView({
|
||||
post,
|
||||
quote,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
quote: AppBskyFeedDefs.PostView
|
||||
}): AppBskyEmbedRecordWithMedia.View | undefined {
|
||||
if (!AppBskyEmbedRecordWithMedia.isView(post.embed)) return
|
||||
return {
|
||||
...(post.embed || {}),
|
||||
record: {
|
||||
record: createEmbedViewRecordFromPost(quote),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getMaybeDetachedQuoteEmbed({
|
||||
viewerDid,
|
||||
post,
|
||||
}: {
|
||||
viewerDid: string
|
||||
post: AppBskyFeedDefs.PostView
|
||||
}) {
|
||||
if (AppBskyEmbedRecord.isView(post.embed)) {
|
||||
// detached
|
||||
if (AppBskyEmbedRecord.isViewRemoved(post.embed.record)) {
|
||||
const urip = new AtUri(post.embed.record.uri)
|
||||
return {
|
||||
embed: post.embed,
|
||||
uri: urip.toString(),
|
||||
isOwnedByViewer: urip.host === viewerDid,
|
||||
isDetached: true,
|
||||
}
|
||||
}
|
||||
|
||||
// post
|
||||
if (AppBskyEmbedRecord.isViewRecord(post.embed.record)) {
|
||||
const urip = new AtUri(post.embed.record.uri)
|
||||
return {
|
||||
embed: post.embed,
|
||||
uri: urip.toString(),
|
||||
isOwnedByViewer: urip.host === viewerDid,
|
||||
isDetached: false,
|
||||
}
|
||||
}
|
||||
} else if (AppBskyEmbedRecordWithMedia.isView(post.embed)) {
|
||||
// detached
|
||||
if (AppBskyEmbedRecord.isViewRemoved(post.embed.record.record)) {
|
||||
const urip = new AtUri(post.embed.record.record.uri)
|
||||
return {
|
||||
embed: post.embed,
|
||||
uri: urip.toString(),
|
||||
isOwnedByViewer: urip.host === viewerDid,
|
||||
isDetached: true,
|
||||
}
|
||||
}
|
||||
|
||||
// post
|
||||
if (AppBskyEmbedRecord.isViewRecord(post.embed.record.record)) {
|
||||
const urip = new AtUri(post.embed.record.record.uri)
|
||||
return {
|
||||
embed: post.embed,
|
||||
uri: urip.toString(),
|
||||
isOwnedByViewer: urip.host === viewerDid,
|
||||
isDetached: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
|
||||
|
||||
export type ThreadgateSetting =
|
||||
| {type: 'nobody'}
|
||||
| {type: 'mention'}
|
||||
| {type: 'following'}
|
||||
| {type: 'list'; list: unknown}
|
||||
|
||||
export function threadgateViewToSettings(
|
||||
threadgate: AppBskyFeedDefs.ThreadgateView | undefined,
|
||||
): ThreadgateSetting[] {
|
||||
const record =
|
||||
threadgate &&
|
||||
AppBskyFeedThreadgate.isRecord(threadgate.record) &&
|
||||
AppBskyFeedThreadgate.validateRecord(threadgate.record).success
|
||||
? threadgate.record
|
||||
: null
|
||||
if (!record) {
|
||||
return []
|
||||
}
|
||||
if (!record.allow?.length) {
|
||||
return [{type: 'nobody'}]
|
||||
}
|
||||
const settings: ThreadgateSetting[] = record.allow
|
||||
.map(allow => {
|
||||
let setting: ThreadgateSetting | undefined
|
||||
if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
|
||||
setting = {type: 'mention'}
|
||||
} else if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
|
||||
setting = {type: 'following'}
|
||||
} else if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
|
||||
setting = {type: 'list', list: allow.list}
|
||||
}
|
||||
return setting
|
||||
})
|
||||
.filter(n => !!n)
|
||||
return settings
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import {AppBskyFeedThreadgate, AtUri, BskyAgent} from '@atproto/api'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {networkRetry, retry} from '#/lib/async/retry'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types'
|
||||
import {
|
||||
createThreadgateRecord,
|
||||
mergeThreadgateRecords,
|
||||
threadgateAllowUISettingToAllowRecordValue,
|
||||
} from '#/state/queries/threadgate/util'
|
||||
import {useAgent} from '#/state/session'
|
||||
|
||||
export * from '#/state/queries/threadgate/types'
|
||||
export * from '#/state/queries/threadgate/util'
|
||||
|
||||
export const threadgateRecordQueryKeyRoot = 'threadgate-record'
|
||||
export const createThreadgateRecordQueryKey = (uri: string) => [
|
||||
threadgateRecordQueryKeyRoot,
|
||||
uri,
|
||||
]
|
||||
|
||||
export function useThreadgateRecordQuery({
|
||||
postUri,
|
||||
initialData,
|
||||
}: {
|
||||
postUri?: string
|
||||
initialData?: AppBskyFeedThreadgate.Record
|
||||
} = {}) {
|
||||
const agent = useAgent()
|
||||
|
||||
return useQuery({
|
||||
enabled: !!postUri,
|
||||
queryKey: createThreadgateRecordQueryKey(postUri || ''),
|
||||
placeholderData: initialData,
|
||||
staleTime: STALE.MINUTES.ONE,
|
||||
async queryFn() {
|
||||
return getThreadgateRecord({
|
||||
agent,
|
||||
postUri: postUri!,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function getThreadgateRecord({
|
||||
agent,
|
||||
postUri,
|
||||
}: {
|
||||
agent: BskyAgent
|
||||
postUri: string
|
||||
}): Promise<AppBskyFeedThreadgate.Record | null> {
|
||||
const urip = new AtUri(postUri)
|
||||
|
||||
if (!urip.host.startsWith('did:')) {
|
||||
const res = await agent.resolveHandle({
|
||||
handle: urip.host,
|
||||
})
|
||||
urip.host = res.data.did
|
||||
}
|
||||
|
||||
try {
|
||||
const {data} = await retry(
|
||||
2,
|
||||
e => {
|
||||
/*
|
||||
* If the record doesn't exist, we want to return null instead of
|
||||
* throwing an error. NB: This will also catch reference errors, such as
|
||||
* a typo in the URI.
|
||||
*/
|
||||
if (e.message.includes(`Could not locate record:`)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
() =>
|
||||
agent.api.com.atproto.repo.getRecord({
|
||||
repo: urip.host,
|
||||
collection: 'app.bsky.feed.threadgate',
|
||||
rkey: urip.rkey,
|
||||
}),
|
||||
)
|
||||
|
||||
if (data.value && AppBskyFeedThreadgate.isRecord(data.value)) {
|
||||
return data.value
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
} catch (e: any) {
|
||||
/*
|
||||
* If the record doesn't exist, we want to return null instead of
|
||||
* throwing an error. NB: This will also catch reference errors, such as
|
||||
* a typo in the URI.
|
||||
*/
|
||||
if (e.message.includes(`Could not locate record:`)) {
|
||||
return null
|
||||
} else {
|
||||
throw new Error(`Failed to get threadgate record`, {cause: e})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeThreadgateRecord({
|
||||
agent,
|
||||
postUri,
|
||||
threadgate,
|
||||
}: {
|
||||
agent: BskyAgent
|
||||
postUri: string
|
||||
threadgate: AppBskyFeedThreadgate.Record
|
||||
}) {
|
||||
const postUrip = new AtUri(postUri)
|
||||
const record = createThreadgateRecord({
|
||||
post: postUri,
|
||||
allow: threadgate.allow, // can/should be undefined!
|
||||
hiddenReplies: threadgate.hiddenReplies || [],
|
||||
})
|
||||
|
||||
await networkRetry(2, () =>
|
||||
agent.api.com.atproto.repo.putRecord({
|
||||
repo: agent.session!.did,
|
||||
collection: 'app.bsky.feed.threadgate',
|
||||
rkey: postUrip.rkey,
|
||||
record,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function upsertThreadgate(
|
||||
{
|
||||
agent,
|
||||
postUri,
|
||||
}: {
|
||||
agent: BskyAgent
|
||||
postUri: string
|
||||
},
|
||||
callback: (
|
||||
threadgate: AppBskyFeedThreadgate.Record | null,
|
||||
) => Promise<AppBskyFeedThreadgate.Record | undefined>,
|
||||
) {
|
||||
const prev = await getThreadgateRecord({
|
||||
agent,
|
||||
postUri,
|
||||
})
|
||||
const next = await callback(prev)
|
||||
if (!next) return
|
||||
await writeThreadgateRecord({
|
||||
agent,
|
||||
postUri,
|
||||
threadgate: next,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the allow list for a threadgate record.
|
||||
*/
|
||||
export async function updateThreadgateAllow({
|
||||
agent,
|
||||
postUri,
|
||||
allow,
|
||||
}: {
|
||||
agent: BskyAgent
|
||||
postUri: string
|
||||
allow: ThreadgateAllowUISetting[]
|
||||
}) {
|
||||
return upsertThreadgate({agent, postUri}, async prev => {
|
||||
if (prev) {
|
||||
return {
|
||||
...prev,
|
||||
allow: threadgateAllowUISettingToAllowRecordValue(allow),
|
||||
}
|
||||
} else {
|
||||
return createThreadgateRecord({
|
||||
post: postUri,
|
||||
allow: threadgateAllowUISettingToAllowRecordValue(allow),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useToggleReplyVisibilityMutation() {
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
postUri,
|
||||
replyUri,
|
||||
action,
|
||||
}: {
|
||||
postUri: string
|
||||
replyUri: string
|
||||
action: 'hide' | 'show'
|
||||
}) => {
|
||||
await upsertThreadgate({agent, postUri}, async prev => {
|
||||
if (prev) {
|
||||
if (action === 'hide') {
|
||||
return mergeThreadgateRecords(prev, {
|
||||
hiddenReplies: [replyUri],
|
||||
})
|
||||
} else if (action === 'show') {
|
||||
return {
|
||||
...prev,
|
||||
hiddenReplies:
|
||||
prev.hiddenReplies?.filter(uri => uri !== replyUri) || [],
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (action === 'hide') {
|
||||
return createThreadgateRecord({
|
||||
post: postUri,
|
||||
hiddenReplies: [replyUri],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [threadgateRecordQueryKeyRoot],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type ThreadgateAllowUISetting =
|
||||
| {type: 'everybody'}
|
||||
| {type: 'nobody'}
|
||||
| {type: 'mention'}
|
||||
| {type: 'following'}
|
||||
| {type: 'list'; list: unknown}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
|
||||
|
||||
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types'
|
||||
|
||||
/**
|
||||
* Converts a full {@link AppBskyFeedThreadgate.Record} to a list of
|
||||
* {@link ThreadgateAllowUISetting}, for use by app UI.
|
||||
*/
|
||||
export function threadgateViewToAllowUISetting(
|
||||
threadgate: AppBskyFeedDefs.ThreadgateView | undefined,
|
||||
): ThreadgateAllowUISetting[] {
|
||||
const record =
|
||||
threadgate &&
|
||||
AppBskyFeedThreadgate.isRecord(threadgate.record) &&
|
||||
AppBskyFeedThreadgate.validateRecord(threadgate.record).success
|
||||
? threadgate.record
|
||||
: null
|
||||
/*
|
||||
* If record doesn't exist (default), or if `record.allow === undefined`, it means
|
||||
* anyone can reply.
|
||||
*
|
||||
* If `record.allow === []` it means no one can reply, and we translate to UI code
|
||||
* here. This was a historical choice, and we have no lexicon representation
|
||||
* for 'replies disabled' other than an empty array.
|
||||
*/
|
||||
if (!record || record.allow === undefined) {
|
||||
return []
|
||||
}
|
||||
if (record.allow.length === 0) {
|
||||
return [{type: 'nobody'}]
|
||||
}
|
||||
|
||||
const settings: ThreadgateAllowUISetting[] = record.allow
|
||||
.map(allow => {
|
||||
let setting: ThreadgateAllowUISetting | undefined
|
||||
if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
|
||||
setting = {type: 'mention'}
|
||||
} else if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
|
||||
setting = {type: 'following'}
|
||||
} else if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
|
||||
setting = {type: 'list', list: allow.list}
|
||||
}
|
||||
return setting
|
||||
})
|
||||
.filter(n => !!n)
|
||||
return settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an array of {@link ThreadgateAllowUISetting} to the `allow` prop on
|
||||
* {@link AppBskyFeedThreadgate.Record}.
|
||||
*
|
||||
* If the `allow` property on the record is undefined, we infer that to mean
|
||||
* that everyone can reply. If it's an empty array, we infer that to mean that
|
||||
* no one can reply.
|
||||
*/
|
||||
export function threadgateAllowUISettingToAllowRecordValue(
|
||||
threadgate: ThreadgateAllowUISetting[],
|
||||
): AppBskyFeedThreadgate.Record['allow'] {
|
||||
if (threadgate.find(v => v.type === 'everybody')) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let allow: (
|
||||
| AppBskyFeedThreadgate.MentionRule
|
||||
| AppBskyFeedThreadgate.FollowingRule
|
||||
| AppBskyFeedThreadgate.ListRule
|
||||
)[] = []
|
||||
|
||||
if (!threadgate.find(v => v.type === 'nobody')) {
|
||||
for (const rule of threadgate) {
|
||||
if (rule.type === 'mention') {
|
||||
allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'})
|
||||
} else if (rule.type === 'following') {
|
||||
allow.push({$type: 'app.bsky.feed.threadgate#followingRule'})
|
||||
} else if (rule.type === 'list') {
|
||||
allow.push({
|
||||
$type: 'app.bsky.feed.threadgate#listRule',
|
||||
list: rule.list,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allow
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges two {@link AppBskyFeedThreadgate.Record} objects, combining their
|
||||
* `allow` and `hiddenReplies` arrays and de-deduplicating them.
|
||||
*
|
||||
* Note: `allow` can be undefined here, be sure you don't accidentally set it
|
||||
* to an empty array. See other comments in this file.
|
||||
*/
|
||||
export function mergeThreadgateRecords(
|
||||
prev: AppBskyFeedThreadgate.Record,
|
||||
next: Partial<AppBskyFeedThreadgate.Record>,
|
||||
): AppBskyFeedThreadgate.Record {
|
||||
// can be undefined if everyone can reply!
|
||||
const allow: AppBskyFeedThreadgate.Record['allow'] | undefined =
|
||||
prev.allow || next.allow
|
||||
? [...(prev.allow || []), ...(next.allow || [])].filter(
|
||||
(v, i, a) => a.findIndex(t => t.$type === v.$type) === i,
|
||||
)
|
||||
: undefined
|
||||
const hiddenReplies = Array.from(
|
||||
new Set([...(prev.hiddenReplies || []), ...(next.hiddenReplies || [])]),
|
||||
)
|
||||
|
||||
return createThreadgateRecord({
|
||||
post: prev.post,
|
||||
allow, // can be undefined!
|
||||
hiddenReplies,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new {@link AppBskyFeedThreadgate.Record} object with the given
|
||||
* properties.
|
||||
*/
|
||||
export function createThreadgateRecord(
|
||||
threadgate: Partial<AppBskyFeedThreadgate.Record>,
|
||||
): AppBskyFeedThreadgate.Record {
|
||||
if (!threadgate.post) {
|
||||
throw new Error('Cannot create a threadgate record without a post URI')
|
||||
}
|
||||
|
||||
return {
|
||||
$type: 'app.bsky.feed.threadgate',
|
||||
post: threadgate.post,
|
||||
createdAt: new Date().toISOString(),
|
||||
allow: threadgate.allow, // can be undefined!
|
||||
hiddenReplies: threadgate.hiddenReplies || [],
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ import {
|
||||
} from '#/state/preferences/languages'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {Gif} from '#/state/queries/tenor'
|
||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
|
||||
import {useUploadVideo} from '#/state/queries/video/video'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
@@ -184,7 +184,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
|
||||
const [extGif, setExtGif] = useState<Gif>()
|
||||
const [labels, setLabels] = useState<string[]>([])
|
||||
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
|
||||
const [threadgate, setThreadgate] = useState<ThreadgateAllowUISetting[]>([])
|
||||
|
||||
const gallery = useMemo(
|
||||
() => new GalleryModel(initImageUris),
|
||||
|
||||
@@ -5,7 +5,7 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
@@ -20,8 +20,8 @@ export function ThreadgateBtn({
|
||||
onChange,
|
||||
style,
|
||||
}: {
|
||||
threadgate: ThreadgateSetting[]
|
||||
onChange: (v: ThreadgateSetting[]) => void
|
||||
threadgate: ThreadgateAllowUISetting[]
|
||||
onChange: (v: ThreadgateAllowUISetting[]) => void
|
||||
style?: StyleProp<AnimatedStyle<ViewStyle>>
|
||||
}) {
|
||||
const {track} = useAnalytics()
|
||||
|
||||
@@ -3,7 +3,11 @@ import {StyleSheet, useWindowDimensions, View} from 'react-native'
|
||||
import {runOnJS} from 'react-native-reanimated'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedThreadgate,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -23,6 +27,7 @@ import {
|
||||
usePostThreadQuery,
|
||||
} from '#/state/queries/post-thread'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useThreadgateRecordQuery} from '#/state/queries/threadgate'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
@@ -113,6 +118,16 @@ export function PostThread({uri}: {uri: string | undefined}) {
|
||||
)
|
||||
const rootPost = thread?.type === 'post' ? thread.post : undefined
|
||||
const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
|
||||
const replyRef =
|
||||
rootPostRecord && AppBskyFeedPost.isRecord(rootPostRecord)
|
||||
? rootPostRecord.reply
|
||||
: undefined
|
||||
const rootPostUri = replyRef ? replyRef.root.uri : rootPost?.uri
|
||||
|
||||
const {data: threadgateRecord} = useThreadgateRecordQuery({
|
||||
postUri: rootPostUri,
|
||||
initialData: rootPost?.threadgate?.record as AppBskyFeedThreadgate.Record,
|
||||
})
|
||||
|
||||
const moderationOpts = useModerationOpts()
|
||||
const isNoPwi = React.useMemo(() => {
|
||||
@@ -175,11 +190,13 @@ export function PostThread({uri}: {uri: string | undefined}) {
|
||||
threadModerationCache,
|
||||
currentDid,
|
||||
justPostedUris,
|
||||
threadgateRecord ?? undefined,
|
||||
),
|
||||
!!currentDid,
|
||||
treeView,
|
||||
threadModerationCache,
|
||||
hiddenRepliesState !== HiddenRepliesState.Hide,
|
||||
threadgateRecord ?? undefined,
|
||||
)
|
||||
}, [
|
||||
thread,
|
||||
@@ -189,6 +206,7 @@ export function PostThread({uri}: {uri: string | undefined}) {
|
||||
threadModerationCache,
|
||||
hiddenRepliesState,
|
||||
justPostedUris,
|
||||
threadgateRecord,
|
||||
])
|
||||
|
||||
const error = React.useMemo(() => {
|
||||
@@ -549,6 +567,7 @@ function createThreadSkeleton(
|
||||
treeView: boolean,
|
||||
modCache: ThreadModerationCache,
|
||||
showHiddenReplies: boolean,
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record,
|
||||
): ThreadSkeletonParts | null {
|
||||
if (!node) return null
|
||||
|
||||
@@ -562,6 +581,7 @@ function createThreadSkeleton(
|
||||
treeView,
|
||||
modCache,
|
||||
showHiddenReplies,
|
||||
threadgateRecord,
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -598,6 +618,7 @@ function* flattenThreadReplies(
|
||||
treeView: boolean,
|
||||
modCache: ThreadModerationCache,
|
||||
showHiddenReplies: boolean,
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record,
|
||||
): Generator<YieldedItem, HiddenReplyType> {
|
||||
if (node.type === 'post') {
|
||||
// dont show pwi-opted-out posts to logged out users
|
||||
@@ -616,6 +637,13 @@ function* flattenThreadReplies(
|
||||
return HiddenReplyType.Hidden
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!showHiddenReplies &&
|
||||
threadgateRecord?.hiddenReplies?.includes(node.post.uri)
|
||||
) {
|
||||
return HiddenReplyType.Hidden
|
||||
}
|
||||
}
|
||||
|
||||
if (!node.ctx.isHighlightedPost) {
|
||||
@@ -631,6 +659,7 @@ function* flattenThreadReplies(
|
||||
treeView,
|
||||
modCache,
|
||||
showHiddenReplies,
|
||||
threadgateRecord,
|
||||
)
|
||||
if (hiddenReply > hiddenReplies) {
|
||||
hiddenReplies = hiddenReply
|
||||
|
||||
@@ -15,6 +15,7 @@ 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 {useThreadgateRecordQuery} from '#/state/queries/threadgate'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {MAX_POST_LINES} from 'lib/constants'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
@@ -29,6 +30,7 @@ import {isWeb} from 'platform/detection'
|
||||
import {useSession} from 'state/session'
|
||||
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {AppModerationCause} from '#/components/Pills'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {ContentHider} from '../../../components/moderation/ContentHider'
|
||||
import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
|
||||
@@ -199,6 +201,21 @@ let PostThreadItemLoaded = ({
|
||||
return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
|
||||
}, [post.uri, post.author])
|
||||
const repostsTitle = _(msg`Reposts of this post`)
|
||||
const {data: threadgateRecord} = useThreadgateRecordQuery({
|
||||
postUri: rootUri,
|
||||
})
|
||||
const isPostHiddenByThreadgate = threadgateRecord?.hiddenReplies?.includes(
|
||||
post.uri,
|
||||
)
|
||||
const additionalPostAlerts: AppModerationCause[] = isPostHiddenByThreadgate
|
||||
? [
|
||||
{
|
||||
type: 'reply-hidden',
|
||||
source: {type: 'user'},
|
||||
priority: 6,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const translatorUrl = getTranslatorLink(
|
||||
record?.text || '',
|
||||
@@ -315,6 +332,7 @@ let PostThreadItemLoaded = ({
|
||||
size="lg"
|
||||
includeMute
|
||||
style={[a.pt_2xs, a.pb_sm]}
|
||||
additionalCauses={additionalPostAlerts}
|
||||
/>
|
||||
{richText?.text ? (
|
||||
<View
|
||||
@@ -392,6 +410,8 @@ let PostThreadItemLoaded = ({
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
logContext="PostThreadItem"
|
||||
rootPostUri={rootUri}
|
||||
threadgateRecord={threadgateRecord ?? undefined}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -512,6 +532,7 @@ let PostThreadItemLoaded = ({
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentList')}
|
||||
style={[a.pt_2xs, a.pb_2xs]}
|
||||
additionalCauses={additionalPostAlerts}
|
||||
/>
|
||||
{richText?.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
@@ -543,6 +564,8 @@ let PostThreadItemLoaded = ({
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
logContext="PostThreadItem"
|
||||
rootPostUri={rootUri}
|
||||
threadgateRecord={threadgateRecord ?? undefined}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as Clipboard from 'expo-clipboard'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedThreadgate,
|
||||
AtUri,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
@@ -31,6 +32,9 @@ import {
|
||||
usePostDeleteMutation,
|
||||
useThreadMuteMutationQueue,
|
||||
} from '#/state/queries/post'
|
||||
import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate'
|
||||
import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util'
|
||||
import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate'
|
||||
import {useSession} from '#/state/session'
|
||||
import {getCurrentRoute} from 'lib/routes/helpers'
|
||||
import {shareUrl} from 'lib/sharing'
|
||||
@@ -50,6 +54,7 @@ import {
|
||||
EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
|
||||
EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
|
||||
} from '#/components/icons/Emoji'
|
||||
import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye'
|
||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
|
||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||
@@ -57,6 +62,7 @@ import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/Pap
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
|
||||
@@ -73,6 +79,8 @@ let PostDropdownBtn = ({
|
||||
hitSlop,
|
||||
size,
|
||||
timestamp,
|
||||
rootPostUri,
|
||||
threadgateRecord,
|
||||
}: {
|
||||
testID: string
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -83,6 +91,8 @@ let PostDropdownBtn = ({
|
||||
hitSlop?: PressableProps['hitSlop']
|
||||
size?: 'lg' | 'md' | 'sm'
|
||||
timestamp: string
|
||||
rootPostUri?: string
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
}): React.ReactNode => {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const theme = useTheme()
|
||||
@@ -104,17 +114,33 @@ let PostDropdownBtn = ({
|
||||
const loggedOutWarningPromptControl = useDialogControl()
|
||||
const embedPostControl = useDialogControl()
|
||||
const sendViaChatControl = useDialogControl()
|
||||
const {mutateAsync: toggleReplyVisibility} =
|
||||
useToggleReplyVisibilityMutation()
|
||||
|
||||
const postUri = post.uri
|
||||
const postCid = post.cid
|
||||
const postAuthor = post.author
|
||||
const quoteEmbed = React.useMemo(() => {
|
||||
if (!currentAccount || !post.embed) return
|
||||
return getMaybeDetachedQuoteEmbed({
|
||||
viewerDid: currentAccount?.did,
|
||||
post,
|
||||
})
|
||||
}, [post, currentAccount])
|
||||
|
||||
const rootUri = record.reply?.root?.uri || postUri
|
||||
const isReply = Boolean(record.reply)
|
||||
const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue(
|
||||
post,
|
||||
rootUri,
|
||||
)
|
||||
const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
|
||||
const isAuthor = postAuthor.did === currentAccount?.did
|
||||
const isReplyHiddenByThreadgate =
|
||||
threadgateRecord?.hiddenReplies?.includes(postUri)
|
||||
|
||||
const {mutateAsync: toggleQuoteDetachment, isPending} =
|
||||
useToggleQuoteDetachmentMutation()
|
||||
|
||||
const href = React.useMemo(() => {
|
||||
const urip = new AtUri(postUri)
|
||||
@@ -242,7 +268,62 @@ let PostDropdownBtn = ({
|
||||
[navigation, postUri],
|
||||
)
|
||||
|
||||
const onToggleReplyVisibility = React.useCallback(async () => {
|
||||
// TODO no threadgate?
|
||||
if (!rootPostUri) return
|
||||
|
||||
const action = isReplyHiddenByThreadgate ? 'show' : 'hide'
|
||||
const isHide = action === 'hide'
|
||||
|
||||
try {
|
||||
await toggleReplyVisibility({
|
||||
postUri: rootPostUri,
|
||||
replyUri: postUri,
|
||||
action,
|
||||
})
|
||||
Toast.show(
|
||||
isHide
|
||||
? _(msg`Reply was successfully hidden`)
|
||||
: _(msg`Reply visibility updated`),
|
||||
)
|
||||
} catch (e: any) {
|
||||
Toast.show(_(msg`Updating reply visibility failed`))
|
||||
logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
|
||||
}
|
||||
}, [
|
||||
_,
|
||||
isReplyHiddenByThreadgate,
|
||||
rootPostUri,
|
||||
postUri,
|
||||
toggleReplyVisibility,
|
||||
])
|
||||
|
||||
const onToggleQuotePostAttachment = React.useCallback(async () => {
|
||||
if (!quoteEmbed) return
|
||||
|
||||
const action = quoteEmbed.isDetached ? 'reattach' : 'detach'
|
||||
const isDetach = action === 'detach'
|
||||
|
||||
try {
|
||||
await toggleQuoteDetachment({
|
||||
post,
|
||||
quoteUri: quoteEmbed.uri,
|
||||
action: quoteEmbed.isDetached ? 'reattach' : 'detach',
|
||||
})
|
||||
Toast.show(
|
||||
isDetach
|
||||
? _(msg`Quote post was successfully detached`)
|
||||
: _(msg`Quote post was re-attached`),
|
||||
)
|
||||
} catch (e: any) {
|
||||
Toast.show(_(msg`Updating quote attachment failed`))
|
||||
logger.error(`Failed to ${action} quote`, {safeMessage: e.message})
|
||||
}
|
||||
}, [_, quoteEmbed, post, toggleQuoteDetachment])
|
||||
|
||||
const canEmbed = isWeb && gtMobile && !hideInPWI
|
||||
const canHideReply = !isAuthor && !isPostHidden && rootPostUri && isReply
|
||||
const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer
|
||||
|
||||
return (
|
||||
<EventStopper onKeyDown={false}>
|
||||
@@ -397,6 +478,62 @@ let PostDropdownBtn = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasSession && (canHideReply || canDetachQuote) && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Group>
|
||||
{canHideReply && (
|
||||
<Menu.Item
|
||||
testID="postDropdownHideBtn"
|
||||
label={
|
||||
isReplyHiddenByThreadgate
|
||||
? _(msg`Show reply for everyone`)
|
||||
: _(msg`Hide reply for everyone`)
|
||||
}
|
||||
onPress={onToggleReplyVisibility}>
|
||||
<Menu.ItemText>
|
||||
{isReplyHiddenByThreadgate
|
||||
? _(msg`Show reply for everyone`)
|
||||
: _(msg`Hide reply for everyone`)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={isReplyHiddenByThreadgate ? Eye : EyeSlash}
|
||||
position="right"
|
||||
/>
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{canDetachQuote && (
|
||||
<Menu.Item
|
||||
disabled={isPending}
|
||||
testID="postDropdownHideBtn"
|
||||
label={
|
||||
quoteEmbed.isDetached
|
||||
? _(msg`Re-attach quote`)
|
||||
: _(msg`Detach quote`)
|
||||
}
|
||||
onPress={onToggleQuotePostAttachment}>
|
||||
<Menu.ItemText>
|
||||
{quoteEmbed.isDetached
|
||||
? _(msg`Re-attach quote`)
|
||||
: _(msg`Detach quote`)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon
|
||||
icon={
|
||||
isPending
|
||||
? Loader
|
||||
: quoteEmbed.isDetached
|
||||
? Eye
|
||||
: EyeSlash
|
||||
}
|
||||
position="right"
|
||||
/>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasSession && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as Clipboard from 'expo-clipboard'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedThreadgate,
|
||||
AtUri,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
@@ -59,6 +60,8 @@ let PostCtrls = ({
|
||||
style,
|
||||
onPressReply,
|
||||
logContext,
|
||||
rootPostUri,
|
||||
threadgateRecord,
|
||||
}: {
|
||||
big?: boolean
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -68,6 +71,8 @@ let PostCtrls = ({
|
||||
style?: StyleProp<ViewStyle>
|
||||
onPressReply: () => void
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||
rootPostUri?: string
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
@@ -338,6 +343,8 @@ let PostCtrls = ({
|
||||
style={{padding: 5}}
|
||||
hitSlop={POST_CTRL_HITSLOP}
|
||||
timestamp={post.indexedAt}
|
||||
rootPostUri={rootPostUri}
|
||||
threadgateRecord={threadgateRecord}
|
||||
/>
|
||||
</View>
|
||||
{gate('debug_show_feedcontext') && feedContext && (
|
||||
|
||||
@@ -26,6 +26,7 @@ import {useQueryClient} from '@tanstack/react-query'
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {s} from '#/lib/styles'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useSession} from '#/state/session'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {InfoCircleIcon} from 'lib/icons'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
@@ -52,6 +53,7 @@ export function MaybeQuoteEmbed({
|
||||
allowNestedQuotes?: boolean
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {currentAccount} = useSession()
|
||||
if (
|
||||
AppBskyEmbedRecord.isViewRecord(embed.record) &&
|
||||
AppBskyFeedPost.isRecord(embed.record.value) &&
|
||||
@@ -84,6 +86,22 @@ export function MaybeQuoteEmbed({
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
} else if (AppBskyEmbedRecord.isViewRemoved(embed.record)) {
|
||||
const isViewerOwner = currentAccount?.did
|
||||
? embed.record.uri.includes(currentAccount.did)
|
||||
: false
|
||||
return (
|
||||
<View style={[styles.errorContainer, pal.borderDark]}>
|
||||
<InfoCircleIcon size={18} style={pal.text} />
|
||||
<Text type="lg" style={pal.text}>
|
||||
{isViewerOwner ? (
|
||||
<Trans>Removed by you</Trans>
|
||||
) : (
|
||||
<Trans>Removed by author</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user