Add UI for creating group clip clop invite links (#10318)

This commit is contained in:
DS Boyce
2026-04-23 13:13:12 -07:00
committed by Eric Bailey
parent d5a37f76ab
commit a53ec04c1a
22 changed files with 1301 additions and 348 deletions
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M3 16.782v-9.64c0-.546-.001-1.015.03-1.4.033-.397.105-.792.299-1.172a3 3 0 0 1 1.315-1.316c.38-.194.775-.266 1.172-.298.386-.032.856-.03 1.402-.03h3.815a1.004 1.004 0 1 1 0 2.008H7.218c-.58 0-.953 0-1.238.023-.273.023-.374.06-.424.086a1 1 0 0 0-.438.439c-.025.05-.063.15-.085.424a17 17 0 0 0-.025 1.236v9.64c0 .58.002.953.025 1.238.022.272.06.374.085.424.096.188.25.342.438.438.05.025.151.063.424.085.285.023.659.025 1.238.025h9.639c.579 0 .953-.002 1.238-.025.272-.022.373-.06.423-.085.189-.096.342-.25.438-.438.026-.05.063-.152.086-.424.023-.285.024-.659.024-1.238v-3.815a1.004 1.004 0 1 1 2.009 0v3.815c0 .546 0 1.016-.03 1.4-.033.398-.106.793-.299 1.173a3 3 0 0 1-1.316 1.317c-.38.193-.775.264-1.172.297-.385.031-.855.031-1.401.031h-9.64c-.545 0-1.015 0-1.4-.031-.398-.033-.793-.104-1.173-.297a3 3 0 0 1-1.315-1.317c-.194-.38-.266-.775-.299-1.172-.031-.385-.03-.855-.03-1.4m13.685-13.9a3.01 3.01 0 0 1 4.26 0l.173.172a3.013 3.013 0 0 1 0 4.26l-8.077 8.077a2 2 0 0 1-1.42.588H9.026c-.555 0-1.004-.45-1.004-1.004V12.38c0-.533.211-1.044.588-1.42zM10.03 13.971h1.592l8.077-8.076a1.004 1.004 0 0 0 0-1.42l-.173-.173a1.004 1.004 0 0 0-1.42 0L10.03 12.38z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z"/></svg>

After

Width:  |  Height:  |  Size: 502 B

+6 -1
View File
@@ -556,7 +556,11 @@ export type Events = {
| 'FindContacts' | 'FindContacts'
} }
'chat:create': { 'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' logContext:
| 'ProfileHeader'
| 'NewChatDialog'
| 'SendViaChatDialog'
| 'ConvoSettings'
} }
'chat:open': { 'chat:open': {
logContext: logContext:
@@ -564,6 +568,7 @@ export type Events = {
| 'NewChatDialog' | 'NewChatDialog'
| 'ChatsList' | 'ChatsList'
| 'SendViaChatDialog' | 'SendViaChatDialog'
| 'ConvoSettings'
} }
'groupchat:create': { 'groupchat:create': {
logContext: 'NewChatDialog' logContext: 'NewChatDialog'
+11 -1
View File
@@ -81,6 +81,7 @@ export type ItemProps = ViewStyleProp & {
isInvalid?: boolean isInvalid?: boolean
children: ((props: ItemState) => React.ReactNode) | React.ReactNode children: ((props: ItemState) => React.ReactNode) | React.ReactNode
hitSlop?: PressableProps['hitSlop'] hitSlop?: PressableProps['hitSlop']
highlightRow?: boolean
} }
export function useItemContext() { export function useItemContext() {
@@ -160,8 +161,11 @@ export function Item({
style, style,
type = 'checkbox', type = 'checkbox',
label, label,
highlightRow,
...rest ...rest
}: ItemProps) { }: ItemProps) {
const t = useTheme()
const { const {
values: selectedValues, values: selectedValues,
type: groupType, type: groupType,
@@ -207,6 +211,12 @@ export function Item({
[name, selected, disabled, hovered, pressed, focused, isInvalid], [name, selected, disabled, hovered, pressed, focused, isInvalid],
) )
const highlightStyle = highlightRow
? selected
? [a.rounded_full, a.p_md, {backgroundColor: t.palette.primary_50}]
: [a.rounded_full, a.p_md]
: null
return ( return (
<ItemContext.Provider value={state}> <ItemContext.Provider value={state}>
<Pressable <Pressable
@@ -232,7 +242,7 @@ export function Item({
onPressOut={onPressOut} onPressOut={onPressOut}
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
style={[a.flex_row, a.align_center, a.gap_sm, style]}> style={[a.flex_row, a.align_center, a.gap_sm, highlightStyle, style]}>
{typeof children === 'function' ? children(state) : children} {typeof children === 'function' ? children(state) : children}
</Pressable> </Pressable>
</ItemContext.Provider> </ItemContext.Provider>
+7
View File
@@ -5,6 +5,13 @@ export const EditBig_Stroke1_Corner0_Rounded = createSinglePathSVG({
path: 'M19.667 4.458a1 1 0 1 0 0-2v2Zm25 23a1 1 0 0 0-2 0h2ZM3.912 45.543l.454-.891-.454.89Zm-2.33-2.33.89-.455h0l-.89.454Zm39.173 2.33-.454-.891h0l.454.89Zm2.33-2.33-.89-.455.89.454ZM1.581 6.37l-.89-.454h0l.89.454Zm2.331-2.331-.454-.891.454.89ZM14.333 32.79h-1a1 1 0 0 0 1 1v-1Zm.781-8.781.707.707-.707-.707ZM36.562 2.562l-.707-.707v0l.707.707Zm7.543 0-.707.707v0l.707-.707Zm.457.458.707-.707v0l-.707.707Zm0 7.542.707.707-.707-.707ZM23.114 32.01l.707.707-.707-.707Zm12.02 14.114v-1h-25.6v2h25.6v-1ZM1 37.591h1v-25.6H0v25.6h1ZM9.533 3.458v1h10.134v-2H9.533v1Zm34.134 24h-1V37.59h2V27.457h-1ZM9.533 46.124v-1c-1.51 0-2.582 0-3.421-.07-.828-.067-1.34-.195-1.746-.402l-.454.89-.454.892c.735.374 1.54.537 2.491.614.94.077 2.107.076 3.584.076v-1ZM1 37.591H0c0 1.477 0 2.645.076 3.584.078.951.24 1.756.614 2.491l.891-.454.891-.454c-.207-.406-.335-.918-.403-1.746C2.001 40.173 2 39.101 2 37.591H1Zm2.912 7.952.454-.891a4.33 4.33 0 0 1-1.894-1.894l-.89.454-.892.454a6.33 6.33 0 0 0 2.768 2.768l.454-.891Zm31.221.581v1c1.477 0 2.645.001 3.585-.076.95-.078 1.756-.24 2.49-.614l-.453-.891-.454-.891c-.406.207-.919.335-1.746.403-.84.068-1.912.07-3.422.07v1Zm8.534-8.533h-1c0 1.51-.001 2.582-.07 3.421-.067.828-.196 1.34-.403 1.746l.891.454.891.454c.375-.735.537-1.54.615-2.49.076-.94.076-2.108.076-3.585h-1Zm-2.912 7.952.454.89a6.33 6.33 0 0 0 2.767-2.767l-.89-.454-.892-.454a4.33 4.33 0 0 1-1.893 1.894l.454.89ZM1 11.99h1c0-1.51 0-2.582.07-3.422.067-.827.195-1.34.402-1.745l-.89-.454-.892-.454c-.374.734-.536 1.54-.614 2.49C-.001 9.345 0 10.513 0 11.99h1Zm8.533-8.533v-1c-1.477 0-2.645-.001-3.584.076-.951.077-1.756.24-2.49.614l.453.89.454.892c.406-.207.918-.336 1.746-.403.839-.069 1.911-.07 3.421-.07v-1ZM1.581 6.37l.891.454A4.33 4.33 0 0 1 4.366 4.93l-.454-.891-.454-.891A6.33 6.33 0 0 0 .69 5.916l.891.454Zm12.752 19.525h-1v6.896h2v-6.896h-1Zm0 6.896v1h6.896v-2h-6.896v1Zm.781-8.781.707.707L37.27 3.269l-.707-.707-.707-.707-21.448 21.448.707.707Zm28.99-21.448-.706.707.457.458.707-.707.707-.707-.457-.458-.707.707Zm.458 8-.707-.707-21.448 21.448.707.707.707.707L45.27 11.269l-.707-.707Zm0-7.542-.707.707a4.333 4.333 0 0 1 0 6.128l.707.707.707.707a6.333 6.333 0 0 0 0-8.956l-.707.707Zm-8-.458.707.707a4.333 4.333 0 0 1 6.129 0l.707-.707.707-.707a6.333 6.333 0 0 0-8.957 0l.707.707ZM21.23 32.791v1c.972 0 1.905-.386 2.593-1.074l-.708-.707-.707-.707a1.67 1.67 0 0 1-1.178.488v1Zm-6.896-6.896h1c0-.442.176-.866.489-1.178l-.708-.707-.707-.707a3.67 3.67 0 0 0-1.074 2.592h1Z', path: 'M19.667 4.458a1 1 0 1 0 0-2v2Zm25 23a1 1 0 0 0-2 0h2ZM3.912 45.543l.454-.891-.454.89Zm-2.33-2.33.89-.455h0l-.89.454Zm39.173 2.33-.454-.891h0l.454.89Zm2.33-2.33-.89-.455.89.454ZM1.581 6.37l-.89-.454h0l.89.454Zm2.331-2.331-.454-.891.454.89ZM14.333 32.79h-1a1 1 0 0 0 1 1v-1Zm.781-8.781.707.707-.707-.707ZM36.562 2.562l-.707-.707v0l.707.707Zm7.543 0-.707.707v0l.707-.707Zm.457.458.707-.707v0l-.707.707Zm0 7.542.707.707-.707-.707ZM23.114 32.01l.707.707-.707-.707Zm12.02 14.114v-1h-25.6v2h25.6v-1ZM1 37.591h1v-25.6H0v25.6h1ZM9.533 3.458v1h10.134v-2H9.533v1Zm34.134 24h-1V37.59h2V27.457h-1ZM9.533 46.124v-1c-1.51 0-2.582 0-3.421-.07-.828-.067-1.34-.195-1.746-.402l-.454.89-.454.892c.735.374 1.54.537 2.491.614.94.077 2.107.076 3.584.076v-1ZM1 37.591H0c0 1.477 0 2.645.076 3.584.078.951.24 1.756.614 2.491l.891-.454.891-.454c-.207-.406-.335-.918-.403-1.746C2.001 40.173 2 39.101 2 37.591H1Zm2.912 7.952.454-.891a4.33 4.33 0 0 1-1.894-1.894l-.89.454-.892.454a6.33 6.33 0 0 0 2.768 2.768l.454-.891Zm31.221.581v1c1.477 0 2.645.001 3.585-.076.95-.078 1.756-.24 2.49-.614l-.453-.891-.454-.891c-.406.207-.919.335-1.746.403-.84.068-1.912.07-3.422.07v1Zm8.534-8.533h-1c0 1.51-.001 2.582-.07 3.421-.067.828-.196 1.34-.403 1.746l.891.454.891.454c.375-.735.537-1.54.615-2.49.076-.94.076-2.108.076-3.585h-1Zm-2.912 7.952.454.89a6.33 6.33 0 0 0 2.767-2.767l-.89-.454-.892-.454a4.33 4.33 0 0 1-1.893 1.894l.454.89ZM1 11.99h1c0-1.51 0-2.582.07-3.422.067-.827.195-1.34.402-1.745l-.89-.454-.892-.454c-.374.734-.536 1.54-.614 2.49C-.001 9.345 0 10.513 0 11.99h1Zm8.533-8.533v-1c-1.477 0-2.645-.001-3.584.076-.951.077-1.756.24-2.49.614l.453.89.454.892c.406-.207.918-.336 1.746-.403.839-.069 1.911-.07 3.421-.07v-1ZM1.581 6.37l.891.454A4.33 4.33 0 0 1 4.366 4.93l-.454-.891-.454-.891A6.33 6.33 0 0 0 .69 5.916l.891.454Zm12.752 19.525h-1v6.896h2v-6.896h-1Zm0 6.896v1h6.896v-2h-6.896v1Zm.781-8.781.707.707L37.27 3.269l-.707-.707-.707-.707-21.448 21.448.707.707Zm28.99-21.448-.706.707.457.458.707-.707.707-.707-.457-.458-.707.707Zm.458 8-.707-.707-21.448 21.448.707.707.707.707L45.27 11.269l-.707-.707Zm0-7.542-.707.707a4.333 4.333 0 0 1 0 6.128l.707.707.707.707a6.333 6.333 0 0 0 0-8.956l-.707.707Zm-8-.458.707.707a4.333 4.333 0 0 1 6.129 0l.707-.707.707-.707a6.333 6.333 0 0 0-8.957 0l.707.707ZM21.23 32.791v1c.972 0 1.905-.386 2.593-1.074l-.708-.707-.707-.707a1.67 1.67 0 0 1-1.178.488v1Zm-6.896-6.896h1c0-.442.176-.866.489-1.178l-.708-.707-.707-.707a3.67 3.67 0 0 0-1.074 2.592h1Z',
}) })
/**
* @deprecated Use EditBig_Stroke2_Corner2_Rounded
*/
export const EditBig_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const EditBig_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M17.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-9 9A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l9-9ZM10 12.414V14h1.586l8-8L18 4.414l-8 8ZM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Z', path: 'M17.293 2.293a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1 0 1.414l-9 9A1 1 0 0 1 12 16H9a1 1 0 0 1-1-1v-3a1 1 0 0 1 .293-.707l9-9ZM10 12.414V14h1.586l8-8L18 4.414l-8 8ZM3 4a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Z',
}) })
export const EditBig_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M3 16.782v-9.64c0-.546-.001-1.015.03-1.4.033-.397.105-.792.299-1.172a3 3 0 0 1 1.315-1.316c.38-.194.775-.266 1.172-.298.386-.032.856-.03 1.402-.03h3.815a1.004 1.004 0 1 1 0 2.008H7.218c-.58 0-.953 0-1.238.023-.273.023-.374.06-.424.086-.189.096-.342.25-.438.439-.025.05-.063.15-.085.424-.023.284-.025.658-.025 1.236v9.64c0 .58.002.953.025 1.238.022.272.06.374.085.424.096.188.25.342.438.438.05.025.151.063.424.085.285.023.659.025 1.238.025h9.639c.579 0 .953-.002 1.238-.025.272-.022.373-.06.423-.085.189-.096.342-.25.438-.438.026-.05.063-.152.086-.424.023-.285.024-.659.024-1.238v-3.815a1.004 1.004 0 1 1 2.009 0v3.815c0 .546 0 1.016-.03 1.4-.033.398-.106.793-.299 1.173a3 3 0 0 1-1.316 1.317c-.38.193-.775.264-1.172.297-.385.031-.855.031-1.401.031h-9.64c-.545 0-1.015 0-1.4-.031-.398-.033-.793-.104-1.173-.297a3 3 0 0 1-1.315-1.317c-.194-.38-.266-.775-.299-1.172-.031-.385-.03-.855-.03-1.4m13.685-13.9a3.01 3.01 0 0 1 4.26 0l.173.172a3.013 3.013 0 0 1 0 4.26l-8.077 8.077a2 2 0 0 1-1.42.588H9.026c-.555 0-1.004-.45-1.004-1.004V12.38c0-.533.211-1.044.588-1.42zM10.03 13.971h1.592l8.077-8.076a1.004 1.004 0 0 0 0-1.42l-.173-.173a1.004 1.004 0 0 0-1.42 0L10.03 12.38z',
})
@@ -1,5 +1,12 @@
import {createSinglePathSVG} from './TEMPLATE' import {createSinglePathSVG} from './TEMPLATE'
/**
* @deprecated Use SquareBehindSquare_Stroke2_Corner2_Rounded
*/
export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z', path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z',
}) })
export const SquareBehindSquare_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M20 4.25a.25.25 0 0 0-.25-.25h-9.5a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25zM4 19.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V16h-3.75A2.25 2.25 0 0 1 8 13.75V10H4.25a.25.25 0 0 0-.25.25zm18-6A2.25 2.25 0 0 1 19.75 16H16v3.75A2.25 2.25 0 0 1 13.75 22h-9.5A2.25 2.25 0 0 1 2 19.75v-9.5A2.25 2.25 0 0 1 4.25 8H8V4.25A2.25 2.25 0 0 1 10.25 2h9.5A2.25 2.25 0 0 1 22 4.25z',
})
+181 -212
View File
@@ -1,4 +1,4 @@
import {useMemo, useState} from 'react' import {useState} from 'react'
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
import {moderateProfile} from '@atproto/api' import {moderateProfile} from '@atproto/api'
import {plural} from '@lingui/core/macro' import {plural} from '@lingui/core/macro'
@@ -8,14 +8,15 @@ import {StackActions, useNavigation} from '@react-navigation/native'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import { import {
type CommonNavigatorParams, type CommonNavigatorParams,
type NativeStackScreenProps, type NativeStackScreenProps,
type NavigationProp, type NavigationProp,
} from '#/lib/routes/types' } from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {type Shadow} from '#/state/cache/types' import {type Shadow} from '#/state/cache/types'
import {ConvoProvider, useConvo} from '#/state/messages/convo' import {ConvoProvider, useConvo} from '#/state/messages/convo'
import {ConvoStatus} from '#/state/messages/convo/types' import {ConvoStatus} from '#/state/messages/convo/types'
@@ -50,7 +51,7 @@ import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/i
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron'
import {type Props as SVGIconProps} from '#/components/icons/common' import {type Props as SVGIconProps} from '#/components/icons/common'
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
import {EditBig_Stroke2_Corner0_Rounded as EditIcon} from '#/components/icons/EditBig' import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig'
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
@@ -70,10 +71,17 @@ import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {InviteLinkDialog} from './components/InviteLinkDialog'
const MEMBER_LIMIT = 50 const MEMBER_LIMIT = 50
const ROW_SPACING = 10 const ROW_SPACING = 10
const dateFormatter = new Intl.DateTimeFormat(undefined, {
month: 'long',
day: 'numeric',
year: 'numeric',
})
type Item = type Item =
| { | {
type: 'MEMBERS_AND_REQUESTS' type: 'MEMBERS_AND_REQUESTS'
@@ -83,7 +91,7 @@ type Item =
} }
| { | {
type: 'CHAT_MEMBER' type: 'CHAT_MEMBER'
profile: Shadow<bsky.profile.AnyProfileView> profile: bsky.profile.AnyProfileView
status: 'owner' | 'standard' | 'invited' status: 'owner' | 'standard' | 'invited'
} }
@@ -92,9 +100,6 @@ type Props = NativeStackScreenProps<
'MessagesConversationSettings' 'MessagesConversationSettings'
> >
/**
* TODO This is just layout for now.
*/
export function MessagesConversationSettingsScreen({route}: Props) { export function MessagesConversationSettingsScreen({route}: Props) {
const {gtTablet} = useBreakpoints() const {gtTablet} = useBreakpoints()
@@ -138,6 +143,8 @@ function SettingsInner({convoId}: {convoId: string}) {
const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did
const data: bsky.profile.AnyProfileView[] = convo?.members ?? [] const data: bsky.profile.AnyProfileView[] = convo?.members ?? []
// TODO Need this data in order to populate this array. -dsb
const invites: string[] = []
const {data: joinRequestsData, hasNextPage: hasMoreRequests} = const {data: joinRequestsData, hasNextPage: hasMoreRequests} =
useListJoinRequestsQuery({ useListJoinRequestsQuery({
@@ -150,7 +157,7 @@ function SettingsInner({convoId}: {convoId: string}) {
0, 0,
) ?? 0 ) ?? 0
const items = [ const items: Item[] = [
{ {
type: 'MEMBERS_AND_REQUESTS', type: 'MEMBERS_AND_REQUESTS',
}, },
@@ -167,11 +174,18 @@ function SettingsInner({convoId}: {convoId: string}) {
if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1 if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1
return 0 return 0
}) })
.map(profile => ({ .map(
type: 'CHAT_MEMBER', (profile): Item => ({
profile, type: 'CHAT_MEMBER',
status: primaryMember?.did === profile.did ? 'owner' : 'standard', profile,
})), status:
primaryMember?.did === profile.did
? 'owner'
: invites.includes(profile.did)
? 'invited'
: 'standard',
}),
),
] ]
function renderItem({item}: {item: Item}) { function renderItem({item}: {item: Item}) {
@@ -194,14 +208,14 @@ function SettingsInner({convoId}: {convoId: string}) {
/> />
) )
case 'CHAT_MEMBER': case 'CHAT_MEMBER':
return ( return convo ? (
<Member <Member
convo={convo} convo={convo}
profile={item.profile} profile={item.profile}
status={item.status} status={item.status}
isOwner={isOwner} isOwner={isOwner}
/> />
) ) : null
default: default:
return null return null
} }
@@ -209,14 +223,12 @@ function SettingsInner({convoId}: {convoId: string}) {
if (convoState.status === ConvoStatus.Error) { if (convoState.status === ConvoStatus.Error) {
return ( return (
<> <Error
<Error title={l`Something went wrong`}
title={l`Something went wrong`} message={l`We couldnt load this conversations settings`}
message={l`We couldnt load this conversations settings`} onRetry={() => convoState.error.retry()}
onRetry={() => convoState.error.retry()} sideBorders={false}
sideBorders={false} />
/>
</>
) )
} }
@@ -228,7 +240,7 @@ function SettingsInner({convoId}: {convoId: string}) {
initialNumToRender={initialNumToRender} initialNumToRender={initialNumToRender}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
ListHeaderComponent={ ListHeaderComponent={
convo ? ( convo?.kind === 'group' ? (
<SettingsHeader convo={convo} isOwner={isOwner} /> <SettingsHeader convo={convo} isOwner={isOwner} />
) : ( ) : (
<SettingsHeaderPlaceholder /> <SettingsHeaderPlaceholder />
@@ -258,9 +270,9 @@ function MembersAndRequests({
return ( return (
<View style={[a.flex_row, a.justify_between, a.mx_xl, a.mt_lg, a.mb_sm]}> <View style={[a.flex_row, a.justify_between, a.mx_xl, a.mt_lg, a.mb_sm]}>
<View style={[a.flex_row, a.align_center]}> <View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Text style={[a.text_lg, a.font_semi_bold, t.atoms.text]}> <Text style={[a.text_lg, a.font_semi_bold, t.atoms.text]}>
<Trans>Members</Trans>{' '} <Trans>Members</Trans>
</Text> </Text>
<Text <Text
style={[a.text_xs, a.font_medium, {color: t.palette.contrast_500}]}> style={[a.text_xs, a.font_medium, {color: t.palette.contrast_500}]}>
@@ -275,6 +287,7 @@ function MembersAndRequests({
<InlineLinkText <InlineLinkText
label={l`View incoming group chat requests`} label={l`View incoming group chat requests`}
style={[a.text_sm, a.text_right, a.font_semi_bold]} style={[a.text_sm, a.text_right, a.font_semi_bold]}
// TODO Need to implement this. -dsb
to="#"> to="#">
{hasMoreRequests {hasMoreRequests
? l({ ? l({
@@ -311,6 +324,9 @@ function AddMembersLink({
const convoId = convo?.view.id const convoId = convo?.view.id
const {mutate: addGroupMembers} = useAddGroupMembers(convoId, { const {mutate: addGroupMembers} = useAddGroupMembers(convoId, {
onSuccess: () => {
addMembersControl.close()
},
onError: e => { onError: e => {
logger.error('Failed to add group chat members', {message: e}) logger.error('Failed to add group chat members', {message: e})
Toast.show(l`Failed to add members`, {type: 'error'}) Toast.show(l`Failed to add members`, {type: 'error'})
@@ -393,7 +409,6 @@ function AddMembersLink({
title={l`Add members`} title={l`Add members`}
onAddMembers={members => { onAddMembers={members => {
addGroupMembers({members}) addGroupMembers({members})
addMembersControl.close()
}} }}
/> />
</Dialog.Outer> </Dialog.Outer>
@@ -403,12 +418,12 @@ function AddMembersLink({
function Member({ function Member({
convo, convo,
profile, profile: profileUnshadowed,
status, status,
isOwner, isOwner,
}: { }: {
convo: ConvoWithDetails | null convo: ConvoWithDetails
profile: Shadow<bsky.profile.AnyProfileView> profile: bsky.profile.AnyProfileView
status: 'owner' | 'standard' | 'invited' status: 'owner' | 'standard' | 'invited'
isOwner: boolean isOwner: boolean
}) { }) {
@@ -416,36 +431,32 @@ function Member({
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const profile = useProfileShadow(profileUnshadowed)
const {currentAccount} = useSession() const {currentAccount} = useSession()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const moderation = useMemo( const moderation = moderationOpts
() => ? moderateProfile(profile, moderationOpts)
moderationOpts ? moderateProfile(profile, moderationOpts) : undefined, : undefined
[profile, moderationOpts],
)
if (!moderation) return null if (!moderation) return null
const isDeletedAccount = profile.handle === 'missing.invalid' const isDeletedAccount = profile.handle === 'missing.invalid'
const displayName = isDeletedAccount const displayName = isDeletedAccount
? l`Deleted Account` ? l`Deleted Account`
: sanitizeDisplayName( : createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
profile.displayName || profile.handle,
moderation.ui('displayName'),
)
const isSelf = currentAccount?.did === profile.did
let statusBadge: React.ReactNode | null = null let statusBadge: React.ReactNode | null = null
if (currentAccount?.did === profile.did) { if (isSelf) {
switch (status) { if (status === 'owner') {
case 'owner': statusBadge = <StatusBadge label={l`Admin`} />
statusBadge = <StatusBadge label={l`Admin`} />
break
} }
} else { } else {
statusBadge = convo ? ( statusBadge = convo ? (
<MemberMenu <MemberMenu
convo={convo} convo={convo}
profile={profile} profile={profile}
displayName={displayName}
type={status} type={status}
isOwner={isOwner} isOwner={isOwner}
/> />
@@ -454,42 +465,46 @@ function Member({
return ( return (
<SubtleHoverWrapper> <SubtleHoverWrapper>
<Pressable <View
accessibilityRole="button"
style={[ style={[
a.flex_row,
a.align_center,
a.justify_between,
a.mx_xl, a.mx_xl,
{ {
marginTop: ROW_SPACING, marginTop: ROW_SPACING,
marginBottom: ROW_SPACING, marginBottom: ROW_SPACING,
}, },
]} ]}>
onPress={() => { <Pressable
navigation.navigate('Profile', {name: profile.did}) accessibilityRole="button"
}}> accessibilityLabel={l`View ${displayName}s profile`}
<View style={[a.flex_row, a.align_center, a.justify_between]}> accessibilityHint={l`Opens this members profile`}
<View style={[a.flex_row, a.align_center]}> style={[a.flex_1, a.flex_row, a.align_center]}
<PreviewableUserAvatar onPress={() => {
profile={profile} navigation.navigate('Profile', {name: profile.did})
size={48} }}>
moderation={moderation.ui('avatar')} <PreviewableUserAvatar
/> profile={profile}
<View style={[a.mx_sm]}> size={48}
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> moderation={moderation.ui('avatar')}
{displayName} />
</Text> <View style={[a.mx_sm]}>
<Text <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
style={[ {displayName}
a.text_xs, </Text>
{color: t.palette.contrast_500}, <Text
web(a.pt_2xs), style={[
]}> a.text_xs,
{sanitizeHandle(profile.handle, '@')} {color: t.palette.contrast_500},
</Text> web(a.pt_2xs),
</View> ]}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View> </View>
<View>{statusBadge}</View> </Pressable>
</View> <View>{statusBadge}</View>
</Pressable> </View>
</SubtleHoverWrapper> </SubtleHoverWrapper>
) )
} }
@@ -497,72 +512,53 @@ function Member({
function StatusBadge({ function StatusBadge({
label, label,
style, style,
pressableProps,
}: { }: {
label: string label: string
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
pressableProps?: TriggerChildProps['props']
}) { }) {
const t = useTheme() const t = useTheme()
return ( const badgeStyle = [
<View a.rounded_xs,
style={[ t.atoms.bg_contrast_50,
a.rounded_xs, {
t.atoms.bg_contrast_50, paddingTop: 3,
{ paddingBottom: 3,
paddingTop: 3, paddingLeft: 6,
paddingBottom: 3, paddingRight: 6,
paddingLeft: 6, },
paddingRight: 6, style,
}, ]
style,
]}>
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}>
{label}
</Text>
</View>
)
}
function StatusButton({ const labelText = (
label, <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}>
style, {label}
...rest </Text>
}: {
label: string
style?: StyleProp<ViewStyle>
} & TriggerChildProps['props']) {
const t = useTheme()
return (
<Pressable
style={[
a.rounded_xs,
t.atoms.bg_contrast_50,
{
paddingTop: 3,
paddingBottom: 3,
paddingLeft: 6,
paddingRight: 6,
},
style,
]}
{...rest}>
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}>
{label}
</Text>
</Pressable>
) )
if (pressableProps) {
return (
<Pressable style={badgeStyle} {...pressableProps}>
{labelText}
</Pressable>
)
}
return <View style={badgeStyle}>{labelText}</View>
} }
function MemberMenu({ function MemberMenu({
convo, convo,
profile, profile,
displayName,
type, type,
isOwner, isOwner,
}: { }: {
convo: ConvoWithDetails convo: ConvoWithDetails
profile: Shadow<bsky.profile.AnyProfileView> profile: Shadow<bsky.profile.AnyProfileView>
type: 'owner' | 'standard' | 'invited' type: 'owner' | 'standard' | 'invited'
displayName: string
isOwner: boolean isOwner: boolean
}) { }) {
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
@@ -574,10 +570,13 @@ function MemberMenu({
const blockMemberPrompt = Prompt.usePromptControl() const blockMemberPrompt = Prompt.usePromptControl()
const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) const [menuDidOpen, setMenuDidOpen] = useState(false)
const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, {
enabled: menuDidOpen,
})
const {mutate: initiateConvo} = useGetConvoForMembers({ const {mutate: initiateConvo} = useGetConvoForMembers({
onSuccess: ({convo}) => { onSuccess: ({convo}) => {
ax.metric('chat:open', {logContext: 'ProfileHeader'}) ax.metric('chat:open', {logContext: 'ConvoSettings'})
navigation.navigate('MessagesConversation', {conversation: convo.id}) navigation.navigate('MessagesConversation', {conversation: convo.id})
}, },
onError: () => { onError: () => {
@@ -599,12 +598,12 @@ function MemberMenu({
} }
if (convoAvailability.convo) { if (convoAvailability.convo) {
ax.metric('chat:open', {logContext: 'ProfileHeader'}) ax.metric('chat:open', {logContext: 'ConvoSettings'})
navigation.navigate('MessagesConversation', { navigation.navigate('MessagesConversation', {
conversation: convoAvailability.convo.id, conversation: convoAvailability.convo.id,
}) })
} else { } else {
ax.metric('chat:create', {logContext: 'ProfileHeader'}) ax.metric('chat:create', {logContext: 'ConvoSettings'})
initiateConvo([profile.did]) initiateConvo([profile.did])
} }
} }
@@ -625,7 +624,7 @@ function MemberMenu({
} catch (err) { } catch (err) {
const e = err as Error const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
ax.logger.error('Failed to unblock account', {message: e}) logger.error('Failed to unblock account', {message: e})
Toast.show(l`There was an issue! ${e.toString()}`, { Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error', type: 'error',
}) })
@@ -638,7 +637,7 @@ function MemberMenu({
} catch (err) { } catch (err) {
const e = err as Error const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
ax.logger.error('Failed to block account', {message: e}) logger.error('Failed to block account', {message: e})
Toast.show(l`There was an issue! ${e.toString()}`, { Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error', type: 'error',
}) })
@@ -647,32 +646,22 @@ function MemberMenu({
} }
} }
const moderationOpts = useModerationOpts()
const moderation = useMemo(
() =>
moderationOpts ? moderateProfile(profile, moderationOpts) : undefined,
[profile, moderationOpts],
)
if (!moderation) return null
const isDeletedAccount = profile.handle === 'missing.invalid'
const displayName = isDeletedAccount
? l`Deleted Account`
: sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)
return ( return (
<> <>
<Menu.Root> <Menu.Root>
<Menu.Trigger label={l`Open chat member options for ${displayName}`}> <Menu.Trigger label={l`Open chat member options for ${displayName}`}>
{({props, state, control: menuControl}) => {({props, state, control: menuControl}) => {
type === 'owner' || type === 'invited' ? ( const triggerProps = {
<StatusButton ...props,
{...props} onPress: () => {
setMenuDidOpen(true)
props.onPress()
},
}
return type === 'owner' || type === 'invited' ? (
<StatusBadge
label={type === 'owner' ? l`Admin` : l`Invited`} label={type === 'owner' ? l`Admin` : l`Invited`}
pressableProps={triggerProps}
style={[ style={[
state.hovered || state.pressed || menuControl.isOpen state.hovered || state.pressed || menuControl.isOpen
? { ? {
@@ -683,7 +672,7 @@ function MemberMenu({
/> />
) : ( ) : (
<Pressable <Pressable
{...props} {...triggerProps}
style={[ style={[
a.rounded_full, a.rounded_full,
a.p_sm, a.p_sm,
@@ -699,7 +688,7 @@ function MemberMenu({
/> />
</Pressable> </Pressable>
) )
} }}
</Menu.Trigger> </Menu.Trigger>
<Menu.Outer> <Menu.Outer>
<Menu.Group> <Menu.Group>
@@ -738,7 +727,7 @@ function MemberMenu({
<Menu.ItemIcon icon={PersonXIcon} /> <Menu.ItemIcon icon={PersonXIcon} />
</Menu.Item> </Menu.Item>
) : null} ) : null}
{isOwner ? ( {isOwner && type !== 'invited' ? (
<Menu.Item <Menu.Item
label={l`Remove ${displayName} from this group chat`} label={l`Remove ${displayName} from this group chat`}
onPress={() => removeMembers({members: [profile.did]})}> onPress={() => removeMembers({members: [profile.did]})}>
@@ -751,6 +740,7 @@ function MemberMenu({
{isOwner && type === 'invited' ? ( {isOwner && type === 'invited' ? (
<Menu.Item <Menu.Item
label={l`Uninvite ${displayName} from this group chat`} label={l`Uninvite ${displayName} from this group chat`}
// TODO Need to wire up the uninvite flow. -dsb
onPress={() => {}}> onPress={() => {}}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Uninvite</Trans> <Trans>Uninvite</Trans>
@@ -773,7 +763,7 @@ function SettingsHeader({
convo, convo,
isOwner, isOwner,
}: { }: {
convo: ConvoWithDetails convo: Extract<ConvoWithDetails, {kind: 'group'}>
isOwner: boolean isOwner: boolean
}) { }) {
const t = useTheme() const t = useTheme()
@@ -781,11 +771,20 @@ function SettingsHeader({
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const groupName = convo.kind === 'group' ? convo.details.name : '' const groupName = convo.details.name
const [newGroupName, setNewGroupName] = useState(groupName) const [newGroupName, setNewGroupName] = useState(groupName)
const [isLocked, setIsLocked] = useState(false) const [isLocked, setIsLocked] = useState(false)
// TODO Enable this once the feature is working end-to-end. -dsb
// const {joinLink} = convo.details
const isJoinLinkEnabled = false
// const isJoinLinkEnabled =
// isOwner || (!isOwner && joinLink?.enabledStatus === 'enabled')
// TODO Enable this once the feature is working end-to-end. -dsb
const isReportLinkEnabled = false
const {mutate: editGroupName} = useEditGroupChatName(convo.view.id, { const {mutate: editGroupName} = useEditGroupChatName(convo.view.id, {
onError: e => { onError: e => {
setNewGroupName(groupName) setNewGroupName(groupName)
@@ -813,7 +812,7 @@ function SettingsHeader({
}) })
const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, { const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, {
onMutate: () => { onSuccess: () => {
navigation.dispatch(StackActions.pop(2)) navigation.dispatch(StackActions.pop(2))
}, },
onError: e => { onError: e => {
@@ -824,8 +823,8 @@ function SettingsHeader({
}, },
}) })
const inviteLinkDialog = Dialog.useDialogControl()
const editNamePrompt = Prompt.usePromptControl() const editNamePrompt = Prompt.usePromptControl()
const inviteLinkPrompt = Prompt.usePromptControl()
const lockChatPrompt = Prompt.usePromptControl() const lockChatPrompt = Prompt.usePromptControl()
const leaveChatPrompt = Prompt.usePromptControl() const leaveChatPrompt = Prompt.usePromptControl()
@@ -833,31 +832,16 @@ function SettingsHeader({
muteConvo({mute: !convo.view.muted}) muteConvo({mute: !convo.view.muted})
} }
const handleLeaveChat = () => { // TODO Need to implement this when the backend is ready. -dsb
leaveChatPrompt.open()
}
const handleReportChat = () => {} const handleReportChat = () => {}
const handlePromptName = () => { const handlePromptName = () => {
setNewGroupName(groupName)
editNamePrompt.open() editNamePrompt.open()
} }
const handleEditName = () => { const handleEditName = () => {
editGroupName({name: newGroupName}) editGroupName({name: newGroupName})
editNamePrompt.close()
}
const handlePromptInviteLink = () => {
inviteLinkPrompt.open()
}
const handleConfirmInviteLink = () => {
inviteLinkPrompt.close()
}
const handlePromptLock = () => {
lockChatPrompt.open()
} }
const handleConfirmLock = () => { const handleConfirmLock = () => {
@@ -868,6 +852,9 @@ function SettingsHeader({
setIsLocked(false) setIsLocked(false)
} }
// TODO The creation date doesn't exist yet. -dsb
const createdAt = new Date()
return ( return (
<> <>
<View <View
@@ -893,7 +880,7 @@ function SettingsHeader({
a.px_xl, a.px_xl,
t.atoms.text_contrast_high, t.atoms.text_contrast_high,
]}> ]}>
Created April 2, 2026 <Trans>Created {dateFormatter.format(createdAt)}</Trans>
</Text> </Text>
<View <View
style={[ style={[
@@ -922,12 +909,18 @@ function SettingsHeader({
onPress={handlePromptName} onPress={handlePromptName}
/> />
) : null} ) : null}
<SettingsButton {isJoinLinkEnabled ? (
icon={ChainLinkIcon} <SettingsButton
label={l`Create an invite link for this group chat`} icon={ChainLinkIcon}
text={l`Invite link`} label={
onPress={handlePromptInviteLink} isOwner
/> ? l`Create or modify an invite link for this group chat`
: l`View the invite link for this group chat`
}
text={l`Invite link`}
onPress={inviteLinkDialog.open}
/>
) : null}
{isOwner ? ( {isOwner ? (
<SettingsButton <SettingsButton
color={isLocked ? 'negative_subtle' : 'secondary'} color={isLocked ? 'negative_subtle' : 'secondary'}
@@ -936,10 +929,10 @@ function SettingsHeader({
isLocked ? l`Unlock this group chat` : l`Lock this group chat` isLocked ? l`Unlock this group chat` : l`Lock this group chat`
} }
text={isLocked ? l`Locked` : l`Lock`} text={isLocked ? l`Locked` : l`Lock`}
onPress={isLocked ? handleUnlock : handlePromptLock} onPress={isLocked ? handleUnlock : lockChatPrompt.open}
/> />
) : null} ) : null}
{isOwner ? null : ( {isOwner ? null : isReportLinkEnabled ? (
<SettingsButton <SettingsButton
color="secondary" color="secondary"
icon={FlagIcon} icon={FlagIcon}
@@ -947,14 +940,14 @@ function SettingsHeader({
text={l`Report`} text={l`Report`}
onPress={handleReportChat} onPress={handleReportChat}
/> />
)} ) : null}
{isOwner ? null : ( {isOwner ? null : (
<SettingsButton <SettingsButton
color="secondary" color="secondary"
icon={ArrowBoxLeftIcon} icon={ArrowBoxLeftIcon}
label={l`Leave this group chat`} label={l`Leave this group chat`}
text={l`Leave`} text={l`Leave`}
onPress={handleLeaveChat} onPress={leaveChatPrompt.open}
/> />
)} )}
</View> </View>
@@ -965,9 +958,10 @@ function SettingsHeader({
onChangeText={setNewGroupName} onChangeText={setNewGroupName}
onConfirm={handleEditName} onConfirm={handleEditName}
/> />
<InviteLinkPrompt <InviteLinkDialog
control={inviteLinkPrompt} convo={convo}
onConfirm={handleConfirmInviteLink} control={inviteLinkDialog}
isOwner={isOwner}
/> />
<LockChatPrompt control={lockChatPrompt} onConfirm={handleConfirmLock} /> <LockChatPrompt control={lockChatPrompt} onConfirm={handleConfirmLock} />
<LeaveChatPrompt <LeaveChatPrompt
@@ -999,7 +993,7 @@ function SettingsHeaderPlaceholder() {
a.px_xl, a.px_xl,
t.atoms.text_contrast_high, t.atoms.text_contrast_high,
]}> ]}>
<Trans></Trans>
</Text> </Text>
<View <View
style={[ style={[
@@ -1120,11 +1114,7 @@ function EditNamePrompt({
</View> </View>
</Prompt.Content> </Prompt.Content>
<Prompt.Actions> <Prompt.Actions>
<Prompt.Action <Prompt.Action cta={l`Save`} onPress={onConfirm} />
cta={l`Save`}
shouldCloseOnPress={false}
onPress={onConfirm}
/>
<Prompt.Cancel /> <Prompt.Cancel />
</Prompt.Actions> </Prompt.Actions>
</> </>
@@ -1132,27 +1122,6 @@ function EditNamePrompt({
) )
} }
function InviteLinkPrompt({
control,
onConfirm,
}: {
control: Dialog.DialogOuterProps['control']
onConfirm: () => void
}) {
const {t: l} = useLingui()
return (
<Prompt.Basic
control={control}
title={l`Invite link`}
description={l`An invite link lets people join this group chat without being added directly. You control who can use the link and whether they need your approval. You can disable the link at any time. Your name, avatar, and the name of the group chat will be visible to everyone.`}
confirmButtonCta={l`Get started`}
cancelButtonCta={l`Cancel`}
onConfirm={onConfirm}
/>
)
}
function LockChatPrompt({ function LockChatPrompt({
control, control,
onConfirm, onConfirm,
@@ -0,0 +1,90 @@
import {useCallback, useEffect, useState} from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import Animated, {
FadeOutUp,
useReducedMotion,
ZoomIn,
} from 'react-native-reanimated'
import * as Clipboard from 'expo-clipboard'
import {Trans} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, type ButtonProps} from '#/components/Button'
import {SquareBehindSquare_Stroke2_Corner2_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4'
import {Text} from '#/components/Typography'
export function CopyTextButton({
children,
disabled,
style,
value,
onPress: onPressProp,
...props
}: ButtonProps & {value: string}) {
const t = useTheme()
const [hasBeenCopied, setHasBeenCopied] = useState(false)
const isReducedMotionEnabled = useReducedMotion()
useEffect(() => {
if (hasBeenCopied) {
const timeout = setTimeout(
() => setHasBeenCopied(false),
isReducedMotionEnabled ? 2000 : 100,
)
return () => clearTimeout(timeout)
}
}, [hasBeenCopied, isReducedMotionEnabled])
const onPress = useCallback(
(evt: GestureResponderEvent) => {
void Clipboard.setStringAsync(value)
setHasBeenCopied(true)
onPressProp?.(evt)
},
[value, onPressProp],
)
return (
<View style={[a.relative]}>
{hasBeenCopied && (
<Animated.View
entering={ZoomIn.duration(100)}
exiting={FadeOutUp.duration(2000)}
style={[
a.absolute,
{bottom: '100%', right: 0},
a.justify_center,
a.gap_sm,
a.z_10,
a.pb_sm,
]}
pointerEvents="none">
<Text
style={[
a.font_medium,
a.text_right,
a.text_sm,
t.atoms.text_contrast_high,
]}>
<Trans>Copied!</Trans>
</Text>
</Animated.View>
)}
<Button
color="secondary"
disabled={disabled}
style={[a.flex_1, a.justify_between, {borderRadius: 10}, style]}
onPress={onPress}
{...props}>
{context => (
<View style={[a.flex_1, a.flex_row, a.justify_between, a.p_md]}>
{typeof children === 'function' ? children(context) : children}
{disabled ? null : <ButtonIcon icon={CopyIcon} size="lg" />}
</View>
)}
</Button>
</View>
)
}
@@ -0,0 +1,59 @@
import {View} from 'react-native'
import {Trans} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {Button, type ButtonProps} from '#/components/Button'
import {Text} from '#/components/Typography'
export function EditTextButton({
children,
style,
onPress,
...props
}: ButtonProps & {value: string}) {
const t = useTheme()
return (
<View style={[a.relative]}>
<Button
color="secondary"
style={[
a.flex_1,
a.justify_between,
a.rounded_full,
a.border,
t.atoms.bg,
t.atoms.border_contrast_low,
style,
]}
onPress={onPress}
{...props}>
{context => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_between,
a.px_md,
a.py_sm,
]}>
{typeof children === 'function' ? children(context) : children}
<View
style={[
a.ml_sm,
a.rounded_full,
t.atoms.bg_contrast_50,
{paddingHorizontal: 10, paddingVertical: 8},
]}>
<Text
style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}>
<Trans>Edit</Trans>
</Text>
</View>
</View>
)}
</Button>
</View>
)
}
@@ -0,0 +1,462 @@
import {useState} from 'react'
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {shareUrl} from '#/lib/sharing'
import {useCreateJoinLink} from '#/state/queries/messages/create-join-link'
import {useDisableJoinLink} from '#/state/queries/messages/disable-join-link'
import {useEditJoinLink} from '#/state/queries/messages/edit-join-link'
import {useEnableJoinLink} from '#/state/queries/messages/enable-join-link'
import {atoms as a, useTheme, web} from '#/alf'
import {
Button,
ButtonIcon,
ButtonText,
StackedButton,
} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {type ConvoWithDetails} from '#/components/dms/util'
import * as Toggle from '#/components/forms/Toggle'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight'
import {ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon} from '#/components/icons/ChainLink'
import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env'
import {CopyTextButton} from './CopyTextButton'
import {EditTextButton} from './EditTextButton'
enum Step {
INFO,
GENERATE,
MANAGE,
}
const timeFormatter = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
})
const dateFormatter = new Intl.DateTimeFormat(undefined, {
month: 'long',
day: 'numeric',
year: 'numeric',
})
export function InviteLinkDialog({
convo,
control,
isOwner,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
control: Dialog.DialogOuterProps['control']
isOwner: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
const ownerName = createSanitizedDisplayName(convo.primaryMember)
const {joinLink} = convo.details
const enabledStatus = joinLink?.enabledStatus
const defaultStep = joinLink ? Step.MANAGE : Step.INFO
const defaultWhoCanJoin = joinLink
? [
`${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`,
]
: ['anyone']
const [step, setStep] = useState<Step>(defaultStep)
const [whoCanJoin, setWhoCanJoin] = useState(defaultWhoCanJoin)
const {openComposer} = useOpenComposer()
const {mutate: createJoinLink, isPending: isCreating} = useCreateJoinLink(
convo.view.id,
{
onSuccess: () => {
setStep(Step.MANAGE)
},
onError: () => {
Toast.show(l`Failed to create invite link`, {
type: 'error',
})
},
},
)
const {mutate: editJoinLink, isPending: isEditing} = useEditJoinLink(
convo.view.id,
{
onSuccess: () => {
setStep(Step.MANAGE)
},
onError: () => {
Toast.show(l`Failed to edit invite link`, {
type: 'error',
})
},
},
)
const {mutate: disableJoinLink, isPending: isDisabling} = useDisableJoinLink(
convo.view.id,
{
onError: () => {
Toast.show(l`Failed to disable invite link`, {
type: 'error',
})
},
},
)
const {mutate: enableJoinLink, isPending: isEnabling} = useEnableJoinLink(
convo.view.id,
{
onError: () => {
Toast.show(l`Failed to enable invite link`, {
type: 'error',
})
},
},
)
const isSaving = isCreating || isEditing
const whoCanJoinOptions = [
{
name: 'anyone',
owner: l`Anyone can join instantly`,
member: l`Anyone can join instantly`,
},
{
name: 'anyone:requireApproval',
owner: l`Anyone can request to join`,
member: l`Anyone can request to join`,
},
{
name: 'followedByOwner',
owner: l`People I follow can join instantly`,
member: l`People ${ownerName} follows can join instantly`,
},
{
name: 'followedByOwner:requireApproval',
owner: l`People I follow can request to join`,
member: l`People ${ownerName} follows can request to join`,
},
]
let content: React.ReactNode = null
let header: string | null = null
switch (step) {
case Step.INFO:
header = l`Invite link`
content = (
<>
<View>
<Text style={[a.text_md, t.atoms.text]}>
<Trans>
An invite link lets people join this group chat without being
added directly. You control who can use the link and whether
they need your approval. You can disable the link at any time.
</Trans>
</Text>
<Text style={[a.mt_lg, a.text_md, t.atoms.text]}>
<Trans>
Your name, avatar, and the name of the group chat will be
visible to everyone.
</Trans>
</Text>
</View>
<View style={[a.mt_4xl]}>
<Button
label={l`Get started`}
color="primary"
size="large"
onPress={() => {
setStep(Step.GENERATE)
}}>
<ButtonText>
<Trans>Get started</Trans>
</ButtonText>
<ButtonIcon icon={ArrowRightIcon} />
</Button>
</View>
</>
)
break
case Step.GENERATE:
header = l`Generate invite link`
content = (
<>
<View>
<Text style={[a.text_md, t.atoms.text]}>
<Trans>Choose who can join this group chat and how.</Trans>
</Text>
</View>
<View style={[a.mt_lg]}>
<Toggle.Group
label={l`Who can join this group chat and how`}
type="radio"
values={whoCanJoin}
onChange={setWhoCanJoin}>
<View style={[a.gap_sm]}>
{whoCanJoinOptions.map(option => (
<Toggle.Item
key={option.name}
highlightRow={true}
label={isOwner ? option.owner : option.member}
name={option.name}
style={[a.flex_1]}>
{({selected}) => (
<TargetOption
label={isOwner ? option.owner : option.member}
selected={selected}
/>
)}
</Toggle.Item>
))}
</View>
</Toggle.Group>
</View>
<View style={[a.mt_4xl]}>
<Button
label={l`Generate invite link`}
color="primary"
size="large"
disabled={isSaving}
onPress={() => {
const parts = whoCanJoin[0].split(':')
const joinRule = parts[0]
const requireApproval = parts[1] === 'requireApproval'
if (joinLink && enabledStatus === 'enabled') {
editJoinLink({
joinRule,
requireApproval,
})
} else {
createJoinLink({
joinRule,
requireApproval,
})
}
}}>
<ButtonText>
{joinLink && enabledStatus === 'enabled'
? l`Update invite link`
: l`Generate invite link`}
</ButtonText>
<ButtonIcon icon={isSaving ? Loader : ArrowRightIcon} />
</Button>
</View>
</>
)
break
case Step.MANAGE: {
const hasJoinLinkCode = joinLink && joinLink.code !== ''
const joinLinkURI = hasJoinLinkCode
? `https://bsky.app/chat/${joinLink.code}`
: 'https://bsky.app/chat'
const createdAt = joinLink ? new Date(joinLink.createdAt) : null
const currentOption = whoCanJoinOptions.find(
o => o.name === whoCanJoin[0],
)
const ownerValue = currentOption?.owner ?? whoCanJoinOptions[0].owner
const memberValue = currentOption?.member ?? whoCanJoinOptions[0].member
header =
enabledStatus === 'enabled' ? l`Invite link` : l`Invite link disabled`
content = (
<>
<View style={[a.mt_lg]}>
<CopyTextButton
disabled={enabledStatus === 'disabled' || !hasJoinLinkCode}
label={l`Invite link`}
value={joinLinkURI}>
<Text
numberOfLines={1}
style={[
a.mr_xs,
a.text_md,
enabledStatus === 'disabled'
? t.atoms.text_contrast_low
: t.atoms.text,
]}>
{joinLinkURI}
</Text>
</CopyTextButton>
{createdAt ? (
<Text style={[a.mt_xs, a.text_xs, t.atoms.text_contrast_medium]}>
<Trans>
Created {timeFormatter.format(createdAt)}{' '}
{dateFormatter.format(createdAt)}
</Trans>
</Text>
) : null}
</View>
{enabledStatus === 'enabled' ? (
<View style={[a.mt_lg]}>
{isOwner ? (
<EditTextButton
label={l`Edit link settings`}
value={ownerValue}
onPress={() => setStep(Step.GENERATE)}>
<Text
numberOfLines={1}
style={[
a.mr_xs,
a.text_md,
t.atoms.text,
{maxWidth: '80%'},
]}>
{ownerValue}
</Text>
</EditTextButton>
) : (
<Text style={[a.text_sm, t.atoms.text]}>{memberValue}</Text>
)}
</View>
) : null}
{enabledStatus === 'enabled' ? (
<View style={[a.flex_row, a.justify_between, a.gap_sm, a.mt_lg]}>
{isOwner ? (
<StackedButton
label={l`Disable`}
icon={isDisabling ? Loader : ChainLinkBrokenIcon}
color="negative_subtle"
style={[a.flex_1, a.rounded_full]}
disabled={isDisabling}
onPress={() => {
disableJoinLink()
}}>
<Trans>Disable</Trans>
</StackedButton>
) : null}
<StackedButton
disabled={enabledStatus === 'disabled'}
label={l`Post link`}
icon={EditIcon}
color="primary_subtle"
style={[a.flex_1, a.rounded_full]}
onPress={() => {
control.close(() => {
openComposer({
text: joinLinkURI,
logContext: 'Other',
})
})
}}>
<Trans>Post link</Trans>
</StackedButton>
<StackedButton
disabled={enabledStatus === 'disabled'}
label={l`Share`}
icon={ArrowShareRightIcon}
color="primary_subtle"
style={[a.flex_1, a.rounded_full]}
onPress={() => {
void shareUrl(joinLinkURI)
}}>
<Trans>Share</Trans>
</StackedButton>
</View>
) : (
<View style={[a.gap_md, a.mt_lg]}>
<Button
label={l`Re-enable invite link`}
color="primary"
size="large"
disabled={isEnabling}
onPress={() => {
enableJoinLink()
}}>
<ButtonText>
<Trans>Re-enable link</Trans>
</ButtonText>
{isEnabling && <ButtonIcon icon={Loader} />}
</Button>
<Button
label={l`Generate new invite link`}
color="secondary"
size="large"
onPress={() => setStep(Step.GENERATE)}>
<ButtonText>
<Trans>Generate new link</Trans>
</ButtonText>
</Button>
</View>
)}
</>
)
break
}
}
if (!isOwner && (!joinLink || joinLink?.enabledStatus === 'disabled')) {
header = l`Invite link`
content = (
<>
<View style={[a.mt_lg]}>
<Text style={[a.text_sm, t.atoms.text]}>
<Trans>There is no invite link for this group chat.</Trans>
</Text>
</View>
<View style={[a.gap_md, a.mt_lg]}>
<Button
label={l`Close`}
color="primary"
size="large"
onPress={() => control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</View>
</>
)
}
return (
<Dialog.Outer
control={control}
onClose={() => {
setStep(defaultStep)
setWhoCanJoin(defaultWhoCanJoin)
}}>
<Dialog.Handle />
<Dialog.ScrollableInner
header={
<View>
<View style={[IS_WEB ? [a.px_2xl, a.pt_xl] : {paddingTop: 10}]}>
<Text style={[a.font_bold, a.text_2xl, a.mb_sm, t.atoms.text]}>
{header}
</Text>
</View>
<Dialog.Close />
</View>
}
label={l`Group chat invite link dialog`}
style={web({maxWidth: 400})}>
{content}
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function TargetOption({label, selected}: {label: string; selected: boolean}) {
const t = useTheme()
return (
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText
style={[
a.font_normal,
a.flex_1,
a.leading_tight,
selected ? t.atoms.text : t.atoms.text_contrast_high,
]}>
{label}
</Toggle.LabelText>
</View>
)
}
@@ -10,16 +10,19 @@ import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {AddMembersFlow} from '#/components/dms/AddMembersFlow' import {AddMembersFlow} from '#/components/dms/AddMembersFlow'
import {parseConvoView} from '#/components/dms/util'
import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink'
import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {InviteLinkDialog} from './InviteLinkDialog'
export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) { export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const addMembersControl = Dialog.useDialogControl() const addMembersControl = Dialog.useDialogControl()
const inviteLinkControl = Dialog.useDialogControl()
const {currentAccount} = useSession() const {currentAccount} = useSession()
@@ -30,12 +33,21 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
Toast.show(l`Failed to add members`, {type: 'error'}) Toast.show(l`Failed to add members`, {type: 'error'})
}, },
}) })
const convo = convoState.convo
? parseConvoView(convoState.convo, currentAccount?.did)
: null
const groupConvo = convo?.kind === 'group' ? convo : null
// TODO Enable this once the feature is working end-to-end. -dsb
// const joinLink = groupConvo?.details.joinLink
const isJoinLinkEnabled = false
// (isOwner && groupConvo) ||
// (!isOwner && groupConvo && joinLink?.enabledStatus === 'enabled')
const isOwner = const isOwner =
currentAccount?.did == null currentAccount?.did == null
? false ? false
: convoState.getPrimaryMember?.()?.did === currentAccount.did : convoState.getPrimaryMember?.()?.did === currentAccount.did
// TODO Get this from @api/atproto - dsb // TODO Get this from @api/atproto -dsb
const isLinkEnabled = false const isLinkEnabled = false
const groupName = convoState.getGroupInfo?.()?.name const groupName = convoState.getGroupInfo?.()?.name
@@ -113,12 +125,16 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
</ButtonText> </ButtonText>
</Button> </Button>
) : null} ) : null}
{isOwner || isLinkEnabled ? ( {isJoinLinkEnabled ? (
<Button <Button
color="secondary" color="secondary"
size="small" size="small"
label={l`Click here to view or create an invite link for this group chat`} label={
onPress={() => {}}> isOwner
? l`Click here to create or manage an invite link for this group chat`
: l`Click here to view the invite link for this group chat`
}
onPress={inviteLinkControl.open}>
<ButtonIcon icon={ChainLinkIcon} /> <ButtonIcon icon={ChainLinkIcon} />
<ButtonText> <ButtonText>
<Trans>Invite link</Trans> <Trans>Invite link</Trans>
@@ -128,6 +144,13 @@ export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) {
</View> </View>
) : null} ) : null}
</View> </View>
{groupConvo ? (
<InviteLinkDialog
isOwner={isOwner}
convo={groupConvo}
control={inviteLinkControl}
/>
) : null}
<Dialog.Outer <Dialog.Outer
control={addMembersControl} control={addMembersControl}
testID="addChatMembersDialog" testID="addChatMembersDialog"
+21
View File
@@ -4,6 +4,7 @@ import {
ChatBskyConvoDefs, ChatBskyConvoDefs,
type ChatBskyConvoGetLog, type ChatBskyConvoGetLog,
type ChatBskyConvoSendMessage, type ChatBskyConvoSendMessage,
type ChatBskyGroupDefs,
} from '@atproto/api' } from '@atproto/api'
import {XRPCError} from '@atproto/api' import {XRPCError} from '@atproto/api'
import {EventEmitter} from 'eventemitter3' import {EventEmitter} from 'eventemitter3'
@@ -146,6 +147,7 @@ export class Convo {
this.getPrimaryMember = this.getPrimaryMember.bind(this) this.getPrimaryMember = this.getPrimaryMember.bind(this)
this.updateGroupName = this.updateGroupName.bind(this) this.updateGroupName = this.updateGroupName.bind(this)
this.updateGroupMembers = this.updateGroupMembers.bind(this) this.updateGroupMembers = this.updateGroupMembers.bind(this)
this.updateJoinLink = this.updateJoinLink.bind(this)
} }
private commit() { private commit() {
@@ -972,6 +974,25 @@ export class Convo {
this.commit() this.commit()
} }
updateJoinLink(joinLink: ChatBskyGroupDefs.JoinLinkView | undefined) {
if (
this.convo &&
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
this.convo.kind,
ChatBskyConvoDefs.isGroupConvo,
)
) {
this.convo = {
...this.convo,
kind: {
...this.convo.kind,
joinLink,
},
}
}
this.commit()
}
async processPendingMessages() { async processPendingMessages() {
logger.debug( logger.debug(
`processing messages (${this.pendingMessages.size} remaining)`, `processing messages (${this.pendingMessages.size} remaining)`,
+7 -3
View File
@@ -143,10 +143,14 @@ export function ConvoProvider({
data && data &&
convo.convo && convo.convo &&
ChatBskyConvoDefs.isGroupConvo(data.kind) && ChatBskyConvoDefs.isGroupConvo(data.kind) &&
ChatBskyConvoDefs.isGroupConvo(convo.convo.kind) && ChatBskyConvoDefs.isGroupConvo(convo.convo.kind)
data.kind.name !== convo.convo.kind.name
) { ) {
convo.updateGroupName(data.kind.name) if (data.kind.name !== convo.convo.kind.name) {
convo.updateGroupName(data.kind.name)
}
if (data.kind.joinLink !== convo.convo.kind.joinLink) {
convo.updateJoinLink(data.kind.joinLink)
}
} }
if ( if (
data && data &&
@@ -0,0 +1,84 @@
import {
ChatBskyConvoDefs,
type ChatBskyGroupCreateJoinLink,
type ChatBskyGroupDefs,
} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {
rollbackConvoOptimistic,
updateConvoOptimistic,
} from './utils/convo-cache'
export function useCreateJoinLink(
convoId: string | undefined,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyGroupCreateJoinLink.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async ({
joinRule,
requireApproval,
}: {
joinRule: ChatBskyGroupDefs.JoinRule
requireApproval: boolean
}) => {
if (!convoId) throw new Error('No convoId provided')
const {data} = await agent.chat.bsky.group.createJoinLink(
{convoId, joinRule, requireApproval},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
return data
},
onMutate: ({joinRule, requireApproval}) => {
if (!convoId) return
return updateConvoOptimistic(queryClient, convoId, prev => {
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
return {
...prev,
kind: {
...prev.kind,
joinLink: {
$type: 'chat.bsky.group.defs#joinLinkView',
code: '',
enabledStatus: 'enabled',
joinRule,
requireApproval,
createdAt: new Date().toISOString(),
},
},
}
})
},
onSuccess: data => {
if (convoId) {
updateConvoOptimistic(queryClient, convoId, prev => {
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
return {
...prev,
kind: {...prev.kind, joinLink: data.joinLink},
}
})
}
onSuccess?.(data)
},
onError: (e, _variables, context) => {
logger.error(e)
if (convoId && context) {
rollbackConvoOptimistic(queryClient, convoId, context)
}
onError?.(e)
},
})
}
@@ -0,0 +1,72 @@
import {
ChatBskyConvoDefs,
type ChatBskyGroupDisableJoinLink,
} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {
rollbackConvoOptimistic,
updateConvoOptimistic,
} from './utils/convo-cache'
export function useDisableJoinLink(
convoId: string | undefined,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyGroupDisableJoinLink.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async () => {
if (!convoId) throw new Error('No convoId provided')
const {data} = await agent.chat.bsky.group.disableJoinLink(
{convoId},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
return data
},
onMutate: () => {
if (!convoId) return
return updateConvoOptimistic(queryClient, convoId, prev => {
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) {
return undefined
}
return {
...prev,
kind: {
...prev.kind,
joinLink: {...prev.kind.joinLink, enabledStatus: 'disabled'},
},
}
})
},
onSuccess: data => {
if (convoId) {
updateConvoOptimistic(queryClient, convoId, prev => {
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
return {
...prev,
kind: {...prev.kind, joinLink: data.joinLink},
}
})
}
onSuccess?.(data)
},
onError: (e, _variables, context) => {
logger.error(e)
if (convoId && context) {
rollbackConvoOptimistic(queryClient, convoId, context)
}
onError?.(e)
},
})
}
@@ -1,19 +1,13 @@
import { import {ChatBskyConvoDefs, type ChatBskyGroupEditGroup} from '@atproto/api'
ChatBskyConvoDefs, import {useMutation, useQueryClient} from '@tanstack/react-query'
type ChatBskyConvoListConvos,
type ChatBskyGroupEditGroup,
} from '@atproto/api'
import {
type InfiniteData,
useMutation,
useQueryClient,
} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants' import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {RQKEY as CONVO_KEY} from './conversation' import {
import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' rollbackConvoOptimistic,
updateConvoOptimistic,
} from './utils/convo-cache'
export function useEditGroupChatName( export function useEditGroupChatName(
convoId: string | undefined, convoId: string | undefined,
@@ -39,68 +33,21 @@ export function useEditGroupChatName(
}, },
onMutate: ({name: groupName}) => { onMutate: ({name: groupName}) => {
if (!convoId) return if (!convoId) return
return updateConvoOptimistic(queryClient, convoId, prev => {
const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>( if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
CONVO_KEY(convoId),
)
const prevListEntries = queryClient.getQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]})
// Update for a single chat thread
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
prev => {
if (!prev) return
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return prev
return {
...prev,
kind: {
...prev.kind,
name: groupName,
},
}
},
)
// Update for the chat list
queryClient.setQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]}, prev => {
if (!prev?.pages) return
return { return {
...prev, ...prev,
pages: prev.pages.map(page => ({ kind: {...prev.kind, name: groupName},
...page,
convos: page.convos.map(convo => {
if (convo.id !== convoId) return convo
if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo
return {
...convo,
kind: {
...convo.kind,
name: groupName,
},
}
}),
})),
} }
}) })
return {prevConvo, prevListEntries}
}, },
onSuccess: data => { onSuccess: data => {
onSuccess?.(data) onSuccess?.(data)
}, },
onError: (e, _variables, context) => { onError: (e, _variables, context) => {
logger.error(e) logger.error(e)
if (context?.prevConvo && convoId) { if (convoId && context) {
queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo) rollbackConvoOptimistic(queryClient, convoId, context)
}
if (context?.prevListEntries) {
for (const [key, data] of context.prevListEntries) {
queryClient.setQueryData(key, data)
}
} }
onError?.(e) onError?.(e)
}, },
@@ -0,0 +1,79 @@
import {
ChatBskyConvoDefs,
type ChatBskyGroupDefs,
type ChatBskyGroupEditJoinLink,
} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {
rollbackConvoOptimistic,
updateConvoOptimistic,
} from './utils/convo-cache'
export function useEditJoinLink(
convoId: string | undefined,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyGroupEditJoinLink.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async ({
joinRule,
requireApproval,
}: {
joinRule: ChatBskyGroupDefs.JoinRule
requireApproval: boolean
}) => {
if (!convoId) throw new Error('No convoId provided')
const {data} = await agent.chat.bsky.group.editJoinLink(
{convoId, joinRule, requireApproval},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
return data
},
onMutate: ({joinRule, requireApproval}) => {
if (!convoId) return
return updateConvoOptimistic(queryClient, convoId, prev => {
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) {
return undefined
}
return {
...prev,
kind: {
...prev.kind,
joinLink: {...prev.kind.joinLink, joinRule, requireApproval},
},
}
})
},
onSuccess: data => {
if (convoId) {
updateConvoOptimistic(queryClient, convoId, prev => {
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
return {
...prev,
kind: {...prev.kind, joinLink: data.joinLink},
}
})
}
onSuccess?.(data)
},
onError: (e, _variables, context) => {
logger.error(e)
if (convoId && context) {
rollbackConvoOptimistic(queryClient, convoId, context)
}
onError?.(e)
},
})
}
@@ -0,0 +1,69 @@
import {ChatBskyConvoDefs, type ChatBskyGroupEnableJoinLink} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {
rollbackConvoOptimistic,
updateConvoOptimistic,
} from './utils/convo-cache'
export function useEnableJoinLink(
convoId: string | undefined,
{
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyGroupEnableJoinLink.OutputSchema) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async () => {
if (!convoId) throw new Error('No convoId provided')
const {data} = await agent.chat.bsky.group.enableJoinLink(
{convoId},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
return data
},
onMutate: () => {
if (!convoId) return
return updateConvoOptimistic(queryClient, convoId, prev => {
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) {
return undefined
}
return {
...prev,
kind: {
...prev.kind,
joinLink: {...prev.kind.joinLink, enabledStatus: 'enabled'},
},
}
})
},
onSuccess: data => {
if (convoId) {
updateConvoOptimistic(queryClient, convoId, prev => {
if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined
return {
...prev,
kind: {...prev.kind, joinLink: data.joinLink},
}
})
}
onSuccess?.(data)
},
onError: (e, _variables, context) => {
logger.error(e)
if (convoId && context) {
rollbackConvoOptimistic(queryClient, convoId, context)
}
onError?.(e)
},
})
}
@@ -7,7 +7,10 @@ import {STALE} from '..'
const RQKEY_ROOT = 'convo-availability' const RQKEY_ROOT = 'convo-availability'
export const RQKEY = (did: string) => [RQKEY_ROOT, did] export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useGetConvoAvailabilityQuery(did: string) { export function useGetConvoAvailabilityQuery(
did: string,
{enabled = true}: {enabled?: boolean} = {},
) {
const agent = useAgent() const agent = useAgent()
return useQuery({ return useQuery({
@@ -21,5 +24,6 @@ export function useGetConvoAvailabilityQuery(did: string) {
return data return data
}, },
staleTime: STALE.INFINITY, staleTime: STALE.INFINITY,
enabled,
}) })
} }
@@ -71,7 +71,7 @@ export function useLeaveConvo(
return {prevPages} return {prevPages}
}, },
onSuccess: data => { onSuccess: data => {
queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
onSuccess?.(data) onSuccess?.(data)
}, },
onError: (error, _, context) => { onError: (error, _, context) => {
@@ -89,7 +89,7 @@ export function useLeaveConvo(
} }
}, },
) )
queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
onError?.(error) onError?.(error)
}, },
}) })
+12 -60
View File
@@ -1,18 +1,12 @@
import { import {type ChatBskyConvoMuteConvo} from '@atproto/api'
type ChatBskyConvoDefs, import {useMutation, useQueryClient} from '@tanstack/react-query'
type ChatBskyConvoListConvos,
type ChatBskyConvoMuteConvo,
} from '@atproto/api'
import {
type InfiniteData,
useMutation,
useQueryClient,
} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants' import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {RQKEY as CONVO_KEY} from './conversation' import {
import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' rollbackConvoOptimistic,
updateConvoOptimistic,
} from './utils/convo-cache'
export function useMuteConvo( export function useMuteConvo(
convoId: string | undefined, convoId: string | undefined,
@@ -46,59 +40,17 @@ export function useMuteConvo(
}, },
onMutate: ({mute}) => { onMutate: ({mute}) => {
if (!convoId) return if (!convoId) return
return updateConvoOptimistic(queryClient, convoId, prev => ({
const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>( ...prev,
CONVO_KEY(convoId), muted: mute,
) }))
const prevListEntries = queryClient.getQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]})
// Update for a single chat thread
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
prev => {
if (!prev) return
return {
...prev,
muted: mute,
}
},
)
// Update for the chat list
queryClient.setQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]}, prev => {
if (!prev?.pages) return
return {
...prev,
pages: prev.pages.map(page => ({
...page,
convos: page.convos.map(convo => {
if (convo.id !== convoId) return convo
return {
...convo,
muted: mute,
}
}),
})),
}
})
return {prevConvo, prevListEntries}
}, },
onSuccess: data => { onSuccess: data => {
onSuccess?.(data) onSuccess?.(data)
}, },
onError: (e, _variables, context) => { onError: (e, _variables, context) => {
if (context?.prevConvo && convoId) { if (convoId && context) {
queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo) rollbackConvoOptimistic(queryClient, convoId, context)
}
if (context?.prevListEntries) {
for (const [key, data] of context.prevListEntries) {
queryClient.setQueryData(key, data)
}
} }
onError?.(e) onError?.(e)
}, },
@@ -0,0 +1,87 @@
import {
type ChatBskyConvoDefs,
type ChatBskyConvoListConvos,
} from '@atproto/api'
import {
type InfiniteData,
type QueryClient,
type QueryKey,
} from '@tanstack/react-query'
import {RQKEY as CONVO_KEY} from '../conversation'
import {RQKEY_ROOT as CONVO_LIST_KEY} from '../list-conversations'
type ConvoUpdater = (
prev: ChatBskyConvoDefs.ConvoView,
) => ChatBskyConvoDefs.ConvoView | undefined
export type ConvoCacheSnapshot = {
prevConvo: ChatBskyConvoDefs.ConvoView | undefined
prevListEntries: Array<
[QueryKey, InfiniteData<ChatBskyConvoListConvos.OutputSchema> | undefined]
>
}
/**
* Writes an optimistic update to a convo across both the single-convo and
* convo-list caches. The updater receives the current ConvoView and returns
* the next one - return undefined to bail out (e.g. when the convo's kind
* doesn't match what the mutation requires). Returns a snapshot that can be
* passed to `rollbackConvoOptimistic`.
*/
export function updateConvoOptimistic(
queryClient: QueryClient,
convoId: string,
updater: ConvoUpdater,
): ConvoCacheSnapshot {
const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
)
const prevListEntries = queryClient.getQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]})
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
prev => {
if (!prev) return
const next = updater(prev)
return next ?? prev
},
)
queryClient.setQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({queryKey: [CONVO_LIST_KEY]}, prev => {
if (!prev?.pages) return
return {
...prev,
pages: prev.pages.map(page => ({
...page,
convos: page.convos.map(convo => {
if (convo.id !== convoId) return convo
const next = updater(convo)
return next ?? convo
}),
})),
}
})
return {prevConvo, prevListEntries}
}
/**
* Restores the caches to the state captured by `updateConvoOptimistic`.
*/
export function rollbackConvoOptimistic(
queryClient: QueryClient,
convoId: string,
snapshot: ConvoCacheSnapshot,
) {
if (snapshot.prevConvo) {
queryClient.setQueryData(CONVO_KEY(convoId), snapshot.prevConvo)
}
for (const [key, data] of snapshot.prevListEntries) {
queryClient.setQueryData(key, data)
}
}