Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Add button for creating a new group clip clop (#10066)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by

DS Boyce
Samuel Newman
and committed by
GitHub
f4e14626 ce000ada

+1206 -14
+1
src/analytics/features/types.ts
··· 10 10 ImportContactsSettingsDisable = 'import_contacts:settings:disable', 11 11 LiveNowBetaDisable = 'live_now_beta:disable', 12 12 ImageUploadsHighResolution = 'image_uploads:high_resolution', 13 + GroupChatsEnable = 'group_chats:enable', 13 14 14 15 AATest = 'aa-test', 15 16 }
+169
src/components/dms/ChatProfileTabs.tsx
··· 1 + import {useCallback, useEffect} from 'react' 2 + import {type ScrollView, View} from 'react-native' 3 + import Animated, {useAnimatedRef, useSharedValue} from 'react-native-reanimated' 4 + import {moderateProfile} from '@atproto/api' 5 + import {useLingui} from '@lingui/react/macro' 6 + 7 + import {HITSLOP_10} from '#/lib/constants' 8 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 9 + import {sanitizeHandle} from '#/lib/strings/handles' 10 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 11 + import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView' 12 + import {atoms as a, useTheme} from '#/alf' 13 + import {Button} from '#/components/Button' 14 + import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 15 + import * as ProfileCard from '#/components/ProfileCard' 16 + import {Text} from '#/components/Typography' 17 + import type * as bsky from '#/types/bsky' 18 + 19 + type Props = { 20 + testID?: string 21 + profiles: bsky.profile.AnyProfileView[] 22 + onRemove?: (did: string) => void 23 + } 24 + 25 + export function ChatProfileTabs({testID, profiles, onRemove}: Props) { 26 + const t = useTheme() 27 + const scrollElRef = useAnimatedRef<ScrollView>() 28 + const contentSize = useSharedValue(0) 29 + const scrollX = useSharedValue(0) 30 + 31 + useEffect(() => { 32 + requestAnimationFrame(() => { 33 + // Scroll to the end of the list when `profiles` changes. 34 + scrollElRef.current?.scrollToEnd({animated: true}) 35 + }) 36 + }, [profiles, scrollElRef]) 37 + 38 + return ( 39 + <View testID={testID} accessibilityRole="list" style={[t.atoms.bg]}> 40 + <DraggableScrollView 41 + ref={scrollElRef} 42 + testID={`${testID}-selector`} 43 + horizontal={true} 44 + showsHorizontalScrollIndicator={false} 45 + onScroll={e => { 46 + scrollX.set(Math.round(e.nativeEvent.contentOffset.x)) 47 + }}> 48 + <Animated.View 49 + style={[ 50 + a.flex_row, 51 + a.flex_grow, 52 + a.gap_sm, 53 + a.align_center, 54 + a.justify_start, 55 + ]} 56 + onLayout={e => { 57 + contentSize.set(e.nativeEvent.layout.width) 58 + }}> 59 + {profiles.map((profile, index) => ( 60 + <Tab 61 + key={profile.did} 62 + testID={testID} 63 + index={index} 64 + profile={profile} 65 + total={profiles.length} 66 + onRemove={onRemove} 67 + /> 68 + ))} 69 + </Animated.View> 70 + </DraggableScrollView> 71 + </View> 72 + ) 73 + } 74 + 75 + function Tab({ 76 + testID, 77 + index, 78 + profile, 79 + total, 80 + onRemove, 81 + }: { 82 + testID?: string 83 + index: number 84 + profile: bsky.profile.AnyProfileView 85 + total: number 86 + onRemove?: (did: string) => void 87 + }) { 88 + const t = useTheme() 89 + const {t: l} = useLingui() 90 + const moderationOpts = useModerationOpts() 91 + 92 + const moderation = moderateProfile(profile, moderationOpts!) 93 + const displayName = sanitizeDisplayName( 94 + profile.displayName || sanitizeHandle(profile.handle), 95 + moderation.ui('displayName'), 96 + ) 97 + 98 + const onPressItem = useCallback( 99 + (did: string) => { 100 + onRemove?.(did) 101 + }, 102 + [onRemove], 103 + ) 104 + 105 + return ( 106 + <View 107 + testID={`${testID}-selector-${profile.did}`} 108 + style={[ 109 + a.flex_row, 110 + a.align_center, 111 + a.border, 112 + a.justify_center, 113 + a.rounded_lg, 114 + a.pl_xs, 115 + a.pr_sm, 116 + a.py_xs, 117 + t.atoms.border_contrast_low, 118 + t.atoms.bg, 119 + index === 0 ? a.ml_lg : index === total - 1 ? a.mr_lg : null, 120 + ]}> 121 + {moderationOpts ? ( 122 + <> 123 + <ProfileCard.Avatar 124 + profile={profile} 125 + moderationOpts={moderationOpts} 126 + size={24} 127 + disabledPreview 128 + /> 129 + <View style={[a.flex_row, a.align_center, a.max_w_full, a.ml_xs]}> 130 + <Text 131 + emoji 132 + style={[ 133 + a.text_sm, 134 + a.font_normal, 135 + a.leading_snug, 136 + a.self_start, 137 + a.flex_shrink, 138 + t.atoms.text, 139 + ]} 140 + numberOfLines={1}> 141 + {displayName} 142 + </Text> 143 + </View> 144 + </> 145 + ) : ( 146 + <> 147 + <ProfileCard.AvatarPlaceholder size={24} /> 148 + <ProfileCard.NamePlaceholder /> 149 + </> 150 + )} 151 + <Button 152 + hitSlop={HITSLOP_10} 153 + label={l`Remove ${displayName} from group chat`} 154 + style={[a.ml_xs]} 155 + onPress={() => onPressItem(profile.did)}> 156 + {({hovered, pressed, focused}) => ( 157 + <XIcon 158 + size="sm" 159 + style={[ 160 + hovered || pressed || focused 161 + ? t.atoms.text 162 + : t.atoms.text_contrast_high, 163 + ]} 164 + /> 165 + )} 166 + </Button> 167 + </View> 168 + ) 169 + }
+1007
src/components/dms/InitiateChatFlow.tsx
··· 1 + import { 2 + useCallback, 3 + useLayoutEffect, 4 + useMemo, 5 + useReducer, 6 + useRef, 7 + useState, 8 + } from 'react' 9 + import {LayoutAnimation, TextInput, View} from 'react-native' 10 + import {moderateProfile, type ModerationOpts} from '@atproto/api' 11 + import {Trans, useLingui} from '@lingui/react/macro' 12 + 13 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 14 + import {sanitizeHandle} from '#/lib/strings/handles' 15 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 16 + import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 17 + import {useProfileFollowsQuery} from '#/state/queries/profile-follows' 18 + import {useSession} from '#/state/session' 19 + import {type ListMethods} from '#/view/com/util/List' 20 + import {android, atoms as a, native, useTheme, web} from '#/alf' 21 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 22 + import * as Dialog from '#/components/Dialog' 23 + import {canBeMessaged} from '#/components/dms/util' 24 + import * as TextField from '#/components/forms/TextField' 25 + import * as Toggle from '#/components/forms/Toggle' 26 + import {useInteractionState} from '#/components/hooks/useInteractionState' 27 + import { 28 + ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon, 29 + ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon, 30 + } from '#/components/icons/Arrow' 31 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' 32 + import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' 33 + import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' 34 + import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 35 + import * as ProfileCard from '#/components/ProfileCard' 36 + import {Text} from '#/components/Typography' 37 + import {IS_NATIVE, IS_WEB} from '#/env' 38 + import type * as bsky from '#/types/bsky' 39 + import {ChatProfileTabs} from './ChatProfileTabs' 40 + 41 + type NewGroupChatItem = { 42 + type: 'newGroupChat' 43 + key: string 44 + } 45 + 46 + type LabelItem = { 47 + type: 'label' 48 + key: string 49 + message: string 50 + } 51 + 52 + export type ProfileItem = { 53 + type: 'profile' 54 + key: string 55 + profile: bsky.profile.AnyProfileView 56 + } 57 + 58 + type EmptyItem = { 59 + type: 'empty' 60 + key: string 61 + message: string 62 + } 63 + 64 + type PlaceholderItem = { 65 + type: 'placeholder' 66 + key: string 67 + } 68 + 69 + type ErrorItem = { 70 + type: 'error' 71 + key: string 72 + } 73 + 74 + type Item = 75 + | NewGroupChatItem 76 + | LabelItem 77 + | ProfileItem 78 + | EmptyItem 79 + | PlaceholderItem 80 + | ErrorItem 81 + 82 + enum ChatState { 83 + NEW_CHAT, 84 + NEW_GROUP_CHAT, 85 + GROUP_NAME, 86 + } 87 + 88 + export type State = { 89 + chatState: ChatState 90 + screenTitle: string 91 + groupChatDids: string[] 92 + groupChatProfiles: bsky.profile.AnyProfileView[] 93 + groupName: string 94 + } 95 + 96 + export type Action = 97 + | { 98 + type: 'startNewGroupChat' 99 + screenTitle: string 100 + } 101 + | { 102 + type: 'setDids' 103 + groupChatDids: string[] 104 + groupChatProfiles: bsky.profile.AnyProfileView[] 105 + } 106 + | { 107 + type: 'removeDids' 108 + groupChatDids: string[] 109 + groupChatProfiles: bsky.profile.AnyProfileView[] 110 + } 111 + | { 112 + type: 'startNameGroup' 113 + screenTitle: string 114 + } 115 + | { 116 + type: 'nameGroup' 117 + groupName: string 118 + } 119 + | { 120 + type: 'goBackFromNewGroupChat' 121 + screenTitle: string 122 + } 123 + | { 124 + type: 'goBackFromGroupName' 125 + screenTitle: string 126 + } 127 + 128 + function reducer(state: State, action: Action): State { 129 + switch (action.type) { 130 + case 'startNewGroupChat': { 131 + return { 132 + ...state, 133 + chatState: ChatState.NEW_GROUP_CHAT, 134 + screenTitle: action.screenTitle, 135 + groupChatDids: [], 136 + groupChatProfiles: [], 137 + groupName: '', 138 + } 139 + } 140 + case 'setDids': { 141 + return { 142 + ...state, 143 + groupChatDids: action.groupChatDids, 144 + groupChatProfiles: action.groupChatProfiles, 145 + } 146 + } 147 + case 'removeDids': { 148 + return { 149 + ...state, 150 + groupChatDids: action.groupChatDids, 151 + groupChatProfiles: action.groupChatProfiles, 152 + } 153 + } 154 + case 'startNameGroup': { 155 + return { 156 + ...state, 157 + chatState: ChatState.GROUP_NAME, 158 + screenTitle: action.screenTitle, 159 + } 160 + } 161 + case 'nameGroup': { 162 + return { 163 + ...state, 164 + groupName: action.groupName, 165 + } 166 + } 167 + case 'goBackFromNewGroupChat': { 168 + return { 169 + ...state, 170 + chatState: ChatState.NEW_CHAT, 171 + screenTitle: action.screenTitle, 172 + groupChatDids: [], 173 + groupChatProfiles: [], 174 + groupName: '', 175 + } 176 + } 177 + case 'goBackFromGroupName': { 178 + return { 179 + ...state, 180 + chatState: ChatState.NEW_GROUP_CHAT, 181 + screenTitle: action.screenTitle, 182 + groupName: '', 183 + } 184 + } 185 + } 186 + } 187 + export function InitiateChatFlow({ 188 + title, 189 + onSelectChat, 190 + onSelectGroupChat, 191 + }: { 192 + title: string 193 + onSelectChat: (did: string) => void 194 + onSelectGroupChat: (dids: string[], groupName: string) => void 195 + }) { 196 + const t = useTheme() 197 + const {t: l} = useLingui() 198 + const moderationOpts = useModerationOpts() 199 + const control = Dialog.useDialogContext() 200 + const [headerHeight, setHeaderHeight] = useState(0) 201 + const [footerHeight, setFooterHeight] = useState(0) 202 + const listRef = useRef<ListMethods>(null) 203 + const {currentAccount} = useSession() 204 + const inputRef = useRef<TextInput>(null) 205 + 206 + const [searchText, setSearchText] = useState('') 207 + 208 + const { 209 + data: results, 210 + isError, 211 + isFetching, 212 + } = useActorAutocompleteQuery(searchText, true, 12) 213 + const {data: follows} = useProfileFollowsQuery(currentAccount?.did) 214 + 215 + const [ 216 + {chatState, screenTitle, groupChatDids, groupChatProfiles, groupName}, 217 + dispatch, 218 + ] = useReducer(reducer, { 219 + chatState: ChatState.NEW_CHAT, 220 + screenTitle: title, 221 + groupChatDids: [], 222 + groupChatProfiles: [], 223 + groupName: '', 224 + }) 225 + 226 + const newGroupChatTitle = l`New group chat` 227 + const groupNameTitle = l`Group name` 228 + 229 + const onRemoveDid = useCallback( 230 + (did: string) => { 231 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 232 + dispatch({ 233 + type: 'removeDids', 234 + groupChatDids: groupChatDids.filter(d => d !== did), 235 + groupChatProfiles: groupChatProfiles.filter( 236 + profile => profile.did !== did, 237 + ), 238 + }) 239 + }, 240 + [groupChatDids, groupChatProfiles], 241 + ) 242 + 243 + const items = useMemo(() => { 244 + let _items: Item[] = [] 245 + 246 + if (isError) { 247 + _items.push({ 248 + type: 'empty', 249 + key: 'empty', 250 + message: l`We’re having network issues, try again`, 251 + }) 252 + } else if (chatState === ChatState.GROUP_NAME) { 253 + _items = groupChatProfiles.map(profile => ({ 254 + type: 'profile', 255 + key: profile.did, 256 + profile, 257 + })) 258 + _items.unshift({ 259 + type: 'label', 260 + key: 'members', 261 + message: l`New group chat with:`, 262 + }) 263 + } else if (searchText.length) { 264 + if (results?.length) { 265 + for (const profile of results) { 266 + if (profile.did === currentAccount?.did) continue 267 + _items.push({ 268 + type: 'profile', 269 + key: profile.did, 270 + profile, 271 + }) 272 + } 273 + 274 + _items = _items.sort(item => { 275 + return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 276 + }) 277 + } 278 + } else { 279 + const placeholders: Item[] = Array(10) 280 + .fill(0) 281 + .map((__, i) => ({ 282 + type: 'placeholder', 283 + key: i + '', 284 + })) 285 + 286 + if (follows) { 287 + for (const page of follows.pages) { 288 + for (const profile of page.follows) { 289 + _items.push({ 290 + type: 'profile', 291 + key: profile.did, 292 + profile, 293 + }) 294 + } 295 + } 296 + 297 + _items = _items.sort(item => { 298 + return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 299 + }) 300 + } else { 301 + _items.push(...placeholders) 302 + } 303 + } 304 + 305 + if ( 306 + searchText === '' && 307 + (chatState === ChatState.NEW_CHAT || 308 + chatState === ChatState.NEW_GROUP_CHAT) 309 + ) { 310 + _items.unshift({ 311 + type: 'label', 312 + key: 'suggested', 313 + message: l`Suggested`, 314 + }) 315 + } 316 + 317 + if (chatState === ChatState.NEW_CHAT && searchText === '') { 318 + _items.unshift({type: 'newGroupChat', key: 'newGroupChat'}) 319 + } 320 + 321 + return _items 322 + }, [ 323 + isError, 324 + chatState, 325 + searchText, 326 + l, 327 + groupChatProfiles, 328 + results, 329 + currentAccount?.did, 330 + follows, 331 + ]) 332 + 333 + if (searchText && !isFetching && !items.length && !isError) { 334 + items.push({type: 'empty', key: 'empty', message: l`No results`}) 335 + } 336 + 337 + const handlePressBack = useCallback(() => { 338 + switch (chatState) { 339 + case ChatState.NEW_CHAT: 340 + control.close() 341 + break 342 + case ChatState.NEW_GROUP_CHAT: 343 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 344 + dispatch({type: 'goBackFromNewGroupChat', screenTitle: title}) 345 + setSearchText('') 346 + break 347 + case ChatState.GROUP_NAME: 348 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 349 + dispatch({type: 'goBackFromGroupName', screenTitle: newGroupChatTitle}) 350 + break 351 + } 352 + }, [chatState, control, newGroupChatTitle, title]) 353 + 354 + const handlePressNewGroupChat = useCallback(() => { 355 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 356 + dispatch({type: 'startNewGroupChat', screenTitle: newGroupChatTitle}) 357 + }, [newGroupChatTitle]) 358 + 359 + const handlePressNext = useCallback(() => { 360 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 361 + dispatch({type: 'startNameGroup', screenTitle: groupNameTitle}) 362 + setSearchText('') 363 + }, [groupNameTitle]) 364 + 365 + const handlePressConfirm = useCallback(() => { 366 + onSelectGroupChat(groupChatDids, groupName) 367 + }, [groupChatDids, groupName, onSelectGroupChat]) 368 + 369 + const setGroupName = (newGroupName: string) => { 370 + dispatch({type: 'nameGroup', groupName: newGroupName}) 371 + } 372 + 373 + const renderItems = useCallback( 374 + ({item}: {item: Item}) => { 375 + switch (item.type) { 376 + case 'newGroupChat': { 377 + return ( 378 + <NewGroupChatButton 379 + key={item.key} 380 + onPress={handlePressNewGroupChat} 381 + /> 382 + ) 383 + } 384 + case 'label': { 385 + return <Label key={item.key} message={item.message} /> 386 + } 387 + case 'profile': { 388 + switch (chatState) { 389 + case ChatState.NEW_CHAT: 390 + return ( 391 + <DefaultProfileCard 392 + key={item.key} 393 + profile={item.profile} 394 + moderationOpts={moderationOpts!} 395 + onPress={onSelectChat} 396 + /> 397 + ) 398 + case ChatState.NEW_GROUP_CHAT: 399 + return ( 400 + <GroupChatProfileCard 401 + key={item.key} 402 + profile={item.profile} 403 + moderationOpts={moderationOpts!} 404 + /> 405 + ) 406 + case ChatState.GROUP_NAME: 407 + return ( 408 + <GroupChatMemberProfileCard 409 + key={item.key} 410 + profile={item.profile} 411 + moderationOpts={moderationOpts!} 412 + /> 413 + ) 414 + } 415 + } 416 + case 'placeholder': { 417 + return <ProfileCardSkeleton key={item.key} /> 418 + } 419 + case 'empty': { 420 + return <Empty key={item.key} message={item.message} /> 421 + } 422 + default: 423 + return null 424 + } 425 + }, 426 + [chatState, handlePressNewGroupChat, moderationOpts, onSelectChat], 427 + ) 428 + 429 + useLayoutEffect(() => { 430 + if (IS_WEB) { 431 + setImmediate(() => { 432 + inputRef?.current?.focus() 433 + }) 434 + } 435 + }, []) 436 + 437 + let buttonLabel = l`Continue to group name` 438 + let buttonText = l`Next` 439 + let handleButtonPress = handlePressNext 440 + let showButton = 441 + chatState === ChatState.NEW_GROUP_CHAT && groupChatProfiles.length > 0 442 + let isButtonDisabled = !showButton 443 + switch (chatState) { 444 + case ChatState.GROUP_NAME: 445 + buttonLabel = l`Create group chat` 446 + buttonText = l`Create` 447 + handleButtonPress = handlePressConfirm 448 + showButton = true 449 + isButtonDisabled = groupName === '' 450 + break 451 + } 452 + 453 + const showChatProfileTabs = 454 + chatState === ChatState.NEW_GROUP_CHAT && groupChatProfiles.length > 0 455 + 456 + const listHeader = useMemo( 457 + () => ( 458 + <View onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}> 459 + <View 460 + style={[ 461 + a.relative, 462 + web(a.pt_lg), 463 + native(a.pt_4xl), 464 + android({ 465 + borderTopLeftRadius: a.rounded_md.borderRadius, 466 + borderTopRightRadius: a.rounded_md.borderRadius, 467 + }), 468 + a.px_lg, 469 + chatState !== ChatState.GROUP_NAME ? a.pb_xs : a.pb_lg, 470 + chatState !== ChatState.GROUP_NAME && a.border_b, 471 + t.atoms.border_contrast_low, 472 + t.atoms.bg, 473 + ]}> 474 + <View 475 + style={[ 476 + a.flex_row, 477 + a.gap_sm, 478 + a.relative, 479 + a.align_center, 480 + a.justify_between, 481 + web(a.pb_lg), 482 + ]}> 483 + {IS_NATIVE ? ( 484 + <Button 485 + label={l`Back`} 486 + size="large" 487 + shape="round" 488 + variant="ghost" 489 + color="secondary" 490 + style={[native([a.absolute, a.z_20])]} 491 + onPress={handlePressBack}> 492 + <ButtonIcon icon={ArrowLeftIcon} size="lg" /> 493 + </Button> 494 + ) : null} 495 + <Text 496 + style={[ 497 + a.flex_grow, 498 + a.z_10, 499 + a.text_lg, 500 + a.font_bold, 501 + a.leading_tight, 502 + t.atoms.text_contrast_high, 503 + a.text_center, 504 + a.px_5xl, 505 + ]}> 506 + {screenTitle} 507 + </Text> 508 + {IS_WEB ? ( 509 + <Button 510 + label={l`Close`} 511 + size="small" 512 + shape="round" 513 + variant="ghost" 514 + color="secondary" 515 + style={[a.absolute, a.z_20, {right: -4}]} 516 + onPress={() => control.close()}> 517 + <ButtonIcon icon={XIcon} size="lg" /> 518 + </Button> 519 + ) : showButton ? ( 520 + <Button 521 + label={buttonLabel} 522 + size="small" 523 + color="primary" 524 + style={[ 525 + native([ 526 + a.absolute, 527 + a.z_20, 528 + { 529 + right: 8, 530 + }, 531 + ]), 532 + ]} 533 + disabled={isButtonDisabled} 534 + onPress={handleButtonPress}> 535 + <ButtonText>{buttonText}</ButtonText> 536 + </Button> 537 + ) : null} 538 + </View> 539 + <View style={[web(a.pt_xs), native(a.pt_md)]}> 540 + {chatState === ChatState.GROUP_NAME ? ( 541 + <View 542 + style={[a.w_full, a.relative, web(a.pt_md), native(a.pt_xl)]}> 543 + <TextField.Root> 544 + <TextField.Input 545 + label={l`Group name`} 546 + value={groupName} 547 + returnKeyType="next" 548 + keyboardAppearance={t.scheme} 549 + selectTextOnFocus={IS_NATIVE} 550 + autoFocus={false} 551 + accessibilityRole="text" 552 + autoCorrect={false} 553 + autoComplete="off" 554 + autoCapitalize="none" 555 + onChangeText={setGroupName} 556 + onSubmitEditing={ 557 + isButtonDisabled ? undefined : handleButtonPress 558 + } 559 + /> 560 + </TextField.Root> 561 + </View> 562 + ) : ( 563 + <SearchInput 564 + inputRef={inputRef} 565 + value={searchText} 566 + onChangeText={text => { 567 + setSearchText(text) 568 + listRef.current?.scrollToOffset({offset: 0, animated: false}) 569 + }} 570 + onEscape={control.close} 571 + /> 572 + )} 573 + </View> 574 + </View> 575 + {showChatProfileTabs ? ( 576 + <View style={[a.pb_sm, a.pt_md, t.atoms.bg]}> 577 + <ChatProfileTabs 578 + testID="newGroupChatMembers" 579 + profiles={groupChatProfiles} 580 + onRemove={onRemoveDid} 581 + /> 582 + </View> 583 + ) : null} 584 + </View> 585 + ), 586 + [ 587 + chatState, 588 + t.atoms.border_contrast_low, 589 + t.atoms.bg, 590 + t.atoms.text_contrast_high, 591 + t.scheme, 592 + l, 593 + handlePressBack, 594 + screenTitle, 595 + showButton, 596 + buttonLabel, 597 + isButtonDisabled, 598 + handleButtonPress, 599 + buttonText, 600 + groupName, 601 + searchText, 602 + control, 603 + showChatProfileTabs, 604 + groupChatProfiles, 605 + onRemoveDid, 606 + ], 607 + ) 608 + 609 + const setGroupChatMembers = (dids: string[]) => { 610 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 611 + 612 + const added = dids.filter(d => !groupChatDids.includes(d)) 613 + const removed = groupChatDids.filter(d => !dids.includes(d)) 614 + const newDids = [ 615 + ...groupChatDids.filter(d => !removed.includes(d)), 616 + ...added, 617 + ] 618 + 619 + const kept = groupChatProfiles.filter(p => dids.includes(p.did)) 620 + const keptDids = new Set(kept.map(p => p.did)) 621 + const addedProfiles = items 622 + .filter( 623 + (item): item is ProfileItem => 624 + item.type === 'profile' && 625 + dids.includes(item.profile.did) && 626 + !keptDids.has(item.profile.did), 627 + ) 628 + .map(item => item.profile) 629 + .sort((a, b) => dids.indexOf(a.did) - dids.indexOf(b.did)) 630 + 631 + dispatch({ 632 + type: 'setDids', 633 + groupChatDids: newDids, 634 + groupChatProfiles: [...kept, ...addedProfiles], 635 + }) 636 + } 637 + 638 + return ( 639 + <Toggle.Group 640 + values={groupChatDids} 641 + onChange={setGroupChatMembers} 642 + type="checkbox" 643 + label={ 644 + chatState === ChatState.NEW_GROUP_CHAT 645 + ? l`Select group chat members` 646 + : l`Start chat` 647 + } 648 + style={web([a.contents])}> 649 + <Dialog.InnerFlatList 650 + ref={listRef} 651 + data={items} 652 + renderItem={renderItems} 653 + ListHeaderComponent={listHeader} 654 + stickyHeaderIndices={[0]} 655 + keyExtractor={(item: Item) => item.key} 656 + style={[ 657 + web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), 658 + native({height: '100%'}), 659 + ]} 660 + webInnerContentContainerStyle={[a.py_0, {paddingBottom: footerHeight}]} 661 + webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} 662 + scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}} 663 + keyboardDismissMode="on-drag" 664 + footer={ 665 + IS_WEB && chatState !== ChatState.NEW_CHAT ? ( 666 + <Dialog.FlatListFooter 667 + onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}> 668 + <View style={[a.flex_row, a.align_center, a.justify_between]}> 669 + <Button 670 + label={l`Back`} 671 + size="small" 672 + color="secondary" 673 + onPress={handlePressBack}> 674 + <ButtonIcon icon={ArrowLeftIcon} size="md" /> 675 + <ButtonText> 676 + {' '} 677 + <Trans>Back</Trans> 678 + </ButtonText> 679 + </Button> 680 + <Button 681 + label={buttonLabel} 682 + size="small" 683 + color="primary" 684 + disabled={isButtonDisabled} 685 + onPress={handleButtonPress}> 686 + <ButtonText>{buttonText} </ButtonText> 687 + {chatState !== ChatState.GROUP_NAME ? ( 688 + <ButtonIcon icon={ArrowRightIcon} size="md" /> 689 + ) : null} 690 + </Button> 691 + </View> 692 + </Dialog.FlatListFooter> 693 + ) : null 694 + } 695 + /> 696 + </Toggle.Group> 697 + ) 698 + } 699 + 700 + function NewGroupChatButton({onPress}: {onPress: () => void}) { 701 + const t = useTheme() 702 + const {t: l} = useLingui() 703 + 704 + const handleOnPress = () => { 705 + onPress() 706 + } 707 + 708 + return ( 709 + <Button label={l`New group chat`} onPress={handleOnPress}> 710 + {({hovered, pressed, focused}) => ( 711 + <View 712 + style={[ 713 + a.px_lg, 714 + a.py_md, 715 + a.flex_row, 716 + a.flex_1, 717 + a.justify_between, 718 + a.align_center, 719 + a.gap_sm, 720 + pressed || focused || hovered ? t.atoms.bg_contrast_25 : t.atoms.bg, 721 + ]}> 722 + <View 723 + style={[ 724 + a.rounded_full, 725 + a.justify_center, 726 + a.align_center, 727 + { 728 + backgroundColor: t.palette.contrast_50, 729 + padding: 12, 730 + }, 731 + ]}> 732 + <PersonGroupIcon size="md" fill={t.palette.contrast_1000} /> 733 + </View> 734 + <View style={[a.flex_grow]}> 735 + <Text 736 + style={[a.text_md, a.font_medium, a.leading_snug, t.atoms.text]}> 737 + <Trans>New group chat</Trans> 738 + </Text> 739 + </View> 740 + <ChevronRightIcon size="md" fill={t.palette.contrast_1000} /> 741 + </View> 742 + )} 743 + </Button> 744 + ) 745 + } 746 + 747 + function DefaultProfileCard({ 748 + profile, 749 + moderationOpts, 750 + onPress, 751 + }: { 752 + profile: bsky.profile.AnyProfileView 753 + moderationOpts: ModerationOpts 754 + onPress: (did: string) => void 755 + }) { 756 + const t = useTheme() 757 + const {t: l} = useLingui() 758 + const enabled = canBeMessaged(profile) 759 + const moderation = moderateProfile(profile, moderationOpts) 760 + const handle = sanitizeHandle(profile.handle, '@') 761 + const displayName = sanitizeDisplayName( 762 + profile.displayName || sanitizeHandle(profile.handle), 763 + moderation.ui('displayName'), 764 + ) 765 + 766 + const handleOnPress = useCallback(() => { 767 + onPress(profile.did) 768 + }, [onPress, profile.did]) 769 + 770 + return ( 771 + <Button 772 + disabled={!enabled} 773 + label={l`Start chat with ${displayName}`} 774 + onPress={handleOnPress}> 775 + {({hovered, pressed, focused}) => ( 776 + <View 777 + style={[ 778 + a.flex_1, 779 + a.py_sm, 780 + a.px_lg, 781 + !enabled 782 + ? {opacity: 0.5} 783 + : pressed || focused || hovered 784 + ? t.atoms.bg_contrast_25 785 + : t.atoms.bg, 786 + ]}> 787 + <ProfileCard.Header> 788 + <ProfileCard.Avatar 789 + profile={profile} 790 + moderationOpts={moderationOpts} 791 + size={44} 792 + disabledPreview 793 + /> 794 + <View style={[a.flex_1]}> 795 + <ProfileCard.Name 796 + profile={profile} 797 + moderationOpts={moderationOpts} 798 + /> 799 + {enabled ? ( 800 + <ProfileCard.Handle profile={profile} /> 801 + ) : ( 802 + <Text 803 + style={[a.leading_snug, t.atoms.text_contrast_high]} 804 + numberOfLines={2}> 805 + <Trans>{handle} can’t be messaged</Trans> 806 + </Text> 807 + )} 808 + </View> 809 + </ProfileCard.Header> 810 + </View> 811 + )} 812 + </Button> 813 + ) 814 + } 815 + 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 + function GroupChatMemberProfileCard({ 870 + profile, 871 + moderationOpts, 872 + }: { 873 + profile: bsky.profile.AnyProfileView 874 + moderationOpts: ModerationOpts 875 + }) { 876 + const t = useTheme() 877 + const enabled = canBeMessaged(profile) 878 + const handle = sanitizeHandle(profile.handle, '@') 879 + 880 + return ( 881 + <View style={[a.flex_1, a.py_sm, a.px_lg, t.atoms.bg]}> 882 + <ProfileCard.Header> 883 + <ProfileCard.Avatar 884 + profile={profile} 885 + moderationOpts={moderationOpts} 886 + size={44} 887 + disabledPreview 888 + /> 889 + <View style={[a.flex_1]}> 890 + <ProfileCard.Name profile={profile} moderationOpts={moderationOpts} /> 891 + {enabled ? ( 892 + <ProfileCard.Handle profile={profile} /> 893 + ) : ( 894 + <Text 895 + style={[a.leading_snug, t.atoms.text_contrast_high]} 896 + numberOfLines={2}> 897 + <Trans>{handle} can’t be messaged</Trans> 898 + </Text> 899 + )} 900 + </View> 901 + </ProfileCard.Header> 902 + </View> 903 + ) 904 + } 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 + }
+27 -12
src/components/dms/dialogs/NewChatDialog.tsx
··· 1 1 import {useCallback} from 'react' 2 - import {msg} from '@lingui/core/macro' 3 - import {useLingui} from '@lingui/react' 4 - import {Trans} from '@lingui/react/macro' 2 + import {Trans, useLingui} from '@lingui/react/macro' 5 3 6 4 import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 7 5 import {logger} from '#/logger' ··· 10 8 import {useTheme} from '#/alf' 11 9 import * as Dialog from '#/components/Dialog' 12 10 import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' 11 + import {InitiateChatFlow} from '#/components/dms/InitiateChatFlow' 13 12 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 14 13 import * as Toast from '#/components/Toast' 15 14 import {useAnalytics} from '#/analytics' ··· 22 21 onNewChat: (chatId: string) => void 23 22 }) { 24 23 const t = useTheme() 25 - const {_} = useLingui() 24 + const {t: l} = useLingui() 26 25 const ax = useAnalytics() 27 26 const requireEmailVerification = useRequireEmailVerification() 27 + 28 + const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable) 28 29 29 30 const {mutate: createChat} = useGetConvoForMembers({ 30 31 onSuccess: data => { ··· 37 38 }, 38 39 onError: error => { 39 40 logger.error('Failed to create chat', {safeMessage: error}) 40 - Toast.show(_(msg`An issue occurred starting the chat`), { 41 + Toast.show(l`An issue occurred starting the chat`, { 41 42 type: 'error', 42 43 }) 43 44 }, ··· 48 49 control.close(() => createChat([did])) 49 50 }, 50 51 [control, createChat], 52 + ) 53 + 54 + const onCreateGroupChat = useCallback( 55 + (_dids: string[], _groupName: string) => { 56 + control.close() 57 + }, 58 + [control], 51 59 ) 52 60 53 61 const onPress = useCallback(() => { ··· 68 76 onPress={wrappedOnPress} 69 77 icon={<Plus size="lg" fill={t.palette.white} />} 70 78 accessibilityRole="button" 71 - accessibilityLabel={_(msg`New chat`)} 79 + accessibilityLabel={l`New chat`} 72 80 accessibilityHint="" 73 81 /> 74 - 75 82 <Dialog.Outer 76 83 control={control} 77 84 testID="newChatDialog" 78 85 nativeOptions={{fullHeight: true}}> 79 86 <Dialog.Handle /> 80 - <SearchablePeopleList 81 - title={_(msg`Start a new chat`)} 82 - onSelectChat={onCreateChat} 83 - sortByMessageDeclaration 84 - /> 87 + {isGroupChatEnabled ? ( 88 + <InitiateChatFlow 89 + title={l`New chat`} 90 + onSelectChat={onCreateChat} 91 + onSelectGroupChat={onCreateGroupChat} 92 + /> 93 + ) : ( 94 + <SearchablePeopleList 95 + title={l`Start a new chat`} 96 + onSelectChat={onCreateChat} 97 + sortByMessageDeclaration 98 + /> 99 + )} 85 100 </Dialog.Outer> 86 101 </> 87 102 )
+2 -2
src/components/forms/TextField.tsx
··· 115 115 ] 116 116 const focus: ViewStyle[] = [ 117 117 { 118 - backgroundColor: t.palette.contrast_50, 118 + backgroundColor: t.palette.primary_25, 119 119 borderColor: t.palette.primary_500, 120 120 }, 121 121 ] ··· 279 279 a.inset_0, 280 280 {borderRadius: 10}, 281 281 t.atoms.bg_contrast_50, 282 - {borderColor: 'transparent', borderWidth: 2}, 282 + {borderColor: 'transparent', borderWidth: 1}, 283 283 ctx.hovered ? chromeHover : {}, 284 284 ctx.focused ? chromeFocus : {}, 285 285 ctx.isInvalid || isInvalid ? chromeError : {},