diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts
index cdf535dec2..720495ea1d 100644
--- a/src/lib/analytics/types.ts
+++ b/src/lib/analytics/types.ts
@@ -32,6 +32,8 @@ export type TrackPropertiesMap = {
'Post:ThreadMute': {} // CAN BE SERVER
'Post:ThreadUnmute': {} // CAN BE SERVER
'Post:Reply': {} // CAN BE SERVER
+ 'Post:EditThreadgateOpened': {}
+ 'Post:ThreadgateEdited': {}
// PROFILE events
'Profile:Follow': {
username: string
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index dfaae2e018..5b1c998cb8 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -270,7 +270,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
return res
}
-async function createThreadgate(
+export async function createThreadgate(
agent: BskyAgent,
postUri: string,
threadgate: ThreadgateSetting[],
@@ -296,10 +296,17 @@ async function createThreadgate(
}
const postUrip = new AtUri(postUri)
- await agent.api.app.bsky.feed.threadgate.create(
- {repo: agent.session!.did, rkey: postUrip.rkey},
- {post: postUri, createdAt: new Date().toISOString(), allow},
- )
+ 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
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index ced14335bd..685b10bd85 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -70,7 +70,8 @@ export interface SelfLabelModal {
export interface ThreadgateModal {
name: 'threadgate'
settings: ThreadgateSetting[]
- onChange: (settings: ThreadgateSetting[]) => void
+ onChange?: (settings: ThreadgateSetting[]) => void
+ onConfirm?: (settings: ThreadgateSetting[]) => void
}
export interface ChangeHandleModal {
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index f7e5e2ecba..db85e8a177 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -32,7 +32,7 @@ import {
} from './util'
const REPLY_TREE_DEPTH = 10
-const RQKEY_ROOT = 'post-thread'
+export const RQKEY_ROOT = 'post-thread'
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
diff --git a/src/state/queries/threadgate.ts b/src/state/queries/threadgate.ts
index 4891175825..67c6f8c084 100644
--- a/src/state/queries/threadgate.ts
+++ b/src/state/queries/threadgate.ts
@@ -1,5 +1,38 @@
+import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
+
export type ThreadgateSetting =
| {type: 'nobody'}
| {type: 'mention'}
| {type: 'following'}
| {type: 'list'; list: string}
+
+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'}]
+ }
+ return record.allow
+ .map(allow => {
+ if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
+ return {type: 'mention'}
+ }
+ if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
+ return {type: 'following'}
+ }
+ if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
+ return {type: 'list', list: allow.list}
+ }
+ return undefined
+ })
+ .filter(Boolean) as ThreadgateSetting[]
+}
diff --git a/src/view/com/modals/Threadgate.tsx b/src/view/com/modals/Threadgate.tsx
index a2e9f391c0..4a9a9e2ab5 100644
--- a/src/view/com/modals/Threadgate.tsx
+++ b/src/view/com/modals/Threadgate.tsx
@@ -26,9 +26,11 @@ export const snapPoints = ['60%']
export function Component({
settings,
onChange,
+ onConfirm,
}: {
settings: ThreadgateSetting[]
- onChange: (settings: ThreadgateSetting[]) => void
+ onChange?: (settings: ThreadgateSetting[]) => void
+ onConfirm?: (settings: ThreadgateSetting[]) => void
}) {
const pal = usePalette('default')
const {closeModal} = useModalControls()
@@ -38,12 +40,12 @@ export function Component({
const onPressEverybody = () => {
setSelected([])
- onChange([])
+ onChange?.([])
}
const onPressNobody = () => {
setSelected([{type: 'nobody'}])
- onChange([{type: 'nobody'}])
+ onChange?.([{type: 'nobody'}])
}
const onPressAudience = (setting: ThreadgateSetting) => {
@@ -57,7 +59,7 @@ export function Component({
newSelected.splice(i, 1)
}
setSelected(newSelected)
- onChange(newSelected)
+ onChange?.(newSelected)
}
return (
@@ -124,6 +126,7 @@ export function Component({
testID="confirmBtn"
onPress={() => {
closeModal()
+ onConfirm?.(selected)
}}
style={styles.btn}
accessibilityRole="button"
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 5ee60e4eab..6d03029d7f 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -25,7 +25,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
import {countLines} from 'lib/strings/helpers'
import {niceDate} from 'lib/strings/time'
import {s} from 'lib/styles'
-import {isWeb} from 'platform/detection'
+import {isNative, isWeb} from 'platform/detection'
import {useSession} from 'state/session'
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
import {atoms as a} from '#/alf'
@@ -189,6 +189,7 @@ let PostThreadItemLoaded = ({
const itemTitle = _(msg`Post by ${post.author.handle}`)
const authorHref = makeProfileLink(post.author)
const authorTitle = post.author.handle
+ const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
const likesHref = React.useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
@@ -395,7 +396,11 @@ let PostThreadItemLoaded = ({
-
+
>
)
} else {
@@ -578,7 +583,9 @@ let PostThreadItemLoaded = ({
post={post}
style={{
marginTop: 4,
+ borderBottomWidth: 1,
}}
+ isThreadAuthor={isThreadAuthor}
/>
>
)
@@ -681,6 +688,20 @@ function ExpandedPostDetails({
)
}
+function getThreadAuthor(
+ post: AppBskyFeedDefs.PostView,
+ record: AppBskyFeedPost.Record,
+): string {
+ if (!record.reply) {
+ return post.author.did
+ }
+ try {
+ return new AtUri(record.reply.root.uri).host
+ } catch {
+ return ''
+ }
+}
+
const styles = StyleSheet.create({
outer: {
borderTopWidth: hairlineWidth,
diff --git a/src/view/com/threadgate/WhoCanReply.tsx b/src/view/com/threadgate/WhoCanReply.tsx
index c1e36d481d..3ffbaa7ae9 100644
--- a/src/view/com/threadgate/WhoCanReply.tsx
+++ b/src/view/com/threadgate/WhoCanReply.tsx
@@ -1,128 +1,172 @@
import React from 'react'
-import {StyleProp, View, ViewStyle} from 'react-native'
-import {
- AppBskyFeedDefs,
- AppBskyFeedThreadgate,
- AppBskyGraphDefs,
- AtUri,
-} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {Trans} from '@lingui/macro'
+import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
+import {AppBskyFeedDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useQueryClient} from '@tanstack/react-query'
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {createThreadgate} from '#/lib/api'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {usePalette} from '#/lib/hooks/usePalette'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {colors} from '#/lib/styles'
+import {logger} from '#/logger'
+import {isNative} from '#/platform/detection'
+import {useModalControls} from '#/state/modals'
+import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
+import {
+ ThreadgateSetting,
+ threadgateViewToSettings,
+} from '#/state/queries/threadgate'
+import {useAgent} from '#/state/session'
+import * as Toast from 'view/com/util/Toast'
+import {Button} from '#/components/Button'
import {TextLink} from '../util/Link'
import {Text} from '../util/text/Text'
export function WhoCanReply({
post,
+ isThreadAuthor,
style,
}: {
post: AppBskyFeedDefs.PostView
+ isThreadAuthor: boolean
style?: StyleProp
}) {
+ const {track} = useAnalytics()
+ const {_} = useLingui()
const pal = usePalette('default')
- const {isMobile} = useWebMediaQueries()
+ const agent = useAgent()
+ const queryClient = useQueryClient()
+ const {openModal} = useModalControls()
const containerStyles = useColorSchemeStyle(
{
- borderColor: pal.colors.unreadNotifBorder,
backgroundColor: pal.colors.unreadNotifBg,
},
{
- borderColor: pal.colors.unreadNotifBorder,
backgroundColor: pal.colors.unreadNotifBg,
},
)
- const iconStyles = useColorSchemeStyle(
- {
- backgroundColor: colors.blue3,
- },
- {
- backgroundColor: colors.blue3,
- },
- )
const textStyles = useColorSchemeStyle(
- {color: colors.gray7},
+ {color: colors.blue5},
{color: colors.blue1},
)
- const record = React.useMemo(
- () =>
- post.threadgate &&
- AppBskyFeedThreadgate.isRecord(post.threadgate.record) &&
- AppBskyFeedThreadgate.validateRecord(post.threadgate.record).success
- ? post.threadgate.record
- : null,
+ const hoverStyles = useColorSchemeStyle(
+ {
+ backgroundColor: colors.white,
+ },
+ {
+ backgroundColor: pal.colors.background,
+ },
+ )
+ const settings = React.useMemo(
+ () => threadgateViewToSettings(post.threadgate),
[post],
)
- if (record) {
- return (
-
-
-
-
-
-
- {!record.allow?.length ? (
- Replies to this thread are disabled
- ) : (
-
- Only{' '}
- {record.allow.map((rule, i) => (
- <>
-
-
- >
- ))}{' '}
- can reply.
-
- )}
-
-
-
- )
+ const isRootPost = !('reply' in post.record)
+
+ const onPressEdit = () => {
+ track('Post:EditThreadgateOpened')
+ if (isNative && Keyboard.isVisible()) {
+ Keyboard.dismiss()
+ }
+ openModal({
+ name: 'threadgate',
+ settings,
+ async onConfirm(newSettings: ThreadgateSetting[]) {
+ 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,
+ })
+ }
+ Toast.show('Thread settings updated')
+ queryClient.invalidateQueries({
+ queryKey: [POST_THREAD_RQKEY_ROOT],
+ })
+ track('Post:ThreadgateEdited')
+ } catch (err) {
+ Toast.show(
+ 'There was an issue. Please check your internet connection and try again.',
+ )
+ logger.error('Failed to edit threadgate', {message: err})
+ }
+ },
+ })
}
- return null
+
+ if (!isRootPost) {
+ return null
+ }
+ if (!settings.length && !isThreadAuthor) {
+ return null
+ }
+
+ return (
+
+
+
+ {!settings.length ? (
+ Everybody can reply.
+ ) : settings[0].type === 'nobody' ? (
+ Replies to this thread are disabled.
+ ) : (
+
+ Only{' '}
+ {settings.map((rule, i) => (
+ <>
+
+
+ >
+ ))}{' '}
+ can reply.
+
+ )}
+
+
+ {isThreadAuthor && (
+
+
+
+ )}
+
+ )
}
function Rule({
@@ -130,15 +174,15 @@ function Rule({
post,
lists,
}: {
- rule: any
+ rule: ThreadgateSetting
post: AppBskyFeedDefs.PostView
lists: AppBskyGraphDefs.ListViewBasic[] | undefined
}) {
const pal = usePalette('default')
- if (AppBskyFeedThreadgate.isMentionRule(rule)) {
+ if (rule.type === 'mention') {
return mentioned users
}
- if (AppBskyFeedThreadgate.isFollowingRule(rule)) {
+ if (rule.type === 'following') {
return (
users followed by{' '}
@@ -151,7 +195,7 @@ function Rule({
)
}
- if (AppBskyFeedThreadgate.isListRule(rule)) {
+ if (rule.type === 'list') {
const list = lists?.find(l => l.uri === rule.list)
if (list) {
const listUrip = new AtUri(list.uri)