Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add flow for adding people to a group clip clop (#10255)

authored by

DS Boyce and committed by
GitHub
6e3c9c3a ac68cfe9

+849 -262
+470
src/components/dms/AddMembersFlow.tsx
··· 1 + import { 2 + useCallback, 3 + useLayoutEffect, 4 + useMemo, 5 + useReducer, 6 + useRef, 7 + useState, 8 + } from 'react' 9 + import {LayoutAnimation, type TextInput, View} from 'react-native' 10 + import {Trans, useLingui} from '@lingui/react/macro' 11 + 12 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 13 + import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 14 + import {useProfileFollowsQuery} from '#/state/queries/profile-follows' 15 + import {useSession} from '#/state/session' 16 + import {type ListMethods} from '#/view/com/util/List' 17 + import {android, atoms as a, native, useTheme, web} from '#/alf' 18 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19 + import * as Dialog from '#/components/Dialog' 20 + import {canBeMessaged} from '#/components/dms/util' 21 + import * as Toggle from '#/components/forms/Toggle' 22 + import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' 23 + import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 24 + import {Text} from '#/components/Typography' 25 + import {IS_NATIVE, IS_WEB} from '#/env' 26 + import type * as bsky from '#/types/bsky' 27 + import {ChatProfileTabs} from './ChatProfileTabs' 28 + import {EmptyMemberList} from './components/EmptyMemberList' 29 + import {GroupChatProfileCard} from './components/GroupChatProfileCard' 30 + import {ProfileCardSkeleton} from './components/ProfileCardSkeleton' 31 + import {UserLabel} from './components/UserLabel' 32 + import {UserSearchInput} from './components/UserSearchInput' 33 + 34 + type LabelItem = { 35 + type: 'label' 36 + key: string 37 + message: string 38 + } 39 + 40 + type ProfileItem = { 41 + type: 'profile' 42 + key: string 43 + profile: bsky.profile.AnyProfileView 44 + } 45 + 46 + type EmptyItem = { 47 + type: 'empty' 48 + key: string 49 + message: string 50 + } 51 + 52 + type PlaceholderItem = { 53 + type: 'placeholder' 54 + key: string 55 + } 56 + 57 + type ErrorItem = { 58 + type: 'error' 59 + key: string 60 + } 61 + 62 + type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | ErrorItem 63 + 64 + export type State = { 65 + groupChatDids: string[] 66 + groupChatProfiles: bsky.profile.AnyProfileView[] 67 + } 68 + 69 + export type Action = 70 + | { 71 + type: 'setDids' 72 + groupChatDids: string[] 73 + groupChatProfiles: bsky.profile.AnyProfileView[] 74 + } 75 + | { 76 + type: 'removeDids' 77 + groupChatDids: string[] 78 + groupChatProfiles: bsky.profile.AnyProfileView[] 79 + } 80 + 81 + function reducer(state: State, action: Action): State { 82 + switch (action.type) { 83 + case 'setDids': { 84 + return { 85 + ...state, 86 + groupChatDids: action.groupChatDids, 87 + groupChatProfiles: action.groupChatProfiles, 88 + } 89 + } 90 + case 'removeDids': { 91 + return { 92 + ...state, 93 + groupChatDids: action.groupChatDids, 94 + groupChatProfiles: action.groupChatProfiles, 95 + } 96 + } 97 + } 98 + } 99 + 100 + export function AddMembersFlow({ 101 + title, 102 + onAddMembers, 103 + }: { 104 + title: string 105 + onAddMembers: (dids: string[]) => void 106 + }) { 107 + const t = useTheme() 108 + const {t: l} = useLingui() 109 + const moderationOpts = useModerationOpts() 110 + const control = Dialog.useDialogContext() 111 + const [headerHeight, setHeaderHeight] = useState(0) 112 + const [footerHeight, setFooterHeight] = useState(0) 113 + const listRef = useRef<ListMethods>(null) 114 + const {currentAccount} = useSession() 115 + const inputRef = useRef<TextInput>(null) 116 + 117 + const [searchText, setSearchText] = useState('') 118 + 119 + const { 120 + data: results, 121 + isError, 122 + isFetching, 123 + } = useActorAutocompleteQuery(searchText, true, 12) 124 + const {data: follows} = useProfileFollowsQuery(currentAccount?.did) 125 + 126 + const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, { 127 + groupChatDids: [], 128 + groupChatProfiles: [], 129 + }) 130 + 131 + const onRemoveDid = useCallback( 132 + (did: string) => { 133 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 134 + dispatch({ 135 + type: 'removeDids', 136 + groupChatDids: groupChatDids.filter(d => d !== did), 137 + groupChatProfiles: groupChatProfiles.filter( 138 + profile => profile.did !== did, 139 + ), 140 + }) 141 + }, 142 + [groupChatDids, groupChatProfiles], 143 + ) 144 + 145 + const items = useMemo(() => { 146 + let _items: Item[] = [] 147 + 148 + if (isError) { 149 + _items.push({ 150 + type: 'empty', 151 + key: 'empty', 152 + message: l`We’re having network issues, try again`, 153 + }) 154 + } else if (searchText.length) { 155 + if (results?.length) { 156 + for (const profile of results) { 157 + if (profile.did === currentAccount?.did) continue 158 + _items.push({ 159 + type: 'profile', 160 + key: profile.did, 161 + profile, 162 + }) 163 + } 164 + 165 + _items = _items.sort(item => { 166 + return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 167 + }) 168 + } 169 + } else { 170 + const placeholders: Item[] = Array(10) 171 + .fill(0) 172 + .map((__, i) => ({ 173 + type: 'placeholder', 174 + key: i + '', 175 + })) 176 + 177 + if (follows) { 178 + for (const page of follows.pages) { 179 + for (const profile of page.follows) { 180 + _items.push({ 181 + type: 'profile', 182 + key: profile.did, 183 + profile, 184 + }) 185 + } 186 + } 187 + 188 + _items = _items.sort(item => { 189 + return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 190 + }) 191 + } else { 192 + _items.push(...placeholders) 193 + } 194 + } 195 + 196 + if (searchText === '') { 197 + _items.unshift({ 198 + type: 'label', 199 + key: 'suggested', 200 + message: l`Suggested`, 201 + }) 202 + } 203 + 204 + return _items 205 + }, [isError, searchText, l, results, currentAccount?.did, follows]) 206 + 207 + if (searchText && !isFetching && !items.length && !isError) { 208 + items.push({type: 'empty', key: 'empty', message: l`No results`}) 209 + } 210 + 211 + const handlePressBack = useCallback(() => { 212 + control.close() 213 + }, [control]) 214 + 215 + const handlePressAdd = useCallback(() => { 216 + onAddMembers(groupChatDids) 217 + }, [groupChatDids, onAddMembers]) 218 + 219 + const renderItems = useCallback( 220 + ({item}: {item: Item}) => { 221 + switch (item.type) { 222 + case 'label': { 223 + return <UserLabel key={item.key} message={item.message} /> 224 + } 225 + case 'profile': { 226 + return ( 227 + <GroupChatProfileCard 228 + key={item.key} 229 + profile={item.profile} 230 + moderationOpts={moderationOpts!} 231 + /> 232 + ) 233 + } 234 + case 'placeholder': { 235 + return <ProfileCardSkeleton key={item.key} /> 236 + } 237 + case 'empty': { 238 + return <EmptyMemberList key={item.key} message={item.message} /> 239 + } 240 + default: 241 + return null 242 + } 243 + }, 244 + [moderationOpts], 245 + ) 246 + 247 + useLayoutEffect(() => { 248 + if (IS_WEB) { 249 + setImmediate(() => { 250 + inputRef?.current?.focus() 251 + }) 252 + } 253 + }, []) 254 + 255 + let buttonLabel = l`Continue to group name` 256 + let buttonText = l`Next` 257 + let showButton = groupChatProfiles.length > 0 258 + let isButtonDisabled = !showButton 259 + 260 + const showChatProfileTabs = groupChatProfiles.length > 0 261 + 262 + const listHeader = useMemo( 263 + () => ( 264 + <View onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}> 265 + <View 266 + style={[ 267 + a.relative, 268 + web(a.pt_lg), 269 + native(a.pt_4xl), 270 + android({ 271 + borderTopLeftRadius: a.rounded_md.borderRadius, 272 + borderTopRightRadius: a.rounded_md.borderRadius, 273 + }), 274 + a.px_lg, 275 + a.border_b, 276 + t.atoms.border_contrast_low, 277 + t.atoms.bg, 278 + ]}> 279 + <View 280 + style={[ 281 + a.flex_row, 282 + a.gap_sm, 283 + a.relative, 284 + a.align_center, 285 + a.justify_between, 286 + web(a.pb_lg), 287 + ]}> 288 + {IS_NATIVE ? ( 289 + <Button 290 + label={l`Back`} 291 + size="large" 292 + shape="round" 293 + variant="ghost" 294 + color="secondary" 295 + style={[native([a.absolute, a.z_20])]} 296 + onPress={handlePressBack}> 297 + <ButtonIcon icon={ArrowLeftIcon} size="lg" /> 298 + </Button> 299 + ) : null} 300 + <Text 301 + style={[ 302 + a.flex_grow, 303 + a.z_10, 304 + a.text_lg, 305 + a.font_bold, 306 + a.leading_tight, 307 + t.atoms.text_contrast_high, 308 + a.text_center, 309 + a.px_5xl, 310 + ]}> 311 + {title} 312 + </Text> 313 + {IS_WEB ? ( 314 + <Button 315 + label={l`Close`} 316 + size="small" 317 + shape="round" 318 + variant="ghost" 319 + color="secondary" 320 + style={[a.absolute, a.z_20, {right: -4}]} 321 + onPress={() => control.close()}> 322 + <ButtonIcon icon={XIcon} size="lg" /> 323 + </Button> 324 + ) : showButton ? ( 325 + <Button 326 + label={buttonLabel} 327 + size="small" 328 + color="primary" 329 + style={[ 330 + native([ 331 + a.absolute, 332 + a.z_20, 333 + { 334 + right: 8, 335 + }, 336 + ]), 337 + ]} 338 + disabled={isButtonDisabled} 339 + onPress={handlePressAdd}> 340 + <ButtonText> 341 + <Trans>Add</Trans> 342 + </ButtonText> 343 + </Button> 344 + ) : null} 345 + </View> 346 + <View style={[web(a.pt_xs), native(a.pt_md)]}> 347 + <UserSearchInput 348 + inputRef={inputRef} 349 + value={searchText} 350 + onChangeText={text => { 351 + setSearchText(text) 352 + listRef.current?.scrollToOffset({offset: 0, animated: false}) 353 + }} 354 + onEscape={control.close} 355 + /> 356 + </View> 357 + </View> 358 + {showChatProfileTabs ? ( 359 + <View style={[a.pb_sm, a.pt_md, t.atoms.bg]}> 360 + <ChatProfileTabs 361 + testID="newGroupChatMembers" 362 + profiles={groupChatProfiles} 363 + onRemove={onRemoveDid} 364 + /> 365 + </View> 366 + ) : null} 367 + </View> 368 + ), 369 + [ 370 + buttonLabel, 371 + control, 372 + groupChatProfiles, 373 + handlePressAdd, 374 + handlePressBack, 375 + isButtonDisabled, 376 + l, 377 + onRemoveDid, 378 + searchText, 379 + showButton, 380 + showChatProfileTabs, 381 + t.atoms.bg, 382 + t.atoms.border_contrast_low, 383 + t.atoms.text_contrast_high, 384 + title, 385 + ], 386 + ) 387 + 388 + const setGroupChatMembers = (dids: string[]) => { 389 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 390 + 391 + const added = dids.filter(d => !groupChatDids.includes(d)) 392 + const removed = groupChatDids.filter(d => !dids.includes(d)) 393 + const newDids = [ 394 + ...groupChatDids.filter(d => !removed.includes(d)), 395 + ...added, 396 + ] 397 + 398 + const kept = groupChatProfiles.filter(p => dids.includes(p.did)) 399 + const keptDids = new Set(kept.map(p => p.did)) 400 + const addedProfiles = items 401 + .filter( 402 + (item): item is ProfileItem => 403 + item.type === 'profile' && 404 + dids.includes(item.profile.did) && 405 + !keptDids.has(item.profile.did), 406 + ) 407 + .map(item => item.profile) 408 + .sort((a, b) => dids.indexOf(a.did) - dids.indexOf(b.did)) 409 + 410 + dispatch({ 411 + type: 'setDids', 412 + groupChatDids: newDids, 413 + groupChatProfiles: [...kept, ...addedProfiles], 414 + }) 415 + } 416 + 417 + return ( 418 + <Toggle.Group 419 + values={groupChatDids} 420 + onChange={setGroupChatMembers} 421 + type="checkbox" 422 + label={l`Add group chat members`} 423 + style={web([a.contents])}> 424 + <Dialog.InnerFlatList 425 + ref={listRef} 426 + data={items} 427 + renderItem={renderItems} 428 + ListHeaderComponent={listHeader} 429 + stickyHeaderIndices={[0]} 430 + keyExtractor={(item: Item) => item.key} 431 + style={[ 432 + web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), 433 + native({height: '100%'}), 434 + ]} 435 + webInnerContentContainerStyle={[a.py_0, {paddingBottom: footerHeight}]} 436 + webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} 437 + scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}} 438 + keyboardDismissMode="on-drag" 439 + footer={ 440 + IS_WEB ? ( 441 + <Dialog.FlatListFooter 442 + onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}> 443 + <View style={[a.flex_row, a.align_center, a.justify_between]}> 444 + <Button 445 + label={l`Back`} 446 + size="small" 447 + color="secondary" 448 + onPress={handlePressBack}> 449 + <ButtonIcon icon={ArrowLeftIcon} size="md" /> 450 + <ButtonText> 451 + {' '} 452 + <Trans>Back</Trans> 453 + </ButtonText> 454 + </Button> 455 + <Button 456 + label={buttonLabel} 457 + size="small" 458 + color="primary" 459 + disabled={isButtonDisabled} 460 + onPress={handlePressAdd}> 461 + <ButtonText>{buttonText} </ButtonText> 462 + </Button> 463 + </View> 464 + </Dialog.FlatListFooter> 465 + ) : null 466 + } 467 + /> 468 + </Toggle.Group> 469 + ) 470 + }
+11 -163
src/components/dms/InitiateChatFlow.tsx
··· 6 6 useRef, 7 7 useState, 8 8 } from 'react' 9 - import {LayoutAnimation, TextInput, View} from 'react-native' 9 + import {LayoutAnimation, type TextInput, View} from 'react-native' 10 10 import {moderateProfile, type ModerationOpts} from '@atproto/api' 11 11 import {Trans, useLingui} from '@lingui/react/macro' 12 12 ··· 23 23 import {canBeMessaged} from '#/components/dms/util' 24 24 import * as TextField from '#/components/forms/TextField' 25 25 import * as Toggle from '#/components/forms/Toggle' 26 - import {useInteractionState} from '#/components/hooks/useInteractionState' 27 26 import { 28 27 ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon, 29 28 ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon, 30 29 } from '#/components/icons/Arrow' 31 30 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' 32 - import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' 33 31 import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' 34 32 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 35 33 import * as ProfileCard from '#/components/ProfileCard' ··· 37 35 import {IS_NATIVE, IS_WEB} from '#/env' 38 36 import type * as bsky from '#/types/bsky' 39 37 import {ChatProfileTabs} from './ChatProfileTabs' 38 + import {EmptyMemberList} from './components/EmptyMemberList' 39 + import {GroupChatProfileCard} from './components/GroupChatProfileCard' 40 + import {ProfileCardSkeleton} from './components/ProfileCardSkeleton' 41 + import {UserLabel} from './components/UserLabel' 42 + import {UserSearchInput} from './components/UserSearchInput' 40 43 41 44 type NewGroupChatItem = { 42 45 type: 'newGroupChat' ··· 49 52 message: string 50 53 } 51 54 52 - export type ProfileItem = { 55 + type ProfileItem = { 53 56 type: 'profile' 54 57 key: string 55 58 profile: bsky.profile.AnyProfileView ··· 184 187 } 185 188 } 186 189 } 190 + 187 191 export function InitiateChatFlow({ 188 192 title, 189 193 onSelectChat, ··· 382 386 ) 383 387 } 384 388 case 'label': { 385 - return <Label key={item.key} message={item.message} /> 389 + return <UserLabel key={item.key} message={item.message} /> 386 390 } 387 391 case 'profile': { 388 392 switch (chatState) { ··· 417 421 return <ProfileCardSkeleton key={item.key} /> 418 422 } 419 423 case 'empty': { 420 - return <Empty key={item.key} message={item.message} /> 424 + return <EmptyMemberList key={item.key} message={item.message} /> 421 425 } 422 426 default: 423 427 return null ··· 560 564 </TextField.Root> 561 565 </View> 562 566 ) : ( 563 - <SearchInput 567 + <UserSearchInput 564 568 inputRef={inputRef} 565 569 value={searchText} 566 570 onChangeText={text => { ··· 813 817 ) 814 818 } 815 819 816 - function GroupChatProfileCard({ 817 - profile, 818 - moderationOpts, 819 - }: { 820 - profile: bsky.profile.AnyProfileView 821 - moderationOpts: ModerationOpts 822 - }) { 823 - const t = useTheme() 824 - const enabled = canBeMessaged(profile) 825 - const moderation = moderateProfile(profile, moderationOpts) 826 - const handle = sanitizeHandle(profile.handle, '@') 827 - const displayName = sanitizeDisplayName( 828 - profile.displayName || sanitizeHandle(profile.handle), 829 - moderation.ui('displayName'), 830 - ) 831 - 832 - return ( 833 - <Toggle.Item 834 - key={profile.did} 835 - disabled={!enabled} 836 - name={profile.did} 837 - label={displayName} 838 - style={[a.flex_1, a.py_sm, a.px_lg]}> 839 - <View style={[a.flex_grow, !enabled ? {opacity: 0.5} : null]}> 840 - <ProfileCard.Header> 841 - <ProfileCard.Avatar 842 - profile={profile} 843 - moderationOpts={moderationOpts} 844 - size={44} 845 - disabledPreview 846 - /> 847 - <View> 848 - <ProfileCard.Name 849 - profile={profile} 850 - moderationOpts={moderationOpts} 851 - /> 852 - {enabled ? ( 853 - <ProfileCard.Handle profile={profile} /> 854 - ) : ( 855 - <Text 856 - style={[a.leading_snug, t.atoms.text_contrast_high]} 857 - numberOfLines={2}> 858 - <Trans>{handle} can’t be messaged</Trans> 859 - </Text> 860 - )} 861 - </View> 862 - </ProfileCard.Header> 863 - </View> 864 - {enabled ? <Toggle.Checkbox /> : null} 865 - </Toggle.Item> 866 - ) 867 - } 868 - 869 820 function GroupChatMemberProfileCard({ 870 821 profile, 871 822 moderationOpts, ··· 902 853 </View> 903 854 ) 904 855 } 905 - 906 - function ProfileCardSkeleton() { 907 - return ( 908 - <View 909 - style={[ 910 - a.flex_1, 911 - a.py_md, 912 - a.px_lg, 913 - a.gap_md, 914 - a.align_center, 915 - a.flex_row, 916 - ]}> 917 - <ProfileCard.AvatarPlaceholder size={42} /> 918 - <ProfileCard.NameAndHandlePlaceholder /> 919 - </View> 920 - ) 921 - } 922 - 923 - function Label({message}: {message: string}) { 924 - const t = useTheme() 925 - return ( 926 - <View style={[a.px_lg, a.py_sm]}> 927 - <Text style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}> 928 - {message} 929 - </Text> 930 - </View> 931 - ) 932 - } 933 - 934 - function Empty({message}: {message: string}) { 935 - const t = useTheme() 936 - return ( 937 - <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}> 938 - <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}> 939 - {message} 940 - </Text> 941 - 942 - <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text> 943 - </View> 944 - ) 945 - } 946 - 947 - function SearchInput({ 948 - value, 949 - onChangeText, 950 - onEscape, 951 - inputRef, 952 - }: { 953 - value: string 954 - onChangeText: (text: string) => void 955 - onEscape: () => void 956 - inputRef: React.RefObject<TextInput | null> 957 - }) { 958 - const t = useTheme() 959 - const {t: l} = useLingui() 960 - const { 961 - state: hovered, 962 - onIn: onMouseEnter, 963 - onOut: onMouseLeave, 964 - } = useInteractionState() 965 - const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 966 - const interacted = hovered || focused 967 - 968 - return ( 969 - <View 970 - {...web({ 971 - onMouseEnter, 972 - onMouseLeave, 973 - })} 974 - style={[a.flex_row, a.align_center, a.gap_sm]}> 975 - <SearchIcon 976 - size="md" 977 - fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} 978 - /> 979 - <TextInput 980 - // @ts-ignore bottom sheet input types issue - esb 981 - ref={inputRef} 982 - placeholder={l`Search for people`} 983 - value={value} 984 - onChangeText={onChangeText} 985 - onFocus={onFocus} 986 - onBlur={onBlur} 987 - style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]} 988 - placeholderTextColor={t.palette.contrast_500} 989 - keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} 990 - returnKeyType="search" 991 - clearButtonMode="while-editing" 992 - maxLength={50} 993 - onKeyPress={({nativeEvent}) => { 994 - if (nativeEvent.key === 'Escape') { 995 - onEscape() 996 - } 997 - }} 998 - autoCorrect={false} 999 - autoComplete="off" 1000 - autoCapitalize="none" 1001 - autoFocus 1002 - accessibilityLabel={l`Search profiles`} 1003 - accessibilityHint={l`Searches for profiles`} 1004 - /> 1005 - </View> 1006 - ) 1007 - }
+16
src/components/dms/components/EmptyMemberList.tsx
··· 1 + import {View} from 'react-native' 2 + 3 + import {atoms as a, useTheme} from '#/alf' 4 + import {Text} from '#/components/Typography' 5 + 6 + export function EmptyMemberList({message}: {message: string}) { 7 + const t = useTheme() 8 + return ( 9 + <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}> 10 + <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}> 11 + {message} 12 + </Text> 13 + <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text> 14 + </View> 15 + ) 16 + }
+65
src/components/dms/components/GroupChatProfileCard.tsx
··· 1 + import {View} from 'react-native' 2 + import {moderateProfile, type ModerationOpts} from '@atproto/api' 3 + import {Trans} from '@lingui/react/macro' 4 + 5 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 6 + import {sanitizeHandle} from '#/lib/strings/handles' 7 + import {atoms as a, useTheme} from '#/alf' 8 + import {canBeMessaged} from '#/components/dms/util' 9 + import * as Toggle from '#/components/forms/Toggle' 10 + import * as ProfileCard from '#/components/ProfileCard' 11 + import {Text} from '#/components/Typography' 12 + import type * as bsky from '#/types/bsky' 13 + 14 + export function GroupChatProfileCard({ 15 + profile, 16 + moderationOpts, 17 + }: { 18 + profile: bsky.profile.AnyProfileView 19 + moderationOpts: ModerationOpts 20 + }) { 21 + const t = useTheme() 22 + const enabled = canBeMessaged(profile) 23 + const moderation = moderateProfile(profile, moderationOpts) 24 + const handle = sanitizeHandle(profile.handle, '@') 25 + const displayName = sanitizeDisplayName( 26 + profile.displayName || sanitizeHandle(profile.handle), 27 + moderation.ui('displayName'), 28 + ) 29 + 30 + return ( 31 + <Toggle.Item 32 + key={profile.did} 33 + disabled={!enabled} 34 + name={profile.did} 35 + label={displayName} 36 + style={[a.flex_1, a.py_sm, a.px_lg]}> 37 + <View style={[a.flex_grow, !enabled ? {opacity: 0.5} : null]}> 38 + <ProfileCard.Header> 39 + <ProfileCard.Avatar 40 + profile={profile} 41 + moderationOpts={moderationOpts} 42 + size={44} 43 + disabledPreview 44 + /> 45 + <View> 46 + <ProfileCard.Name 47 + profile={profile} 48 + moderationOpts={moderationOpts} 49 + /> 50 + {enabled ? ( 51 + <ProfileCard.Handle profile={profile} /> 52 + ) : ( 53 + <Text 54 + style={[a.leading_snug, t.atoms.text_contrast_high]} 55 + numberOfLines={2}> 56 + <Trans>{handle} can’t be messaged</Trans> 57 + </Text> 58 + )} 59 + </View> 60 + </ProfileCard.Header> 61 + </View> 62 + {enabled ? <Toggle.Checkbox /> : null} 63 + </Toggle.Item> 64 + ) 65 + }
+21
src/components/dms/components/ProfileCardSkeleton.tsx
··· 1 + import {View} from 'react-native' 2 + 3 + import {atoms as a} from '#/alf' 4 + import * as ProfileCard from '#/components/ProfileCard' 5 + 6 + export function ProfileCardSkeleton() { 7 + return ( 8 + <View 9 + style={[ 10 + a.flex_1, 11 + a.py_md, 12 + a.px_lg, 13 + a.gap_md, 14 + a.align_center, 15 + a.flex_row, 16 + ]}> 17 + <ProfileCard.AvatarPlaceholder size={42} /> 18 + <ProfileCard.NameAndHandlePlaceholder /> 19 + </View> 20 + ) 21 + }
+15
src/components/dms/components/UserLabel.tsx
··· 1 + import {View} from 'react-native' 2 + 3 + import {atoms as a, useTheme} from '#/alf' 4 + import {Text} from '#/components/Typography' 5 + 6 + export function UserLabel({message}: {message: string}) { 7 + const t = useTheme() 8 + return ( 9 + <View style={[a.px_lg, a.py_sm]}> 10 + <Text style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}> 11 + {message} 12 + </Text> 13 + </View> 14 + ) 15 + }
+68
src/components/dms/components/UserSearchInput.tsx
··· 1 + import {TextInput, View} from 'react-native' 2 + import {useLingui} from '@lingui/react/macro' 3 + 4 + import {atoms as a, useTheme, web} from '#/alf' 5 + import {useInteractionState} from '#/components/hooks/useInteractionState' 6 + import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' 7 + 8 + export function UserSearchInput({ 9 + value, 10 + onChangeText, 11 + onEscape, 12 + inputRef, 13 + }: { 14 + value: string 15 + onChangeText: (text: string) => void 16 + onEscape: () => void 17 + inputRef: React.RefObject<TextInput | null> 18 + }) { 19 + const t = useTheme() 20 + const {t: l} = useLingui() 21 + const { 22 + state: hovered, 23 + onIn: onMouseEnter, 24 + onOut: onMouseLeave, 25 + } = useInteractionState() 26 + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 27 + const interacted = hovered || focused 28 + 29 + return ( 30 + <View 31 + {...web({ 32 + onMouseEnter, 33 + onMouseLeave, 34 + })} 35 + style={[a.flex_row, a.align_center, a.gap_sm]}> 36 + <SearchIcon 37 + size="md" 38 + fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} 39 + /> 40 + <TextInput 41 + // @ts-ignore bottom sheet input types issue - esb 42 + ref={inputRef} 43 + placeholder={l`Search for people`} 44 + value={value} 45 + onChangeText={onChangeText} 46 + onFocus={onFocus} 47 + onBlur={onBlur} 48 + style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]} 49 + placeholderTextColor={t.palette.contrast_500} 50 + keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} 51 + returnKeyType="search" 52 + clearButtonMode="while-editing" 53 + maxLength={50} 54 + onKeyPress={({nativeEvent}) => { 55 + if (nativeEvent.key === 'Escape') { 56 + onEscape() 57 + } 58 + }} 59 + autoCorrect={false} 60 + autoComplete="off" 61 + autoCapitalize="none" 62 + autoFocus 63 + accessibilityLabel={l`Search profiles`} 64 + accessibilityHint={l`Searches for profiles`} 65 + /> 66 + </View> 67 + ) 68 + }
+101 -57
src/screens/Messages/ConversationSettings.tsx
··· 31 31 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 32 32 import {AvatarBubbles} from '#/components/AvatarBubbles' 33 33 import {Button, type ButtonColor, ButtonIcon} from '#/components/Button' 34 - import type * as Dialog from '#/components/Dialog' 34 + import * as Dialog from '#/components/Dialog' 35 + import {AddMembersFlow} from '#/components/dms/AddMembersFlow' 35 36 import {Error} from '#/components/Error' 36 37 import * as TextField from '#/components/forms/TextField' 37 38 import {useInteractionState} from '#/components/hooks/useInteractionState' ··· 138 139 }, 139 140 ...[...data] 140 141 .sort((a, b) => { 141 - const aIsAdmin = a.did === primaryMember?.did 142 - const bIsAdmin = b.did === primaryMember?.did 142 + const aIsOwner = a.did === primaryMember?.did 143 + const bIsOwner = b.did === primaryMember?.did 143 144 const aIsSelf = a.did === currentAccount?.did 144 145 const bIsSelf = b.did === currentAccount?.did 145 - if (aIsAdmin !== bIsAdmin) return aIsAdmin ? -1 : 1 146 + if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1 146 147 if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1 147 148 return 0 148 149 }) ··· 216 217 const t = useTheme() 217 218 const {t: l} = useLingui() 218 219 220 + const convoState = useConvo() 221 + const {currentAccount} = useSession() 222 + 223 + const isOwner = 224 + currentAccount?.did == null 225 + ? false 226 + : convoState.getPrimaryMember?.()?.did === currentAccount.did 227 + 219 228 return ( 220 229 <View style={[a.flex_row, a.justify_between, a.mx_xl, a.mt_lg, a.mb_sm]}> 221 230 <View style={[a.flex_row, a.align_center]}> ··· 229 238 {color: t.palette.contrast_500}, 230 239 ]}>{l`${memberCount}/${MEMBER_LIMIT}`}</Text> 231 240 </View> 232 - {requestCount > 0 ? ( 241 + {isOwner && requestCount > 0 ? ( 233 242 <InlineLinkText 234 243 label={l`View incoming group chat requests`} 235 244 style={[a.text_sm, a.text_right, a.font_semi_bold]} ··· 246 255 247 256 function AddMembersLink() { 248 257 const t = useTheme() 258 + const {t: l} = useLingui() 259 + 260 + const convoState = useConvo() 261 + const {currentAccount} = useSession() 262 + 263 + const addMembersControl = Dialog.useDialogControl() 264 + 265 + const isOwner = 266 + currentAccount?.did == null 267 + ? false 268 + : convoState.getPrimaryMember?.()?.did === currentAccount.did 269 + 270 + if (!isOwner) { 271 + return null 272 + } 249 273 250 274 return ( 251 - <SubtleHoverWrapper> 252 - <View 253 - style={[ 254 - a.mx_xl, 255 - { 256 - marginTop: ROW_SPACING, 257 - marginBottom: ROW_SPACING, 258 - }, 259 - ]}> 260 - <Pressable 261 - accessibilityRole="button" 262 - style={({pressed}) => [ 263 - a.flex_row, 264 - a.align_center, 265 - a.justify_between, 266 - pressed && web({outline: 'none'}), 275 + <> 276 + <SubtleHoverWrapper> 277 + <View 278 + style={[ 279 + a.mx_xl, 280 + { 281 + marginTop: ROW_SPACING, 282 + marginBottom: ROW_SPACING, 283 + }, 267 284 ]}> 268 - {({pressed}) => ( 269 - <> 270 - <View> 271 - <View style={[a.flex_row, a.align_center]}> 272 - <View 273 - style={[ 274 - a.flex_row, 275 - a.align_center, 276 - a.justify_center, 277 - a.p_lg, 278 - a.rounded_full, 279 - pressed 280 - ? t.atoms.bg_contrast_100 281 - : t.atoms.bg_contrast_50, 282 - { 283 - height: 48, 284 - width: 48, 285 - }, 286 - ]}> 287 - <PlusIcon style={[t.atoms.text_contrast_high]} size="sm" /> 285 + <Pressable 286 + accessibilityRole="button" 287 + style={({pressed}) => [ 288 + a.flex_row, 289 + a.align_center, 290 + a.justify_between, 291 + pressed && web({outline: 'none'}), 292 + ]} 293 + onPress={() => addMembersControl.open()}> 294 + {({pressed}) => ( 295 + <> 296 + <View> 297 + <View style={[a.flex_row, a.align_center]}> 298 + <View 299 + style={[ 300 + a.flex_row, 301 + a.align_center, 302 + a.justify_center, 303 + a.p_lg, 304 + a.rounded_full, 305 + pressed 306 + ? t.atoms.bg_contrast_100 307 + : t.atoms.bg_contrast_50, 308 + { 309 + height: 48, 310 + width: 48, 311 + }, 312 + ]}> 313 + <PlusIcon 314 + style={[t.atoms.text_contrast_high]} 315 + size="sm" 316 + /> 317 + </View> 318 + <Text 319 + style={[ 320 + a.text_md, 321 + a.font_semi_bold, 322 + a.pl_sm, 323 + t.atoms.text, 324 + ]}> 325 + <Trans>Add members</Trans> 326 + </Text> 288 327 </View> 289 - <Text 290 - style={[ 291 - a.text_md, 292 - a.font_semi_bold, 293 - a.pl_sm, 294 - t.atoms.text, 295 - ]}> 296 - <Trans>Add members</Trans> 297 - </Text> 298 328 </View> 299 - </View> 300 - <ChevronIcon style={[t.atoms.text_contrast_medium]} size="md" /> 301 - </> 302 - )} 303 - </Pressable> 304 - </View> 305 - </SubtleHoverWrapper> 329 + <ChevronIcon style={[t.atoms.text_contrast_medium]} size="md" /> 330 + </> 331 + )} 332 + </Pressable> 333 + </View> 334 + </SubtleHoverWrapper> 335 + 336 + <Dialog.Outer 337 + control={addMembersControl} 338 + testID="addChatMembersDialog" 339 + nativeOptions={{fullHeight: true}}> 340 + <Dialog.Handle /> 341 + <AddMembersFlow 342 + title={l`Add members`} 343 + onAddMembers={(_dids: string[]) => { 344 + // TODO Add members here 345 + addMembersControl.close() 346 + }} 347 + /> 348 + </Dialog.Outer> 349 + </> 306 350 ) 307 351 } 308 352
+82 -42
src/screens/Messages/components/MessagesListInfoPanel.tsx
··· 6 6 import {atoms as a, useTheme} from '#/alf' 7 7 import {AvatarBubbles} from '#/components/AvatarBubbles' 8 8 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 9 + import * as Dialog from '#/components/Dialog' 10 + import {AddMembersFlow} from '#/components/dms/AddMembersFlow' 9 11 import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' 10 12 import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' 11 13 import {Text} from '#/components/Typography' ··· 14 16 const t = useTheme() 15 17 const {t: l} = useLingui() 16 18 19 + const addMembersControl = Dialog.useDialogControl() 20 + 17 21 const {currentAccount} = useSession() 22 + 23 + const isOwner = 24 + currentAccount?.did == null 25 + ? false 26 + : convoState.getPrimaryMember?.()?.did === currentAccount.did 27 + // TODO Get this from @api/atproto - dsb 28 + const isLinkEnabled = false 18 29 19 30 const groupName = convoState.getGroupInfo?.()?.name 20 31 ··· 47 58 ) 48 59 } 49 60 61 + const showButtons = isOwner || isLinkEnabled 62 + 50 63 return ( 51 - <View style={[a.align_center, a.justify_center]}> 52 - <AvatarBubbles animate={true} profiles={members} /> 53 - {groupName ? ( 54 - <Text style={[a.text_2xl, a.font_bold, a.mt_lg, t.atoms.text]}> 55 - {groupName} 56 - </Text> 57 - ) : null} 58 - {names ? ( 59 - <Text style={[a.text_sm, a.mt_xs, t.atoms.text_contrast_high]}> 60 - {names} 61 - </Text> 62 - ) : null} 63 - <View 64 - style={[ 65 - a.flex_row, 66 - a.align_center, 67 - a.justify_center, 68 - a.gap_sm, 69 - a.mt_lg, 70 - a.mb_4xl, 71 - ]}> 72 - <Button 73 - color="secondary" 74 - size="small" 75 - label={l`Click here to add people to this group chat`} 76 - onPress={() => {}}> 77 - <ButtonIcon icon={PersonPlusIcon} /> 78 - <ButtonText> 79 - <Trans>Add people</Trans> 80 - </ButtonText> 81 - </Button> 82 - <Button 83 - color="secondary" 84 - size="small" 85 - label={l`Click here to view or create an invite link for this group chat`} 86 - onPress={() => {}}> 87 - <ButtonIcon icon={ChainLinkIcon} /> 88 - <ButtonText> 89 - <Trans>Invite link</Trans> 90 - </ButtonText> 91 - </Button> 64 + <> 65 + <View style={[a.align_center, a.justify_center]}> 66 + <AvatarBubbles animate={true} profiles={members} /> 67 + {groupName ? ( 68 + <Text style={[a.text_2xl, a.font_bold, a.mt_lg, t.atoms.text]}> 69 + {groupName} 70 + </Text> 71 + ) : null} 72 + {names ? ( 73 + <Text 74 + style={[ 75 + a.text_sm, 76 + a.mt_xs, 77 + t.atoms.text_contrast_high, 78 + showButtons ? null : a.mb_4xl, 79 + ]}> 80 + {names} 81 + </Text> 82 + ) : null} 83 + {showButtons ? ( 84 + <View 85 + style={[ 86 + a.flex_row, 87 + a.align_center, 88 + a.justify_center, 89 + a.gap_sm, 90 + a.mt_lg, 91 + a.mb_4xl, 92 + ]}> 93 + {isOwner ? ( 94 + <Button 95 + color="secondary" 96 + size="small" 97 + label={l`Click here to add people to this group chat`} 98 + onPress={() => addMembersControl.open()}> 99 + <ButtonIcon icon={PersonPlusIcon} /> 100 + <ButtonText> 101 + <Trans>Add people</Trans> 102 + </ButtonText> 103 + </Button> 104 + ) : null} 105 + {isOwner || isLinkEnabled ? ( 106 + <Button 107 + color="secondary" 108 + size="small" 109 + label={l`Click here to view or create an invite link for this group chat`} 110 + onPress={() => {}}> 111 + <ButtonIcon icon={ChainLinkIcon} /> 112 + <ButtonText> 113 + <Trans>Invite link</Trans> 114 + </ButtonText> 115 + </Button> 116 + ) : null} 117 + </View> 118 + ) : null} 92 119 </View> 93 - </View> 120 + <Dialog.Outer 121 + control={addMembersControl} 122 + testID="addChatMembersDialog" 123 + nativeOptions={{fullHeight: true}}> 124 + <Dialog.Handle /> 125 + <AddMembersFlow 126 + title={l`Add people`} 127 + onAddMembers={(_dids: string[]) => { 128 + // TODO Add members here 129 + addMembersControl.close() 130 + }} 131 + /> 132 + </Dialog.Outer> 133 + </> 94 134 ) 95 135 }