Add UI for creating group clip clop invite links (#10318)
This commit is contained in:
@@ -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 |
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
})
|
||||||
|
|||||||
@@ -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 couldn’t load this conversation’s settings`}
|
||||||
message={l`We couldn’t load this conversation’s 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 member’s 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"
|
||||||
|
|||||||
@@ -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)`,
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user