Composer - replace threadgate modal with alf dialog (#4329)
* replace threadgate modal with alf dialog * add accessibility to selectable * add aria * hide spinner once fetched * add `hasOpenDialogs` value to context * remove state * Rm loading state * Update the threadgate dialog button theming * Factor out the threadgate editor and add editing to post views * Mark messages for localization * Use colors from mute dialog * Remove unnecessary effect * Reset state on dialog dismiss * Clearer CTA * Fix bugs * Scope keyboard fix * Rm getAreDialogsActive (no longer needed) --------- Co-authored-by: Dan Abramov <dan.abramov@gmail.com> Co-authored-by: Paul Frazee <pfrazee@gmail.com>zio/stable
parent
e0ac7d5bdc
commit
29aaf09a8b
|
@ -88,7 +88,10 @@ export function Outer({
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
|
|
||||||
function handler(e: KeyboardEvent) {
|
function handler(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') close()
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation()
|
||||||
|
close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', handler)
|
document.addEventListener('keydown', handler)
|
||||||
|
|
|
@ -17,7 +17,6 @@ import {HITSLOP_10} from '#/lib/constants'
|
||||||
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
|
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {useModalControls} from '#/state/modals'
|
|
||||||
import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
|
import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
|
||||||
import {
|
import {
|
||||||
ThreadgateSetting,
|
ThreadgateSetting,
|
||||||
|
@ -34,6 +33,7 @@ import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
|
||||||
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
|
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {TextLink} from '../view/com/util/Link'
|
import {TextLink} from '../view/com/util/Link'
|
||||||
|
import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor'
|
||||||
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
|
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
|
||||||
|
|
||||||
interface WhoCanReplyProps {
|
interface WhoCanReplyProps {
|
||||||
|
@ -46,7 +46,15 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const infoDialogControl = useDialogControl()
|
const infoDialogControl = useDialogControl()
|
||||||
const {settings, isRootPost, onPressEdit} = useWhoCanReply(post)
|
const editDialogControl = useDialogControl()
|
||||||
|
const agent = useAgent()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const settings = React.useMemo(
|
||||||
|
() => threadgateViewToSettings(post.threadgate),
|
||||||
|
[post],
|
||||||
|
)
|
||||||
|
const isRootPost = !('reply' in post.record)
|
||||||
|
|
||||||
if (!isRootPost) {
|
if (!isRootPost) {
|
||||||
return null
|
return null
|
||||||
|
@ -63,6 +71,55 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||||
? _(msg`Replies disabled`)
|
? _(msg`Replies disabled`)
|
||||||
: _(msg`Some people can reply`)
|
: _(msg`Some people can reply`)
|
||||||
|
|
||||||
|
const onPressEdit = () => {
|
||||||
|
if (isNative && Keyboard.isVisible()) {
|
||||||
|
Keyboard.dismiss()
|
||||||
|
}
|
||||||
|
if (isThreadAuthor) {
|
||||||
|
editDialogControl.open()
|
||||||
|
} else {
|
||||||
|
infoDialogControl.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEditConfirm = async (newSettings: ThreadgateSetting[]) => {
|
||||||
|
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 whenAppViewReady(agent, post.uri, res => {
|
||||||
|
const thread = res.data.thread
|
||||||
|
if (AppBskyFeedDefs.isThreadViewPost(thread)) {
|
||||||
|
const fetchedSettings = threadgateViewToSettings(
|
||||||
|
thread.post.threadgate,
|
||||||
|
)
|
||||||
|
return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
Toast.show(_(msg`Thread settings updated`))
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [POST_THREAD_RQKEY_ROOT],
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
Toast.show(
|
||||||
|
_(
|
||||||
|
msg`There was an issue. Please check your internet connection and try again.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.error('Failed to edit threadgate', {message: err})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
@ -93,7 +150,18 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<WhoCanReplyDialog control={infoDialogControl} post={post} />
|
<WhoCanReplyDialog
|
||||||
|
control={infoDialogControl}
|
||||||
|
post={post}
|
||||||
|
settings={settings}
|
||||||
|
/>
|
||||||
|
{isThreadAuthor && (
|
||||||
|
<ThreadgateEditorDialog
|
||||||
|
control={editDialogControl}
|
||||||
|
threadgate={settings}
|
||||||
|
onConfirm={onEditConfirm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -113,24 +181,31 @@ function Icon({
|
||||||
return <IconComponent fill={color} width={width} />
|
return <IconComponent fill={color} width={width} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WhoCanReplyDialog({
|
function WhoCanReplyDialog({
|
||||||
control,
|
control,
|
||||||
post,
|
post,
|
||||||
|
settings,
|
||||||
}: {
|
}: {
|
||||||
control: Dialog.DialogControlProps
|
control: Dialog.DialogControlProps
|
||||||
post: AppBskyFeedDefs.PostView
|
post: AppBskyFeedDefs.PostView
|
||||||
|
settings: ThreadgateSetting[]
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog.Outer control={control}>
|
<Dialog.Outer control={control}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
<WhoCanReplyDialogInner post={post} />
|
<WhoCanReplyDialogInner post={post} settings={settings} />
|
||||||
</Dialog.Outer>
|
</Dialog.Outer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function WhoCanReplyDialogInner({post}: {post: AppBskyFeedDefs.PostView}) {
|
function WhoCanReplyDialogInner({
|
||||||
|
post,
|
||||||
|
settings,
|
||||||
|
}: {
|
||||||
|
post: AppBskyFeedDefs.PostView
|
||||||
|
settings: ThreadgateSetting[]
|
||||||
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {settings} = useWhoCanReply(post)
|
|
||||||
return (
|
return (
|
||||||
<Dialog.ScrollableInner
|
<Dialog.ScrollableInner
|
||||||
label={_(msg`Who can reply dialog`)}
|
label={_(msg`Who can reply dialog`)}
|
||||||
|
@ -245,67 +320,6 @@ function Separator({i, length}: {i: number; length: number}) {
|
||||||
return <>, </>
|
return <>, </>
|
||||||
}
|
}
|
||||||
|
|
||||||
function useWhoCanReply(post: AppBskyFeedDefs.PostView) {
|
|
||||||
const agent = useAgent()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const {openModal} = useModalControls()
|
|
||||||
|
|
||||||
const settings = React.useMemo(
|
|
||||||
() => threadgateViewToSettings(post.threadgate),
|
|
||||||
[post],
|
|
||||||
)
|
|
||||||
const isRootPost = !('reply' in post.record)
|
|
||||||
|
|
||||||
const onPressEdit = () => {
|
|
||||||
if (isNative && Keyboard.isVisible()) {
|
|
||||||
Keyboard.dismiss()
|
|
||||||
}
|
|
||||||
openModal({
|
|
||||||
name: 'threadgate',
|
|
||||||
settings,
|
|
||||||
async onConfirm(newSettings: ThreadgateSetting[]) {
|
|
||||||
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 whenAppViewReady(agent, post.uri, res => {
|
|
||||||
const thread = res.data.thread
|
|
||||||
if (AppBskyFeedDefs.isThreadViewPost(thread)) {
|
|
||||||
const fetchedSettings = threadgateViewToSettings(
|
|
||||||
thread.post.threadgate,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
Toast.show('Thread settings updated')
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: [POST_THREAD_RQKEY_ROOT],
|
|
||||||
})
|
|
||||||
} 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 {settings, isRootPost, onPressEdit}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function whenAppViewReady(
|
async function whenAppViewReady(
|
||||||
agent: BskyAgent,
|
agent: BskyAgent,
|
||||||
uri: string,
|
uri: string,
|
||||||
|
|
|
@ -113,6 +113,7 @@ export function EmbedConsentDialog({
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
<Dialog.Close />
|
||||||
</Dialog.ScrollableInner>
|
</Dialog.ScrollableInner>
|
||||||
</Dialog.Outer>
|
</Dialog.Outer>
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,218 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import isEqual from 'lodash.isequal'
|
||||||
|
|
||||||
|
import {useMyListsQuery} from '#/state/queries/my-lists'
|
||||||
|
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
interface ThreadgateEditorDialogProps {
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
threadgate: ThreadgateSetting[]
|
||||||
|
onChange?: (v: ThreadgateSetting[]) => void
|
||||||
|
onConfirm?: (v: ThreadgateSetting[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThreadgateEditorDialog({
|
||||||
|
control,
|
||||||
|
threadgate,
|
||||||
|
onChange,
|
||||||
|
onConfirm,
|
||||||
|
}: ThreadgateEditorDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<DialogContent
|
||||||
|
seedThreadgate={threadgate}
|
||||||
|
onChange={onChange}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
seedThreadgate,
|
||||||
|
onChange,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
seedThreadgate: ThreadgateSetting[]
|
||||||
|
onChange?: (v: ThreadgateSetting[]) => void
|
||||||
|
onConfirm?: (v: ThreadgateSetting[]) => void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const control = Dialog.useDialogContext()
|
||||||
|
const {data: lists} = useMyListsQuery('curate')
|
||||||
|
const [draft, setDraft] = React.useState(seedThreadgate)
|
||||||
|
|
||||||
|
const [prevSeedThreadgate, setPrevSeedThreadgate] =
|
||||||
|
React.useState(seedThreadgate)
|
||||||
|
if (seedThreadgate !== prevSeedThreadgate) {
|
||||||
|
// New data flowed from above (e.g. due to update coming through).
|
||||||
|
setPrevSeedThreadgate(seedThreadgate)
|
||||||
|
setDraft(seedThreadgate) // Reset draft.
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateThreadgate(nextThreadgate: ThreadgateSetting[]) {
|
||||||
|
setDraft(nextThreadgate)
|
||||||
|
onChange?.(nextThreadgate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPressEverybody = () => {
|
||||||
|
updateThreadgate([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPressNobody = () => {
|
||||||
|
updateThreadgate([{type: 'nobody'}])
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPressAudience = (setting: ThreadgateSetting) => {
|
||||||
|
// remove nobody
|
||||||
|
let newSelected = draft.filter(v => v.type !== 'nobody')
|
||||||
|
// toggle
|
||||||
|
const i = newSelected.findIndex(v => isEqual(v, setting))
|
||||||
|
if (i === -1) {
|
||||||
|
newSelected.push(setting)
|
||||||
|
} else {
|
||||||
|
newSelected.splice(i, 1)
|
||||||
|
}
|
||||||
|
updateThreadgate(newSelected)
|
||||||
|
}
|
||||||
|
|
||||||
|
const doneLabel = onConfirm ? _(msg`Save`) : _(msg`Done`)
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
label={_(msg`Choose who can reply`)}
|
||||||
|
style={[{maxWidth: 500}, a.w_full]}>
|
||||||
|
<View style={[a.flex_1, a.gap_md]}>
|
||||||
|
<Text style={[a.text_2xl, a.font_bold]}>
|
||||||
|
<Trans>Chose who can reply</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={a.mt_xs}>
|
||||||
|
<Trans>Either choose "Everybody" or "Nobody"</Trans>
|
||||||
|
</Text>
|
||||||
|
<View style={[a.flex_row, a.gap_sm]}>
|
||||||
|
<Selectable
|
||||||
|
label={_(msg`Everybody`)}
|
||||||
|
isSelected={draft.length === 0}
|
||||||
|
onPress={onPressEverybody}
|
||||||
|
style={{flex: 1}}
|
||||||
|
/>
|
||||||
|
<Selectable
|
||||||
|
label={_(msg`Nobody`)}
|
||||||
|
isSelected={!!draft.find(v => v.type === 'nobody')}
|
||||||
|
onPress={onPressNobody}
|
||||||
|
style={{flex: 1}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={a.mt_md}>
|
||||||
|
<Trans>Or combine these options:</Trans>
|
||||||
|
</Text>
|
||||||
|
<View style={[a.gap_sm]}>
|
||||||
|
<Selectable
|
||||||
|
label={_(msg`Mentioned users`)}
|
||||||
|
isSelected={!!draft.find(v => v.type === 'mention')}
|
||||||
|
onPress={() => onPressAudience({type: 'mention'})}
|
||||||
|
/>
|
||||||
|
<Selectable
|
||||||
|
label={_(msg`Followed users`)}
|
||||||
|
isSelected={!!draft.find(v => v.type === 'following')}
|
||||||
|
onPress={() => onPressAudience({type: 'following'})}
|
||||||
|
/>
|
||||||
|
{lists && lists.length > 0
|
||||||
|
? lists.map(list => (
|
||||||
|
<Selectable
|
||||||
|
key={list.uri}
|
||||||
|
label={_(msg`Users in "${list.name}"`)}
|
||||||
|
isSelected={
|
||||||
|
!!draft.find(v => v.type === 'list' && v.list === list.uri)
|
||||||
|
}
|
||||||
|
onPress={() =>
|
||||||
|
onPressAudience({type: 'list', list: list.uri})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: // No loading states to avoid jumps for the common case (no lists)
|
||||||
|
null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
label={doneLabel}
|
||||||
|
onPress={() => {
|
||||||
|
control.close()
|
||||||
|
onConfirm?.(draft)
|
||||||
|
}}
|
||||||
|
onAccessibilityEscape={control.close}
|
||||||
|
color="primary"
|
||||||
|
size="medium"
|
||||||
|
variant="solid"
|
||||||
|
style={a.mt_xl}>
|
||||||
|
<ButtonText>{doneLabel}</ButtonText>
|
||||||
|
</Button>
|
||||||
|
<Dialog.Close />
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Selectable({
|
||||||
|
label,
|
||||||
|
isSelected,
|
||||||
|
onPress,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
isSelected: boolean
|
||||||
|
onPress: () => void
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onPress={onPress}
|
||||||
|
label={label}
|
||||||
|
accessibilityHint="Select this option"
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
aria-checked={isSelected}
|
||||||
|
accessibilityState={{
|
||||||
|
checked: isSelected,
|
||||||
|
}}
|
||||||
|
style={a.flex_1}>
|
||||||
|
{({hovered, focused}) => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.p_md,
|
||||||
|
{height: 40}, // for consistency with checkmark icon visible or not
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
(hovered || focused) && t.atoms.bg_contrast_100,
|
||||||
|
isSelected && {
|
||||||
|
backgroundColor:
|
||||||
|
t.name === 'light'
|
||||||
|
? t.palette.primary_50
|
||||||
|
: t.palette.primary_975,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
<Text style={[a.text_sm, isSelected && a.font_semibold]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{isSelected ? (
|
||||||
|
<Check size="sm" fill={t.palette.primary_500} />
|
||||||
|
) : (
|
||||||
|
<View />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
|
@ -5,7 +5,6 @@ import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {GalleryModel} from '#/state/models/media/gallery'
|
import {GalleryModel} from '#/state/models/media/gallery'
|
||||||
import {ImageModel} from '#/state/models/media/image'
|
import {ImageModel} from '#/state/models/media/image'
|
||||||
import {ThreadgateSetting} from '../queries/threadgate'
|
|
||||||
|
|
||||||
export interface EditProfileModal {
|
export interface EditProfileModal {
|
||||||
name: 'edit-profile'
|
name: 'edit-profile'
|
||||||
|
@ -67,13 +66,6 @@ export interface SelfLabelModal {
|
||||||
onChange: (labels: string[]) => void
|
onChange: (labels: string[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThreadgateModal {
|
|
||||||
name: 'threadgate'
|
|
||||||
settings: ThreadgateSetting[]
|
|
||||||
onChange?: (settings: ThreadgateSetting[]) => void
|
|
||||||
onConfirm?: (settings: ThreadgateSetting[]) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChangeHandleModal {
|
export interface ChangeHandleModal {
|
||||||
name: 'change-handle'
|
name: 'change-handle'
|
||||||
onChanged: () => void
|
onChanged: () => void
|
||||||
|
@ -149,7 +141,6 @@ export type Modal =
|
||||||
| CropImageModal
|
| CropImageModal
|
||||||
| EditImageModal
|
| EditImageModal
|
||||||
| SelfLabelModal
|
| SelfLabelModal
|
||||||
| ThreadgateModal
|
|
||||||
|
|
||||||
// Bluesky access
|
// Bluesky access
|
||||||
| WaitlistModal
|
| WaitlistModal
|
||||||
|
|
|
@ -5,11 +5,12 @@ import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {useModalControls} from '#/state/modals'
|
|
||||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {ThreadgateEditorDialog} from '#/components/dialogs/ThreadgateEditor'
|
||||||
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
|
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
|
||||||
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
|
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
|
||||||
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
|
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
|
||||||
|
@ -26,18 +27,15 @@ export function ThreadgateBtn({
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {openModal} = useModalControls()
|
const control = Dialog.useDialogControl()
|
||||||
|
|
||||||
const onPress = () => {
|
const onPress = () => {
|
||||||
track('Composer:ThreadgateOpened')
|
track('Composer:ThreadgateOpened')
|
||||||
if (isNative && Keyboard.isVisible()) {
|
if (isNative && Keyboard.isVisible()) {
|
||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
}
|
}
|
||||||
openModal({
|
|
||||||
name: 'threadgate',
|
control.open()
|
||||||
settings: threadgate,
|
|
||||||
onChange,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEverybody = threadgate.length === 0
|
const isEverybody = threadgate.length === 0
|
||||||
|
@ -49,6 +47,7 @@ export function ThreadgateBtn({
|
||||||
: _(msg`Some people can reply`)
|
: _(msg`Some people can reply`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Animated.View style={[a.flex_row, a.p_sm, t.atoms.bg, style]}>
|
<Animated.View style={[a.flex_row, a.p_sm, t.atoms.bg, style]}>
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
|
@ -56,12 +55,21 @@ export function ThreadgateBtn({
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
testID="openReplyGateButton"
|
testID="openReplyGateButton"
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
label={label}>
|
label={label}
|
||||||
|
accessibilityHint={_(
|
||||||
|
msg`Opens a dialog to choose who can reply to this thread`,
|
||||||
|
)}>
|
||||||
<ButtonIcon
|
<ButtonIcon
|
||||||
icon={isEverybody ? Earth : isNobody ? CircleBanSign : Group}
|
icon={isEverybody ? Earth : isNobody ? CircleBanSign : Group}
|
||||||
/>
|
/>
|
||||||
<ButtonText>{label}</ButtonText>
|
<ButtonText>{label}</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
<ThreadgateEditorDialog
|
||||||
|
control={control}
|
||||||
|
threadgate={threadgate}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,6 @@ import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettin
|
||||||
import * as LinkWarningModal from './LinkWarning'
|
import * as LinkWarningModal from './LinkWarning'
|
||||||
import * as ListAddUserModal from './ListAddRemoveUsers'
|
import * as ListAddUserModal from './ListAddRemoveUsers'
|
||||||
import * as SelfLabelModal from './SelfLabel'
|
import * as SelfLabelModal from './SelfLabel'
|
||||||
import * as ThreadgateModal from './Threadgate'
|
|
||||||
import * as UserAddRemoveListsModal from './UserAddRemoveLists'
|
import * as UserAddRemoveListsModal from './UserAddRemoveLists'
|
||||||
import * as VerifyEmailModal from './VerifyEmail'
|
import * as VerifyEmailModal from './VerifyEmail'
|
||||||
|
|
||||||
|
@ -76,9 +75,6 @@ export function ModalsContainer() {
|
||||||
} else if (activeModal?.name === 'self-label') {
|
} else if (activeModal?.name === 'self-label') {
|
||||||
snapPoints = SelfLabelModal.snapPoints
|
snapPoints = SelfLabelModal.snapPoints
|
||||||
element = <SelfLabelModal.Component {...activeModal} />
|
element = <SelfLabelModal.Component {...activeModal} />
|
||||||
} else if (activeModal?.name === 'threadgate') {
|
|
||||||
snapPoints = ThreadgateModal.snapPoints
|
|
||||||
element = <ThreadgateModal.Component {...activeModal} />
|
|
||||||
} else if (activeModal?.name === 'alt-text-image') {
|
} else if (activeModal?.name === 'alt-text-image') {
|
||||||
snapPoints = AltImageModal.snapPoints
|
snapPoints = AltImageModal.snapPoints
|
||||||
element = <AltImageModal.Component {...activeModal} />
|
element = <AltImageModal.Component {...activeModal} />
|
||||||
|
|
|
@ -23,7 +23,6 @@ import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettin
|
||||||
import * as LinkWarningModal from './LinkWarning'
|
import * as LinkWarningModal from './LinkWarning'
|
||||||
import * as ListAddUserModal from './ListAddRemoveUsers'
|
import * as ListAddUserModal from './ListAddRemoveUsers'
|
||||||
import * as SelfLabelModal from './SelfLabel'
|
import * as SelfLabelModal from './SelfLabel'
|
||||||
import * as ThreadgateModal from './Threadgate'
|
|
||||||
import * as UserAddRemoveLists from './UserAddRemoveLists'
|
import * as UserAddRemoveLists from './UserAddRemoveLists'
|
||||||
import * as VerifyEmailModal from './VerifyEmail'
|
import * as VerifyEmailModal from './VerifyEmail'
|
||||||
|
|
||||||
|
@ -84,8 +83,6 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||||
element = <DeleteAccountModal.Component />
|
element = <DeleteAccountModal.Component />
|
||||||
} else if (modal.name === 'self-label') {
|
} else if (modal.name === 'self-label') {
|
||||||
element = <SelfLabelModal.Component {...modal} />
|
element = <SelfLabelModal.Component {...modal} />
|
||||||
} else if (modal.name === 'threadgate') {
|
|
||||||
element = <ThreadgateModal.Component {...modal} />
|
|
||||||
} else if (modal.name === 'change-handle') {
|
} else if (modal.name === 'change-handle') {
|
||||||
element = <ChangeHandleModal.Component {...modal} />
|
element = <ChangeHandleModal.Component {...modal} />
|
||||||
} else if (modal.name === 'invite-codes') {
|
} else if (modal.name === 'invite-codes') {
|
||||||
|
|
|
@ -1,208 +0,0 @@
|
||||||
import React, {useState} from 'react'
|
|
||||||
import {
|
|
||||||
Pressable,
|
|
||||||
StyleProp,
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
ViewStyle,
|
|
||||||
} from 'react-native'
|
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import isEqual from 'lodash.isequal'
|
|
||||||
|
|
||||||
import {useModalControls} from '#/state/modals'
|
|
||||||
import {useMyListsQuery} from '#/state/queries/my-lists'
|
|
||||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
|
||||||
import {colors, s} from 'lib/styles'
|
|
||||||
import {isWeb} from 'platform/detection'
|
|
||||||
import {ScrollView} from 'view/com/modals/util'
|
|
||||||
import {Text} from '../util/text/Text'
|
|
||||||
|
|
||||||
export const snapPoints = ['60%']
|
|
||||||
|
|
||||||
export function Component({
|
|
||||||
settings,
|
|
||||||
onChange,
|
|
||||||
onConfirm,
|
|
||||||
}: {
|
|
||||||
settings: ThreadgateSetting[]
|
|
||||||
onChange?: (settings: ThreadgateSetting[]) => void
|
|
||||||
onConfirm?: (settings: ThreadgateSetting[]) => void
|
|
||||||
}) {
|
|
||||||
const pal = usePalette('default')
|
|
||||||
const {closeModal} = useModalControls()
|
|
||||||
const [selected, setSelected] = useState(settings)
|
|
||||||
const {_} = useLingui()
|
|
||||||
const {data: lists} = useMyListsQuery('curate')
|
|
||||||
|
|
||||||
const onPressEverybody = () => {
|
|
||||||
setSelected([])
|
|
||||||
onChange?.([])
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPressNobody = () => {
|
|
||||||
setSelected([{type: 'nobody'}])
|
|
||||||
onChange?.([{type: 'nobody'}])
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPressAudience = (setting: ThreadgateSetting) => {
|
|
||||||
// remove nobody
|
|
||||||
let newSelected = selected.filter(v => v.type !== 'nobody')
|
|
||||||
// toggle
|
|
||||||
const i = newSelected.findIndex(v => isEqual(v, setting))
|
|
||||||
if (i === -1) {
|
|
||||||
newSelected.push(setting)
|
|
||||||
} else {
|
|
||||||
newSelected.splice(i, 1)
|
|
||||||
}
|
|
||||||
setSelected(newSelected)
|
|
||||||
onChange?.(newSelected)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View testID="threadgateModal" style={[pal.view, styles.container]}>
|
|
||||||
<View style={styles.titleSection}>
|
|
||||||
<Text type="title-lg" style={[pal.text, styles.title]}>
|
|
||||||
<Trans>Who can reply</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView>
|
|
||||||
<Text style={[pal.text, styles.description]}>
|
|
||||||
<Trans>Choose "Everybody" or "Nobody"</Trans>
|
|
||||||
</Text>
|
|
||||||
<View style={{flexDirection: 'row', gap: 6, paddingHorizontal: 6}}>
|
|
||||||
<Selectable
|
|
||||||
label={_(msg`Everybody`)}
|
|
||||||
isSelected={selected.length === 0}
|
|
||||||
onPress={onPressEverybody}
|
|
||||||
style={{flex: 1}}
|
|
||||||
/>
|
|
||||||
<Selectable
|
|
||||||
label={_(msg`Nobody`)}
|
|
||||||
isSelected={!!selected.find(v => v.type === 'nobody')}
|
|
||||||
onPress={onPressNobody}
|
|
||||||
style={{flex: 1}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={[pal.text, styles.description]}>
|
|
||||||
<Trans>Or combine these options:</Trans>
|
|
||||||
</Text>
|
|
||||||
<View style={{flexDirection: 'column', gap: 4, paddingHorizontal: 6}}>
|
|
||||||
<Selectable
|
|
||||||
label={_(msg`Mentioned users`)}
|
|
||||||
isSelected={!!selected.find(v => v.type === 'mention')}
|
|
||||||
onPress={() => onPressAudience({type: 'mention'})}
|
|
||||||
/>
|
|
||||||
<Selectable
|
|
||||||
label={_(msg`Followed users`)}
|
|
||||||
isSelected={!!selected.find(v => v.type === 'following')}
|
|
||||||
onPress={() => onPressAudience({type: 'following'})}
|
|
||||||
/>
|
|
||||||
{lists?.length
|
|
||||||
? lists.map(list => (
|
|
||||||
<Selectable
|
|
||||||
key={list.uri}
|
|
||||||
label={_(msg`Users in "${list.name}"`)}
|
|
||||||
isSelected={
|
|
||||||
!!selected.find(
|
|
||||||
v => v.type === 'list' && v.list === list.uri,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onPress={() =>
|
|
||||||
onPressAudience({type: 'list', list: list.uri})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
: null}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<View style={[styles.btnContainer, pal.borderDark]}>
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="confirmBtn"
|
|
||||||
onPress={() => {
|
|
||||||
closeModal()
|
|
||||||
onConfirm?.(selected)
|
|
||||||
}}
|
|
||||||
style={styles.btn}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg({message: `Done`, context: 'action'}))}
|
|
||||||
accessibilityHint="">
|
|
||||||
<Text style={[s.white, s.bold, s.f18]}>
|
|
||||||
<Trans context="action">Done</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Selectable({
|
|
||||||
label,
|
|
||||||
isSelected,
|
|
||||||
onPress,
|
|
||||||
style,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
isSelected: boolean
|
|
||||||
onPress: () => void
|
|
||||||
style?: StyleProp<ViewStyle>
|
|
||||||
}) {
|
|
||||||
const pal = usePalette(isSelected ? 'inverted' : 'default')
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={onPress}
|
|
||||||
accessibilityLabel={label}
|
|
||||||
accessibilityHint=""
|
|
||||||
style={[styles.selectable, pal.border, pal.view, style]}>
|
|
||||||
<Text type="lg" style={[pal.text]}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
{isSelected ? (
|
|
||||||
<FontAwesomeIcon icon="check" color={pal.colors.text} size={18} />
|
|
||||||
) : null}
|
|
||||||
</Pressable>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
paddingBottom: isWeb ? 0 : 40,
|
|
||||||
},
|
|
||||||
titleSection: {
|
|
||||||
paddingTop: isWeb ? 0 : 4,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
textAlign: 'center',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
textAlign: 'center',
|
|
||||||
paddingVertical: 16,
|
|
||||||
},
|
|
||||||
selectable: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingHorizontal: 18,
|
|
||||||
paddingVertical: 16,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 6,
|
|
||||||
},
|
|
||||||
btn: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRadius: 32,
|
|
||||||
padding: 14,
|
|
||||||
backgroundColor: colors.blue3,
|
|
||||||
},
|
|
||||||
btnContainer: {
|
|
||||||
paddingTop: 20,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
},
|
|
||||||
})
|
|
Loading…
Reference in New Issue