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
Samuel Newman 2024-06-24 23:15:11 +01:00 committed by GitHub
parent e0ac7d5bdc
commit 29aaf09a8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 334 additions and 314 deletions

View File

@ -88,7 +88,10 @@ export function Outer({
if (!isOpen) return
function handler(e: KeyboardEvent) {
if (e.key === 'Escape') close()
if (e.key === 'Escape') {
e.stopPropagation()
close()
}
}
document.addEventListener('keydown', handler)

View File

@ -17,7 +17,6 @@ import {HITSLOP_10} from '#/lib/constants'
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
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,
@ -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 {Text} from '#/components/Typography'
import {TextLink} from '../view/com/util/Link'
import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor'
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
interface WhoCanReplyProps {
@ -46,7 +46,15 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
const {_} = useLingui()
const t = useTheme()
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) {
return null
@ -63,6 +71,55 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
? _(msg`Replies disabled`)
: _(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 (
<>
<Button
@ -93,7 +150,18 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
</View>
)}
</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} />
}
export function WhoCanReplyDialog({
function WhoCanReplyDialog({
control,
post,
settings,
}: {
control: Dialog.DialogControlProps
post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[]
}) {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<WhoCanReplyDialogInner post={post} />
<WhoCanReplyDialogInner post={post} settings={settings} />
</Dialog.Outer>
)
}
function WhoCanReplyDialogInner({post}: {post: AppBskyFeedDefs.PostView}) {
function WhoCanReplyDialogInner({
post,
settings,
}: {
post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[]
}) {
const {_} = useLingui()
const {settings} = useWhoCanReply(post)
return (
<Dialog.ScrollableInner
label={_(msg`Who can reply dialog`)}
@ -245,67 +320,6 @@ function Separator({i, length}: {i: number; length: number}) {
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(
agent: BskyAgent,
uri: string,

View File

@ -113,6 +113,7 @@ export function EmbedConsentDialog({
</ButtonText>
</Button>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)

View File

@ -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>
)
}

View File

@ -5,7 +5,6 @@ import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {GalleryModel} from '#/state/models/media/gallery'
import {ImageModel} from '#/state/models/media/image'
import {ThreadgateSetting} from '../queries/threadgate'
export interface EditProfileModal {
name: 'edit-profile'
@ -67,13 +66,6 @@ export interface SelfLabelModal {
onChange: (labels: string[]) => void
}
export interface ThreadgateModal {
name: 'threadgate'
settings: ThreadgateSetting[]
onChange?: (settings: ThreadgateSetting[]) => void
onConfirm?: (settings: ThreadgateSetting[]) => void
}
export interface ChangeHandleModal {
name: 'change-handle'
onChanged: () => void
@ -149,7 +141,6 @@ export type Modal =
| CropImageModal
| EditImageModal
| SelfLabelModal
| ThreadgateModal
// Bluesky access
| WaitlistModal

View File

@ -5,11 +5,12 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {useAnalytics} from 'lib/analytics/analytics'
import {atoms as a, useTheme} from '#/alf'
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 {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
@ -26,18 +27,15 @@ export function ThreadgateBtn({
const {track} = useAnalytics()
const {_} = useLingui()
const t = useTheme()
const {openModal} = useModalControls()
const control = Dialog.useDialogControl()
const onPress = () => {
track('Composer:ThreadgateOpened')
if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss()
}
openModal({
name: 'threadgate',
settings: threadgate,
onChange,
})
control.open()
}
const isEverybody = threadgate.length === 0
@ -49,6 +47,7 @@ export function ThreadgateBtn({
: _(msg`Some people can reply`)
return (
<>
<Animated.View style={[a.flex_row, a.p_sm, t.atoms.bg, style]}>
<Button
variant="solid"
@ -56,12 +55,21 @@ export function ThreadgateBtn({
size="xsmall"
testID="openReplyGateButton"
onPress={onPress}
label={label}>
label={label}
accessibilityHint={_(
msg`Opens a dialog to choose who can reply to this thread`,
)}>
<ButtonIcon
icon={isEverybody ? Earth : isNobody ? CircleBanSign : Group}
/>
<ButtonText>{label}</ButtonText>
</Button>
</Animated.View>
<ThreadgateEditorDialog
control={control}
threadgate={threadgate}
onChange={onChange}
/>
</>
)
}

View File

@ -23,7 +23,6 @@ import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettin
import * as LinkWarningModal from './LinkWarning'
import * as ListAddUserModal from './ListAddRemoveUsers'
import * as SelfLabelModal from './SelfLabel'
import * as ThreadgateModal from './Threadgate'
import * as UserAddRemoveListsModal from './UserAddRemoveLists'
import * as VerifyEmailModal from './VerifyEmail'
@ -76,9 +75,6 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'self-label') {
snapPoints = SelfLabelModal.snapPoints
element = <SelfLabelModal.Component {...activeModal} />
} else if (activeModal?.name === 'threadgate') {
snapPoints = ThreadgateModal.snapPoints
element = <ThreadgateModal.Component {...activeModal} />
} else if (activeModal?.name === 'alt-text-image') {
snapPoints = AltImageModal.snapPoints
element = <AltImageModal.Component {...activeModal} />

View File

@ -23,7 +23,6 @@ import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettin
import * as LinkWarningModal from './LinkWarning'
import * as ListAddUserModal from './ListAddRemoveUsers'
import * as SelfLabelModal from './SelfLabel'
import * as ThreadgateModal from './Threadgate'
import * as UserAddRemoveLists from './UserAddRemoveLists'
import * as VerifyEmailModal from './VerifyEmail'
@ -84,8 +83,6 @@ function Modal({modal}: {modal: ModalIface}) {
element = <DeleteAccountModal.Component />
} else if (modal.name === 'self-label') {
element = <SelfLabelModal.Component {...modal} />
} else if (modal.name === 'threadgate') {
element = <ThreadgateModal.Component {...modal} />
} else if (modal.name === 'change-handle') {
element = <ChangeHandleModal.Component {...modal} />
} else if (modal.name === 'invite-codes') {

View File

@ -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,
},
})