Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
119
fork

Configure Feed

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

merge(upstream): TSGO TYPECHECK

+5012 -2183
+165 -81
CLAUDE.md
··· 1 - # CLAUDE.md - Bluesky Social App Development Guide 1 + # CLAUDE.md – Bluesky Social App Development Guide 2 2 3 3 This document provides guidance for working effectively in the Bluesky Social app codebase. 4 4 ··· 7 7 Bluesky Social is a cross-platform social media application built with React Native and Expo. It runs on iOS, Android, and Web, connecting to the AT Protocol (atproto) decentralized social network. 8 8 9 9 **Tech Stack:** 10 + 11 + - React 19.1 10 12 - React Native 0.81 with Expo 54 11 - - TypeScript 12 - - React Navigation for routing 13 + - TypeScript 6 14 + - React Navigation 7 for routing 13 15 - TanStack Query (React Query) for data fetching 14 - - Lingui for internationalization 16 + - Lingui 5 for internationalization 15 17 - Custom design system called ALF (Application Layout Framework) 18 + 19 + Prefer using the latest features available for each of these libraries (exact versions are found in `package.json`). For example, prefer `@lingui/react/macro` over `@lingui/react`. Suggest refactoring legacy or deprecated uses. 16 20 17 21 ## Essential Commands 18 22 ··· 162 166 163 167 ### Documentation and Tests Within Features 164 168 169 + Comment code when necessary to explain the “why” behind something; avoid 170 + comments that simply describe the code. Avoid Unicode characters in comments, 171 + e.g., use `-` not `—`. 172 + 165 173 For larger features or components, it's helpful to include a README.md file 166 174 within the directory that explains the purpose of the feature, how it works, and 167 175 any important implementation details. The `/Component/index.tsx` pattern lends ··· 182 190 183 191 ### Basic Usage 184 192 193 + Generally, order atoms by: 194 + 195 + - Flexbox configuration, e.g., `a.flex_row` 196 + - Spacing, e.g., `a.px_md` 197 + - Text styles, e.g., `a.font_bold` 198 + - Themes, e.g., `t.atoms.text`, 199 + - Raw styles, e.g., `{backgroundColor: t.palette.primary_500}` 200 + 185 201 ```tsx 186 202 import {atoms as a, useTheme} from '#/alf' 187 203 ··· 190 206 191 207 return ( 192 208 <View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}> 193 - <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 194 - Hello 195 - </Text> 209 + <Text style={[a.text_md, a.font_bold, t.atoms.text]}>Hello</Text> 196 210 </View> 197 211 ) 198 212 } ··· 200 214 201 215 ### Key Concepts 202 216 203 - **Static Atoms** - Theme-independent styles imported from `atoms`: 217 + **Static Atoms** – Theme-independent styles imported from `atoms`: 218 + 204 219 ```tsx 205 220 import {atoms as a} from '#/alf' 206 221 // a.flex_row, a.p_md, a.gap_sm, a.rounded_md, a.text_lg, etc. 207 222 ``` 208 223 209 - **Theme Atoms** - Theme-dependent colors from `useTheme()`: 224 + **Theme Atoms** – Theme-dependent colors from `useTheme()`: 225 + 210 226 ```tsx 211 227 const t = useTheme() 212 228 // t.atoms.bg, t.atoms.text, t.atoms.border_contrast_low, etc. 213 229 // t.palette.primary_500, t.palette.negative_400, etc. 214 230 ``` 215 231 216 - **Platform Utilities** - For platform-specific styles: 232 + **Platform Utilities** – For platform-specific styles: 233 + 217 234 ```tsx 218 235 import {web, native, ios, android, platform} from '#/alf' 219 236 ··· 225 242 ] 226 243 ``` 227 244 228 - **Breakpoints** - Responsive design: 245 + **Breakpoints** – Responsive design: 246 + 229 247 ```tsx 230 248 import {useBreakpoints} from '#/alf' 231 249 ··· 245 263 246 264 ## Component Patterns 247 265 266 + - Prefer fragment shorthand over `Fragment` unless a `key` is needed. 267 + - Prefer functions over arrow functions for component declarations. 268 + - Prefer prop destructuring via parameters over a const within the component. 269 + - Prefer inline types over `Props` types or interfaces. 270 + - Set reasonable defaults for optional props. 271 + 272 + ```tsx 273 + import {Fragment} from 'react' 274 + import {View} from 'react-native' 275 + import {Trans, useLingui} from '@lingui/react/macro' 276 + 277 + import {Text} from '#/components/Typography' 278 + 279 + function MyComponent({foo = []}: {foo?: string[]}) { 280 + const {t: l} = useLingui() 281 + 282 + return ( 283 + <> 284 + <View><Text><Trans>Example</Trans><Text></View> 285 + <View> 286 + {foo.map((foo, index) => ( 287 + <Fragment key={foo}> 288 + <Text>{index}</Text> 289 + <Text>{foo}</Text> 290 + </Fragment> 291 + ))} 292 + </View> 293 + </> 294 + ); 295 + } 296 + ``` 297 + 248 298 ### Dialog Component 249 299 250 300 Dialogs use a bottom sheet on native and a modal on web. Use `useDialogControl()` hook to manage state. ··· 263 313 264 314 <Dialog.Outer control={control}> 265 315 {/* Typically the inner part is in its own component */} 266 - <Dialog.Handle /> {/* Native-only drag handle */} 267 - <Dialog.ScrollableInner label={_(msg`My Dialog`)}> 268 - <Dialog.Header> 269 - <Dialog.HeaderText>Title</Dialog.HeaderText> 270 - </Dialog.Header> 316 + <DialogInner /> 317 + </Dialog.Outer> 318 + </> 319 + ) 320 + } 271 321 272 - <Text>Dialog content here</Text> 273 - 274 - <Button label="Done" onPress={() => control.close()}> 275 - <ButtonText>Done</ButtonText> 276 - </Button> 277 - <Dialog.Close /> {/* Web-only X button in top left */} 278 - </Dialog.ScrollableInner> 279 - </Dialog.Outer> 322 + function DialogInner() { 323 + return ( 324 + <> 325 + <Dialog.Handle /> {/* Native-only drag handle */} 326 + <Dialog.ScrollableInner label={l`My Dialog`}> 327 + <Dialog.Header> 328 + <Dialog.HeaderText>Title</Dialog.HeaderText> 329 + </Dialog.Header> 330 + <Text>Dialog content here</Text> 331 + <Button label="Done" onPress={() => control.close()}> 332 + <ButtonText>Done</ButtonText> 333 + </Button> 334 + <Dialog.Close /> {/* Web-only X button in top left */} 335 + </Dialog.ScrollableInner> 280 336 </> 281 337 ) 282 338 } ··· 345 401 ``` 346 402 347 403 **Button Props:** 404 + 348 405 - `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'` 349 406 - `size`: `'tiny'` | `'small'` | `'large'` 350 407 - `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'` ··· 384 441 385 442 ## Internationalization (i18n) 386 443 387 - All user-facing strings must be wrapped for translation using Lingui. 444 + All user-facing strings must be wrapped for translation using Lingui. Include `comment` and/or `context` props when necessary to avoid ambiguity, e.g., “Post” as a noun vs a verb. 445 + 446 + Prefer using `t` via `import {useLingui} '@lingui/react/macro'` vs `_` via `import {useLingui} from '@lingui/react'`. Alias `t` to `l` to avoid collisions with `const t = useTheme()`. Refactor existing uses of ``_(msg`foo`)`` to use `` l`foo` ``. 447 + 448 + Prefer Unicode punctuation over keyboard punctuation, e.g., `“quote”` over `"quote"`. Prefer en dashes preceded by a non-breaking space over em dashes, e.g., `one – two` over `one—two`. 388 449 389 450 ```tsx 390 - import {msg, plural} from '@lingui/core/macro' 391 - import {Trans} from '@lingui/react/macro' 392 - import {useLingui} from '@lingui/react' 451 + import {plural} from '@lingui/core/macro' 452 + import {Trans, useLingui} from '@lingui/react/macro' 393 453 394 454 function MyComponent() { 395 - const {_} = useLingui() 455 + const {t: l} = useLingui() 396 456 397 - // Simple strings - use msg() with _() function 398 - const title = _(msg`Settings`) 399 - const errorMessage = _(msg`Something went wrong`) 457 + // Simple strings - use the l macro 458 + const title = l`Settings` 459 + const errorMessage = l({ 460 + message: 'Something went wrong', 461 + comment: 'Generic error message for unknown/unhandled errors.', 462 + context: 'Toast', 463 + }) 400 464 401 465 // Strings with variables 402 - const greeting = _(msg`Hello, ${name}!`) 466 + const greeting = l`Hello, ${name}!` 403 467 404 468 // Pluralization 405 - const countLabel = _(plural(count, { 469 + const countLabel = plural(count, { 406 470 one: '# item', 407 471 other: '# items', 408 - })) 472 + }) 409 473 410 474 // JSX content - use Trans component 411 475 return ( 412 476 <Text> 413 - <Trans>Welcome to <Text style={a.font_bold}>Bluesky</Text></Trans> 477 + <Trans> 478 + Welcome to <Text style={a.font_bold}>Bluesky</Text>, {name}! 479 + </Trans> 414 480 </Text> 415 481 ) 416 482 } 417 483 ``` 418 484 485 + Prefer `i18n.date` for date and time formatting. This ensures formatting is re-applied when the language changes at runtime. Refactor existing uses of `Intl.DateTimeFormat` to use `i18n.date`. 486 + 487 + ```tsx 488 + import {useLingui} from '@lingui/react/macro' 489 + 490 + function MyComponent() { 491 + const {i18n} = useLingui() 492 + 493 + const createdAt = new Date() 494 + 495 + return i18n.date(createdAt, { 496 + dateStyle: 'medium', 497 + timeStyle: 'medium', 498 + }) 499 + } 500 + ``` 501 + 419 502 **Commands:** 503 + 420 504 ```bash 421 505 # DO NOT run these commands - extraction and compilation are handled by a nightly CI job 422 506 pnpm intl:extract # Extract new strings to locale files ··· 473 557 const queryClient = useQueryClient() 474 558 475 559 return useMutation({ 476 - mutationFn: async (data) => { 560 + mutationFn: async data => { 477 561 // Update logic 478 562 }, 479 563 onSuccess: (_, variables) => { ··· 481 565 queryKey: createProfileQueryKey({did: variables.did}), 482 566 }) 483 567 }, 484 - onError: (error) => { 568 + onError: error => { 485 569 if (isNetworkError(error)) { 486 570 // don't log, but inform user 487 571 } else if (error instanceof AppBskyExampleProcedure.ExampleError) { ··· 490 574 // Log unexpected errors to Sentry 491 575 logger.error('Error updating profile', {safeMessage: error}) 492 576 } 493 - } 577 + }, 494 578 }) 495 579 } 496 580 ··· 505 589 const queryClient = useQueryClient() 506 590 507 591 return (data: Partial<Profile>) => { 508 - queryClient.setQueryData(createProfileQueryKey({did: data.did}), oldData => { 509 - if (!oldData) return oldData 510 - return {...oldData, ...data} 511 - }) 592 + queryClient.setQueryData( 593 + createProfileQueryKey({did: data.did}), 594 + oldData => { 595 + if (!oldData) return oldData 596 + return {...oldData, ...data} 597 + }, 598 + ) 512 599 } 513 600 } 514 601 ``` 515 602 516 603 **Stale Time Constants** (from `src/state/queries/index.ts`): 604 + 517 605 ```tsx 518 - STALE.SECONDS.FIFTEEN // 15 seconds 519 - STALE.MINUTES.ONE // 1 minute 520 - STALE.MINUTES.FIVE // 5 minutes 521 - STALE.HOURS.ONE // 1 hour 522 - STALE.INFINITY // Never stale 606 + STALE.SECONDS.FIFTEEN // 15 seconds 607 + STALE.MINUTES.ONE // 1 minute 608 + STALE.MINUTES.FIVE // 5 minutes 609 + STALE.HOURS.ONE // 1 hour 610 + STALE.INFINITY // Never stale 523 611 ``` 524 612 525 613 **Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these: ··· 565 653 const autoplayDisabled = useAutoplayDisabled() 566 654 const setAutoplayDisabled = useSetAutoplayDisabled() 567 655 568 - return ( 569 - <Toggle 570 - value={autoplayDisabled} 571 - onValueChange={setAutoplayDisabled} 572 - /> 573 - ) 656 + return <Toggle value={autoplayDisabled} onValueChange={setAutoplayDisabled} /> 574 657 } 575 658 ``` 576 659 ··· 604 687 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> 605 688 606 689 export function ProfileScreen({route, navigation}: Props) { 607 - const {name} = route.params // Type-safe params 690 + const {name} = route.params // Type-safe params 608 691 609 - return ( 610 - <Layout.Screen> 611 - {/* Screen content */} 612 - </Layout.Screen> 613 - ) 692 + return <Layout.Screen>{/* Screen content */}</Layout.Screen> 614 693 } 615 694 616 695 // Programmatic navigation ··· 637 716 ``` 638 717 639 718 Example from Dialog: 640 - - `src/components/Dialog/index.tsx` - Native (uses BottomSheet) 641 - - `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives) 719 + 720 + - `src/components/Dialog/index.tsx` – Native (uses BottomSheet) 721 + - `src/components/Dialog/index.web.tsx` – Web (uses modal with Radix primitives) 642 722 643 723 **Important:** The bundler automatically resolves platform-specific files. Just import normally: 644 724 ··· 653 733 ``` 654 734 655 735 Platform detection (for runtime logic, not imports): 736 + 656 737 ```tsx 657 738 import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env' 658 739 ··· 687 768 // WRONG - causes bugs with state updates, navigation, opening other dialogs 688 769 const onConfirm = () => { 689 770 control.close() 690 - navigation.navigate('Home') // May race with dialog animation 771 + navigation.navigate('Home') // May race with dialog animation 691 772 } 692 773 693 774 // WRONG - same problem 694 775 const onConfirm = () => { 695 776 control.close() 696 - otherDialogControl.open() // Will likely fail or cause visual glitches 777 + otherDialogControl.open() // Will likely fail or cause visual glitches 697 778 } 698 779 699 780 // CORRECT - action runs after dialog fully closes ··· 720 801 ``` 721 802 722 803 This applies to: 804 + 723 805 - Navigation (`navigation.navigate()`, `navigation.push()`) 724 806 - Opening other dialogs or menus 725 807 - State updates that affect UI (`setState`, `queryClient.invalidateQueries`) 726 808 - Callbacks passed from parent components 727 809 728 - The Menu component on iOS specifically uses this pattern - see `src/components/Menu/index.tsx:151`. 810 + The Menu component on iOS specifically uses this pattern – see `src/components/Menu/index.tsx:151`. 729 811 730 812 ### Controlled vs Uncontrolled Inputs 731 813 ··· 748 830 ### Platform-Specific Behavior 749 831 750 832 Some components behave differently across platforms: 751 - - `Dialog.Handle` - Only renders on native (drag handle for bottom sheet) 752 - - `Dialog.Close` - Only renders on web (X button) 753 - - `Menu.Divider` - Only renders on web 754 - - `Menu.ContainerItem` - Only works on native 833 + 834 + - `Dialog.Handle` – Only renders on native (drag handle for bottom sheet) 835 + - `Dialog.Close` – Only renders on web (X button) 836 + - `Menu.Divider` – Only renders on web 837 + - `Menu.ContainerItem` – Only works on native 755 838 756 839 Always test on multiple platforms when using these components. 757 840 ··· 772 855 ``` 773 856 774 857 Only use `useMemo`/`useCallback` when you have a specific reason, such as: 858 + 775 859 - The value is immediately used in an effect's dependency array 776 860 - You're passing a callback to a non-React library that needs referential stability 777 861 ··· 779 863 780 864 1. **Accessibility**: Always provide `label` prop for interactive elements, use `accessibilityHint` where helpful 781 865 782 - 2. **Translations**: Wrap ALL user-facing strings with `msg()` or `<Trans>` 866 + 2. **Translations**: Wrap ALL user-facing strings with ` `l` `` or `<Trans>` 783 867 784 868 3. **Styling**: Combine static atoms with theme atoms, use platform utilities for platform-specific styles 785 869 ··· 793 877 794 878 ## Key Files Reference 795 879 796 - | Purpose | Location | 797 - |---------|----------| 798 - | Theme definitions | `src/alf/themes.ts` | 799 - | Design tokens | `src/alf/tokens.ts` | 800 - | Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) | 801 - | Navigation config | `src/Navigation.tsx` | 802 - | Route definitions | `src/routes.ts` | 803 - | Route types | `src/lib/routes/types.ts` | 804 - | Query hooks | `src/state/queries/*.ts` | 805 - | Session state | `src/state/session/index.tsx` | 806 - | i18n setup | `src/locale/i18n.ts` | 880 + | Purpose | Location | 881 + | ----------------- | -------------------------------------------- | 882 + | Theme definitions | `src/alf/themes.ts` | 883 + | Design tokens | `src/alf/tokens.ts` | 884 + | Static atoms | `src/alf/atoms.ts` (extends `@bsky.app/alf`) | 885 + | Navigation config | `src/Navigation.tsx` | 886 + | Route definitions | `src/routes.ts` | 887 + | Route types | `src/lib/routes/types.ts` | 888 + | Query hooks | `src/state/queries/*.ts` | 889 + | Session state | `src/state/session/index.tsx` | 890 + | i18n setup | `src/locale/i18n.ts` | 807 891 808 892 ## Witchsky-Specific Notes 809 893
+2
app.config.js
··· 72 72 infoPlist: { 73 73 CADisableMinimumFrameDurationOnPhone: true, 74 74 UIBackgroundModes: ['remote-notification'], 75 + NSUserActivityTypes: ['INSendMessageIntent'], 75 76 NSCameraUsageDescription: 76 77 'Used for profile pictures, posts, and other kinds of content.', 77 78 NSMicrophoneUsageDescription: ··· 132 133 .WITCHSKY_BUNDLE_ID 133 134 ? `group.${process.env.WITCHSKY_BUNDLE_ID}` 134 135 : 'group.app.witchsky', 136 + 'com.apple.developer.usernotifications.communication': true, 135 137 // 'com.apple.developer.device-information.user-assigned-device-name': true, 136 138 }, 137 139 privacyManifests: {
+1
assets/icons/editBig_stroke2_corner2_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M3 16.8V7.2c0-.544-.001-1.011.03-1.395.033-.395.104-.789.297-1.167a3 3 0 0 1 1.31-1.31c.379-.193.772-.265 1.168-.297C6.188 2.999 6.657 3 7.2 3H11a1 1 0 1 1 0 2H7.2c-.576 0-.949 0-1.232.023-.272.022-.373.06-.422.085a1 1 0 0 0-.437.437c-.025.05-.062.15-.085.422C5.001 6.251 5 6.623 5 7.2v9.6c0 .577.001.95.024 1.232.023.272.06.373.085.422a1 1 0 0 0 .437.437c.05.025.15.063.422.085.283.023.656.024 1.232.024h9.6c.576 0 .949-.001 1.232-.024.272-.022.373-.06.422-.085a1 1 0 0 0 .437-.437c.025-.049.062-.15.085-.422.023-.283.024-.655.024-1.232V13a1 1 0 1 1 2 0v3.8c0 .543.001 1.011-.03 1.395-.033.395-.104.788-.297 1.167a3 3 0 0 1-1.31 1.311c-.379.193-.772.264-1.168.296-.383.031-.852.031-1.395.031H7.2c-.543 0-1.012 0-1.395-.031-.396-.032-.789-.103-1.167-.296a3 3 0 0 1-1.31-1.311c-.194-.379-.265-.772-.298-1.167C3 17.81 3 17.343 3 16.8M16.629 2.957a3 3 0 0 1 4.242 0l.172.171a3 3 0 0 1 0 4.243L13 15.414a2 2 0 0 1-1.414.586H9a1 1 0 0 1-1-1v-2.586A2 2 0 0 1 8.586 11zM10 14h1.586l8.043-8.043a1 1 0 0 0 0-1.414l-.172-.172a1 1 0 0 0-1.414 0L10 12.414z"/></svg>
+1
assets/icons/squareBehindSquare_stroke2_corner0_rounded.svg
··· 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>
+1 -1
assets/icons/unlock_stroke2_corner2_rounded.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 13a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z"/><path fill="#000" fill-rule="evenodd" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5Zm-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7Z" clip-rule="evenodd"/></svg> 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1zm5 2a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1"/></svg>
+747
eslint-suppressions.json
··· 1 + { 2 + "src/alf/util/flatten.ts": { 3 + "@typescript-eslint/no-explicit-any": { 4 + "count": 1 5 + } 6 + }, 7 + "src/analytics/PassiveAnalytics.tsx": { 8 + "@typescript-eslint/no-explicit-any": { 9 + "count": 1 10 + } 11 + }, 12 + "src/analytics/metadata.ts": { 13 + "@typescript-eslint/no-explicit-any": { 14 + "count": 1 15 + } 16 + }, 17 + "src/analytics/metrics/client.test.ts": { 18 + "@typescript-eslint/no-explicit-any": { 19 + "count": 1 20 + } 21 + }, 22 + "src/analytics/metrics/client.ts": { 23 + "@typescript-eslint/no-explicit-any": { 24 + "count": 5 25 + } 26 + }, 27 + "src/analytics/metrics/types.ts": { 28 + "@typescript-eslint/no-explicit-any": { 29 + "count": 2 30 + } 31 + }, 32 + "src/components/Button.tsx": { 33 + "@typescript-eslint/no-explicit-any": { 34 + "count": 2 35 + } 36 + }, 37 + "src/components/Composer/index.tsx": { 38 + "@typescript-eslint/no-explicit-any": { 39 + "count": 2 40 + } 41 + }, 42 + "src/components/Dialog/index.tsx": { 43 + "@typescript-eslint/no-explicit-any": { 44 + "count": 2 45 + } 46 + }, 47 + "src/components/Dialog/index.web.tsx": { 48 + "@typescript-eslint/no-explicit-any": { 49 + "count": 4 50 + } 51 + }, 52 + "src/components/DraggableList/index.web.tsx": { 53 + "@typescript-eslint/no-explicit-any": { 54 + "count": 2 55 + } 56 + }, 57 + "src/components/FeedCard.tsx": { 58 + "@typescript-eslint/no-explicit-any": { 59 + "count": 1 60 + } 61 + }, 62 + "src/components/FocusScope/index.tsx": { 63 + "@typescript-eslint/no-explicit-any": { 64 + "count": 1 65 + } 66 + }, 67 + "src/components/InternationalPhoneCodeSelect.tsx": { 68 + "@typescript-eslint/no-explicit-any": { 69 + "count": 1 70 + } 71 + }, 72 + "src/components/Layout/const.ts": { 73 + "@typescript-eslint/no-explicit-any": { 74 + "count": 2 75 + } 76 + }, 77 + "src/components/Lists.tsx": { 78 + "@typescript-eslint/no-explicit-any": { 79 + "count": 1 80 + } 81 + }, 82 + "src/components/Menu/index.web.tsx": { 83 + "@typescript-eslint/no-explicit-any": { 84 + "count": 1 85 + } 86 + }, 87 + "src/components/Menu/types.ts": { 88 + "@typescript-eslint/no-explicit-any": { 89 + "count": 1 90 + } 91 + }, 92 + "src/components/Portal.tsx": { 93 + "@typescript-eslint/no-explicit-any": { 94 + "count": 1 95 + } 96 + }, 97 + "src/components/Post/Embed/ImageEmbed.tsx": { 98 + "@typescript-eslint/no-explicit-any": { 99 + "count": 1 100 + } 101 + }, 102 + "src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx": { 103 + "@typescript-eslint/no-explicit-any": { 104 + "count": 2 105 + } 106 + }, 107 + "src/components/PostControls/BookmarkButton.tsx": { 108 + "@typescript-eslint/no-explicit-any": { 109 + "count": 2 110 + } 111 + }, 112 + "src/components/ProfileHoverCard/index.web.tsx": { 113 + "@typescript-eslint/no-explicit-any": { 114 + "count": 2 115 + } 116 + }, 117 + "src/components/Select/index.tsx": { 118 + "@typescript-eslint/no-explicit-any": { 119 + "count": 4 120 + } 121 + }, 122 + "src/components/Select/index.web.tsx": { 123 + "@typescript-eslint/no-explicit-any": { 124 + "count": 2 125 + } 126 + }, 127 + "src/components/Select/types.ts": { 128 + "@typescript-eslint/no-explicit-any": { 129 + "count": 3 130 + } 131 + }, 132 + "src/components/StarterPack/ProfileStarterPacks.tsx": { 133 + "@typescript-eslint/no-explicit-any": { 134 + "count": 1 135 + } 136 + }, 137 + "src/components/StarterPack/Wizard/WizardEditListDialog.tsx": { 138 + "@typescript-eslint/no-explicit-any": { 139 + "count": 1 140 + } 141 + }, 142 + "src/components/dialogs/DeviceLocationRequestDialog.tsx": { 143 + "@typescript-eslint/no-explicit-any": { 144 + "count": 1 145 + } 146 + }, 147 + "src/components/dialogs/EmailDialog/components/ResendEmailText.tsx": { 148 + "@typescript-eslint/no-explicit-any": { 149 + "count": 1 150 + } 151 + }, 152 + "src/components/dialogs/GifSelect.tsx": { 153 + "@typescript-eslint/no-explicit-any": { 154 + "count": 1 155 + } 156 + }, 157 + "src/components/dialogs/LanguageSelectDialog.tsx": { 158 + "@typescript-eslint/no-explicit-any": { 159 + "count": 1 160 + } 161 + }, 162 + "src/components/dialogs/MutedWords.tsx": { 163 + "@typescript-eslint/no-explicit-any": { 164 + "count": 1 165 + } 166 + }, 167 + "src/components/dialogs/PostInteractionSettingsDialog.tsx": { 168 + "@typescript-eslint/no-explicit-any": { 169 + "count": 2 170 + } 171 + }, 172 + "src/components/dialogs/lists/CreateOrEditListDialog.tsx": { 173 + "@typescript-eslint/no-explicit-any": { 174 + "count": 2 175 + } 176 + }, 177 + "src/components/forms/DateField/index.web.tsx": { 178 + "@typescript-eslint/no-explicit-any": { 179 + "count": 1 180 + } 181 + }, 182 + "src/components/forms/SegmentedControl.tsx": { 183 + "@typescript-eslint/no-explicit-any": { 184 + "count": 1 185 + } 186 + }, 187 + "src/components/forms/ToggleButton.tsx": { 188 + "@typescript-eslint/no-explicit-any": { 189 + "count": 1 190 + } 191 + }, 192 + "src/components/hooks/useFollowMethods.ts": { 193 + "@typescript-eslint/no-explicit-any": { 194 + "count": 2 195 + } 196 + }, 197 + "src/components/images/AutoSizedImage.tsx": { 198 + "@typescript-eslint/no-explicit-any": { 199 + "count": 1 200 + } 201 + }, 202 + "src/components/images/Gallery/index.tsx": { 203 + "@typescript-eslint/no-explicit-any": { 204 + "count": 5 205 + } 206 + }, 207 + "src/components/images/Gallery/useKeyboardHandlers.ts": { 208 + "@typescript-eslint/no-explicit-any": { 209 + "count": 6 210 + } 211 + }, 212 + "src/components/images/Gallery/usePointerHandlers.ts": { 213 + "@typescript-eslint/no-explicit-any": { 214 + "count": 6 215 + } 216 + }, 217 + "src/components/images/ImageLayoutGrid.tsx": { 218 + "@typescript-eslint/no-explicit-any": { 219 + "count": 2 220 + } 221 + }, 222 + "src/components/images/ImageLayoutGridItem.tsx": { 223 + "@typescript-eslint/no-explicit-any": { 224 + "count": 2 225 + } 226 + }, 227 + "src/components/moderation/ReportDialog/index.tsx": { 228 + "@typescript-eslint/no-explicit-any": { 229 + "count": 1 230 + } 231 + }, 232 + "src/features/liveNow/index.tsx": { 233 + "@typescript-eslint/no-explicit-any": { 234 + "count": 3 235 + } 236 + }, 237 + "src/geolocation/service.ts": { 238 + "@typescript-eslint/no-explicit-any": { 239 + "count": 2 240 + } 241 + }, 242 + "src/lib/ScrollContext.tsx": { 243 + "@typescript-eslint/no-explicit-any": { 244 + "count": 3 245 + } 246 + }, 247 + "src/lib/api/index.ts": { 248 + "@typescript-eslint/no-explicit-any": { 249 + "count": 5 250 + } 251 + }, 252 + "src/lib/async/retry.ts": { 253 + "@typescript-eslint/no-explicit-any": { 254 + "count": 2 255 + } 256 + }, 257 + "src/lib/async/until.ts": { 258 + "@typescript-eslint/no-explicit-any": { 259 + "count": 2 260 + } 261 + }, 262 + "src/lib/broadcast/stub.ts": { 263 + "@typescript-eslint/no-explicit-any": { 264 + "count": 1 265 + } 266 + }, 267 + "src/lib/functions.ts": { 268 + "@typescript-eslint/no-explicit-any": { 269 + "count": 6 270 + } 271 + }, 272 + "src/lib/getUserDisplayName.ts": { 273 + "@typescript-eslint/no-explicit-any": { 274 + "count": 1 275 + } 276 + }, 277 + "src/lib/hooks/useAccountSwitcher.ts": { 278 + "@typescript-eslint/no-explicit-any": { 279 + "count": 1 280 + } 281 + }, 282 + "src/lib/hooks/useCleanError.ts": { 283 + "@typescript-eslint/no-explicit-any": { 284 + "count": 1 285 + } 286 + }, 287 + "src/lib/hooks/useNonReactiveCallback.ts": { 288 + "@typescript-eslint/no-explicit-any": { 289 + "count": 1 290 + } 291 + }, 292 + "src/lib/hooks/useOTAUpdates.ts": { 293 + "@typescript-eslint/no-explicit-any": { 294 + "count": 2 295 + } 296 + }, 297 + "src/lib/hooks/useRequireEmailVerification.tsx": { 298 + "@typescript-eslint/no-explicit-any": { 299 + "count": 2 300 + } 301 + }, 302 + "src/lib/hooks/useToggleMutationQueue.ts": { 303 + "@typescript-eslint/no-explicit-any": { 304 + "count": 2 305 + } 306 + }, 307 + "src/lib/hooks/useWebScrollRestoration.ts": { 308 + "@typescript-eslint/no-explicit-any": { 309 + "count": 2 310 + } 311 + }, 312 + "src/lib/international-telephone-codes.ts": { 313 + "@typescript-eslint/no-explicit-any": { 314 + "count": 1 315 + } 316 + }, 317 + "src/lib/media/manip.ts": { 318 + "@typescript-eslint/no-explicit-any": { 319 + "count": 1 320 + } 321 + }, 322 + "src/lib/media/save-image.ios.ts": { 323 + "@typescript-eslint/no-explicit-any": { 324 + "count": 1 325 + } 326 + }, 327 + "src/lib/media/save-image.ts": { 328 + "@typescript-eslint/no-explicit-any": { 329 + "count": 1 330 + } 331 + }, 332 + "src/lib/merge-refs.ts": { 333 + "@typescript-eslint/no-explicit-any": { 334 + "count": 1 335 + } 336 + }, 337 + "src/lib/react-query.tsx": { 338 + "@typescript-eslint/no-explicit-any": { 339 + "count": 1 340 + } 341 + }, 342 + "src/lib/routes/router.ts": { 343 + "@typescript-eslint/no-explicit-any": { 344 + "count": 1 345 + } 346 + }, 347 + "src/lib/routes/types.ts": { 348 + "@typescript-eslint/no-explicit-any": { 349 + "count": 1 350 + } 351 + }, 352 + "src/lib/strings/errors.ts": { 353 + "@typescript-eslint/no-explicit-any": { 354 + "count": 1 355 + } 356 + }, 357 + "src/locale/i18n.web.ts": { 358 + "@typescript-eslint/no-explicit-any": { 359 + "count": 1 360 + } 361 + }, 362 + "src/platform/polyfills.web.ts": { 363 + "@typescript-eslint/no-explicit-any": { 364 + "count": 1 365 + } 366 + }, 367 + "src/screens/Bookmarks/index.tsx": { 368 + "@typescript-eslint/no-explicit-any": { 369 + "count": 1 370 + } 371 + }, 372 + "src/screens/Deactivated.tsx": { 373 + "@typescript-eslint/no-explicit-any": { 374 + "count": 1 375 + } 376 + }, 377 + "src/screens/Login/ChooseAccountForm.tsx": { 378 + "@typescript-eslint/no-explicit-any": { 379 + "count": 1 380 + } 381 + }, 382 + "src/screens/Login/ForgotPasswordForm.tsx": { 383 + "@typescript-eslint/no-explicit-any": { 384 + "count": 1 385 + } 386 + }, 387 + "src/screens/Login/LoginForm.tsx": { 388 + "@typescript-eslint/no-explicit-any": { 389 + "count": 1 390 + } 391 + }, 392 + "src/screens/Login/SetNewPasswordForm.tsx": { 393 + "@typescript-eslint/no-explicit-any": { 394 + "count": 1 395 + } 396 + }, 397 + "src/screens/Moderation/index.tsx": { 398 + "@typescript-eslint/no-explicit-any": { 399 + "count": 2 400 + } 401 + }, 402 + "src/screens/ModerationInteractionSettings/index.tsx": { 403 + "@typescript-eslint/no-explicit-any": { 404 + "count": 1 405 + } 406 + }, 407 + "src/screens/Onboarding/StepFinished/index.tsx": { 408 + "@typescript-eslint/no-explicit-any": { 409 + "count": 1 410 + } 411 + }, 412 + "src/screens/Onboarding/StepInterests/index.tsx": { 413 + "@typescript-eslint/no-explicit-any": { 414 + "count": 1 415 + } 416 + }, 417 + "src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx": { 418 + "@typescript-eslint/no-explicit-any": { 419 + "count": 2 420 + } 421 + }, 422 + "src/screens/PostThread/index.tsx": { 423 + "@typescript-eslint/no-explicit-any": { 424 + "count": 2 425 + } 426 + }, 427 + "src/screens/Profile/Header/EditProfileDialog.tsx": { 428 + "@typescript-eslint/no-explicit-any": { 429 + "count": 3 430 + } 431 + }, 432 + "src/screens/Profile/Header/ProfileHeaderLabeler.tsx": { 433 + "@typescript-eslint/no-explicit-any": { 434 + "count": 2 435 + } 436 + }, 437 + "src/screens/Profile/Header/Shell.tsx": { 438 + "@typescript-eslint/no-explicit-any": { 439 + "count": 1 440 + } 441 + }, 442 + "src/screens/Profile/Sections/Feed.tsx": { 443 + "@typescript-eslint/no-explicit-any": { 444 + "count": 1 445 + } 446 + }, 447 + "src/screens/Profile/components/GermButton.tsx": { 448 + "@typescript-eslint/no-explicit-any": { 449 + "count": 1 450 + } 451 + }, 452 + "src/screens/Settings/FindContactsSettings.tsx": { 453 + "@typescript-eslint/no-explicit-any": { 454 + "count": 1 455 + } 456 + }, 457 + "src/screens/Settings/components/ChangePasswordDialog.tsx": { 458 + "@typescript-eslint/no-explicit-any": { 459 + "count": 2 460 + } 461 + }, 462 + "src/screens/Settings/components/DeactivateAccountDialog.tsx": { 463 + "@typescript-eslint/no-explicit-any": { 464 + "count": 1 465 + } 466 + }, 467 + "src/screens/Settings/components/DeleteAccountDialog.tsx": { 468 + "@typescript-eslint/no-explicit-any": { 469 + "count": 2 470 + } 471 + }, 472 + "src/screens/Signup/StepInfo/Policies.tsx": { 473 + "@typescript-eslint/no-explicit-any": { 474 + "count": 1 475 + } 476 + }, 477 + "src/screens/SignupQueued.tsx": { 478 + "@typescript-eslint/no-explicit-any": { 479 + "count": 1 480 + } 481 + }, 482 + "src/state/cache/types.ts": { 483 + "@typescript-eslint/no-explicit-any": { 484 + "count": 1 485 + } 486 + }, 487 + "src/state/feed-feedback.tsx": { 488 + "@typescript-eslint/no-explicit-any": { 489 + "count": 3 490 + } 491 + }, 492 + "src/state/messages/convo/agent.ts": { 493 + "@typescript-eslint/no-explicit-any": { 494 + "count": 2 495 + } 496 + }, 497 + "src/state/messages/events/agent.ts": { 498 + "@typescript-eslint/no-explicit-any": { 499 + "count": 2 500 + } 501 + }, 502 + "src/state/persisted/index.ts": { 503 + "@typescript-eslint/no-explicit-any": { 504 + "count": 1 505 + } 506 + }, 507 + "src/state/persisted/index.web.ts": { 508 + "@typescript-eslint/no-explicit-any": { 509 + "count": 1 510 + } 511 + }, 512 + "src/state/persisted/util.ts": { 513 + "@typescript-eslint/no-explicit-any": { 514 + "count": 1 515 + } 516 + }, 517 + "src/state/queries/notifications/feed.ts": { 518 + "@typescript-eslint/no-explicit-any": { 519 + "count": 2 520 + } 521 + }, 522 + "src/state/queries/nuxs/definitions.ts": { 523 + "@typescript-eslint/no-explicit-any": { 524 + "count": 1 525 + } 526 + }, 527 + "src/state/queries/pinned-post.ts": { 528 + "@typescript-eslint/no-explicit-any": { 529 + "count": 1 530 + } 531 + }, 532 + "src/state/queries/post-feed.ts": { 533 + "@typescript-eslint/no-explicit-any": { 534 + "count": 3 535 + } 536 + }, 537 + "src/state/queries/postgate/index.ts": { 538 + "@typescript-eslint/no-explicit-any": { 539 + "count": 2 540 + } 541 + }, 542 + "src/state/queries/search-posts.ts": { 543 + "@typescript-eslint/no-explicit-any": { 544 + "count": 2 545 + } 546 + }, 547 + "src/state/queries/threadgate/index.ts": { 548 + "@typescript-eslint/no-explicit-any": { 549 + "count": 1 550 + } 551 + }, 552 + "src/state/queries/util.ts": { 553 + "@typescript-eslint/no-explicit-any": { 554 + "count": 1 555 + } 556 + }, 557 + "src/state/session/agent.ts": { 558 + "@typescript-eslint/no-explicit-any": { 559 + "count": 1 560 + } 561 + }, 562 + "src/state/shell/progress-guide.tsx": { 563 + "@typescript-eslint/no-explicit-any": { 564 + "count": 1 565 + } 566 + }, 567 + "src/storage/index.ts": { 568 + "@typescript-eslint/no-explicit-any": { 569 + "count": 8 570 + } 571 + }, 572 + "src/view/com/composer/Composer.tsx": { 573 + "@typescript-eslint/no-explicit-any": { 574 + "count": 2 575 + } 576 + }, 577 + "src/view/com/composer/drafts/state/queries.ts": { 578 + "@typescript-eslint/no-explicit-any": { 579 + "count": 2 580 + } 581 + }, 582 + "src/view/com/composer/photos/OpenCameraBtn.tsx": { 583 + "@typescript-eslint/no-explicit-any": { 584 + "count": 1 585 + } 586 + }, 587 + "src/view/com/feeds/ComposerPrompt.tsx": { 588 + "@typescript-eslint/no-explicit-any": { 589 + "count": 2 590 + } 591 + }, 592 + "src/view/com/feeds/ProfileFeedgens.tsx": { 593 + "@typescript-eslint/no-explicit-any": { 594 + "count": 3 595 + } 596 + }, 597 + "src/view/com/lightbox/ImageViewing/@types/index.ts": { 598 + "@typescript-eslint/no-explicit-any": { 599 + "count": 1 600 + } 601 + }, 602 + "src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx": { 603 + "@typescript-eslint/no-explicit-any": { 604 + "count": 1 605 + } 606 + }, 607 + "src/view/com/lightbox/ImageViewing/index.tsx": { 608 + "@typescript-eslint/no-explicit-any": { 609 + "count": 1 610 + } 611 + }, 612 + "src/view/com/lists/ListMembers.tsx": { 613 + "@typescript-eslint/no-explicit-any": { 614 + "count": 3 615 + } 616 + }, 617 + "src/view/com/lists/MyLists.tsx": { 618 + "@typescript-eslint/no-explicit-any": { 619 + "count": 2 620 + } 621 + }, 622 + "src/view/com/lists/ProfileLists.tsx": { 623 + "@typescript-eslint/no-explicit-any": { 624 + "count": 3 625 + } 626 + }, 627 + "src/view/com/notifications/NotificationFeed.tsx": { 628 + "@typescript-eslint/no-explicit-any": { 629 + "count": 2 630 + } 631 + }, 632 + "src/view/com/notifications/NotificationFeedItem.tsx": { 633 + "@typescript-eslint/no-explicit-any": { 634 + "count": 3 635 + } 636 + }, 637 + "src/view/com/pager/Pager.tsx": { 638 + "@typescript-eslint/no-explicit-any": { 639 + "count": 4 640 + } 641 + }, 642 + "src/view/com/pager/PagerWithHeader.tsx": { 643 + "@typescript-eslint/no-explicit-any": { 644 + "count": 3 645 + } 646 + }, 647 + "src/view/com/pager/PagerWithHeader.web.tsx": { 648 + "@typescript-eslint/no-explicit-any": { 649 + "count": 2 650 + } 651 + }, 652 + "src/view/com/pager/TabBar.web.tsx": { 653 + "@typescript-eslint/no-explicit-any": { 654 + "count": 1 655 + } 656 + }, 657 + "src/view/com/posts/FeedShutdownMsg.tsx": { 658 + "@typescript-eslint/no-explicit-any": { 659 + "count": 2 660 + } 661 + }, 662 + "src/view/com/posts/PostFeed.tsx": { 663 + "@typescript-eslint/no-explicit-any": { 664 + "count": 1 665 + } 666 + }, 667 + "src/view/com/posts/PostFeedErrorMessage.tsx": { 668 + "@typescript-eslint/no-explicit-any": { 669 + "count": 1 670 + } 671 + }, 672 + "src/view/com/profile/ProfileMenu.tsx": { 673 + "@typescript-eslint/no-explicit-any": { 674 + "count": 6 675 + } 676 + }, 677 + "src/view/com/testing/TestCtrls.e2e.tsx": { 678 + "@typescript-eslint/no-explicit-any": { 679 + "count": 2 680 + } 681 + }, 682 + "src/view/com/util/EmptyState.tsx": { 683 + "@typescript-eslint/no-explicit-any": { 684 + "count": 1 685 + } 686 + }, 687 + "src/view/com/util/ErrorBoundary.tsx": { 688 + "@typescript-eslint/no-explicit-any": { 689 + "count": 2 690 + } 691 + }, 692 + "src/view/com/util/EventStopper.tsx": { 693 + "@typescript-eslint/no-explicit-any": { 694 + "count": 1 695 + } 696 + }, 697 + "src/view/com/util/Link.tsx": { 698 + "@typescript-eslint/no-explicit-any": { 699 + "count": 2 700 + } 701 + }, 702 + "src/view/com/util/List.tsx": { 703 + "@typescript-eslint/no-explicit-any": { 704 + "count": 2 705 + } 706 + }, 707 + "src/view/com/util/List.web.tsx": { 708 + "@typescript-eslint/no-explicit-any": { 709 + "count": 14 710 + } 711 + }, 712 + "src/view/com/util/MainScrollProvider.tsx": { 713 + "@typescript-eslint/no-explicit-any": { 714 + "count": 2 715 + } 716 + }, 717 + "src/view/com/util/ViewSelector.tsx": { 718 + "@typescript-eslint/no-explicit-any": { 719 + "count": 6 720 + } 721 + }, 722 + "src/view/com/util/Views.tsx": { 723 + "@typescript-eslint/no-explicit-any": { 724 + "count": 1 725 + } 726 + }, 727 + "src/view/com/util/fab/FABInner.tsx": { 728 + "@typescript-eslint/no-explicit-any": { 729 + "count": 1 730 + } 731 + }, 732 + "src/view/screens/Debug.tsx": { 733 + "@typescript-eslint/no-explicit-any": { 734 + "count": 1 735 + } 736 + }, 737 + "src/view/screens/DebugMod.tsx": { 738 + "@typescript-eslint/no-explicit-any": { 739 + "count": 1 740 + } 741 + }, 742 + "src/view/shell/createNativeStackNavigatorWithAuth.tsx": { 743 + "@typescript-eslint/no-explicit-any": { 744 + "count": 1 745 + } 746 + } 747 + }
+1 -1
eslint.config.mjs
··· 232 232 * v8 `warn` ones are probably worth fixing. `off` ones are a bit too 233 233 * nit-picky 234 234 */ 235 - '@typescript-eslint/no-explicit-any': 'off', 235 + '@typescript-eslint/no-explicit-any': 'error', 236 236 '@typescript-eslint/ban-ts-comment': 'off', 237 237 '@typescript-eslint/no-empty-object-type': 'off', 238 238 '@typescript-eslint/no-unsafe-function-type': 'off',
+7
modules/BlueskyNSE/Info.plist
··· 8 8 <string>com.apple.usernotifications.service</string> 9 9 <key>NSExtensionPrincipalClass</key> 10 10 <string>$(PRODUCT_MODULE_NAME).NotificationService</string> 11 + <key>NSExtensionAttributes</key> 12 + <dict> 13 + <key>IntentsSupported</key> 14 + <array> 15 + <string>INSendMessageIntent</string> 16 + </array> 17 + </dict> 11 18 </dict> 12 19 <key>MainAppScheme</key> 13 20 <string>bluesky</string>
+84 -7
modules/BlueskyNSE/NotificationService.swift
··· 1 1 import UserNotifications 2 2 import UIKit 3 + import Intents 3 4 4 5 let APP_GROUP = "group.app.witchsky" 5 6 typealias ContentHandler = (UNNotificationContent) -> Void ··· 40 41 } 41 42 42 43 self.bestAttempt = bestAttempt 43 - if reason == "chat-message" { 44 + 45 + if reason == "chat-message" || reason == "chat-reaction" { 44 46 mutateWithChatMessage(bestAttempt) 47 + let finalContent = createCommunicationNotification( 48 + from: bestAttempt, 49 + userInfo: request.content.userInfo 50 + ) 51 + contentHandler(finalContent) 45 52 } else { 46 53 mutateWithBadge(bestAttempt) 54 + contentHandler(bestAttempt) 47 55 } 48 - 49 - // Any image downloading (or other network tasks) should be handled at the end 50 - // of this block. Otherwise, if there is a timeout and serviceExtensionTimeWillExpire 51 - // gets called, we might not have all the needed mutations completed in time. 52 - 53 - contentHandler(bestAttempt) 54 56 } 55 57 56 58 override func serviceExtensionTimeWillExpire() { ··· 59 61 return 60 62 } 61 63 contentHandler(bestAttempt) 64 + } 65 + 66 + // MARK: Communication Notification 67 + 68 + func createCommunicationNotification( 69 + from content: UNMutableNotificationContent, 70 + userInfo: [AnyHashable: Any] 71 + ) -> UNNotificationContent { 72 + let senderDisplayName = userInfo["senderDisplayName"] as? String ?? "Unknown" 73 + let convoId = userInfo["convoId"] as? String 74 + var avatarImage: INImage? = nil 75 + if let avatarUrlString = userInfo["senderAvatarUrl"] as? String { 76 + avatarImage = downloadAvatarImage(from: avatarUrlString) 77 + } 78 + 79 + let senderHandleValue = userInfo["senderHandle"] as? String 80 + let senderHandle = INPersonHandle(value: senderHandleValue, type: .unknown) 81 + let sender = INPerson( 82 + personHandle: senderHandle, 83 + nameComponents: nil, 84 + displayName: senderDisplayName, 85 + image: avatarImage, 86 + contactIdentifier: nil, 87 + customIdentifier: nil 88 + ) 89 + 90 + let intent = INSendMessageIntent( 91 + recipients: nil, 92 + outgoingMessageType: .outgoingMessageText, 93 + content: content.body, 94 + speakableGroupName: nil, 95 + conversationIdentifier: convoId, 96 + serviceName: nil, 97 + sender: sender, 98 + attachments: nil 99 + ) 100 + 101 + let interaction = INInteraction(intent: intent, response: nil) 102 + interaction.direction = .incoming 103 + interaction.donate(completion: nil) 104 + 105 + do { 106 + return try content.updating(from: intent) 107 + } catch { 108 + return content 109 + } 110 + } 111 + 112 + func downloadAvatarImage(from urlString: String) -> INImage? { 113 + let thumbnailUrlString = urlString.replacingOccurrences( 114 + of: "/img/avatar/", 115 + with: "/img/avatar_thumbnail/" 116 + ) 117 + 118 + guard let url = URL(string: thumbnailUrlString) else { return nil } 119 + 120 + var request = URLRequest(url: url) 121 + request.timeoutInterval = 5 122 + 123 + var imageData: Data? = nil 124 + let semaphore = DispatchSemaphore(value: 0) 125 + 126 + let task = URLSession.shared.dataTask(with: request) { data, response, error in 127 + if let data = data, 128 + let httpResponse = response as? HTTPURLResponse, 129 + httpResponse.statusCode == 200 { 130 + imageData = data 131 + } 132 + semaphore.signal() 133 + } 134 + task.resume() 135 + semaphore.wait() 136 + 137 + guard let data = imageData else { return nil } 138 + return INImage(imageData: data) 62 139 } 63 140 64 141 // MARK: Mutations
+1 -1
modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
··· 13 13 return 14 14 } 15 15 16 - if (remoteMessage.data["reason"] == "chat-message") { 16 + if (remoteMessage.data["reason"] == "chat-message" || remoteMessage.data["reason"] == "chat-reaction") { 17 17 mutateWithChatMessage(remoteMessage) 18 18 } else { 19 19 mutateWithOtherReason(remoteMessage)
+2 -1
package.json
··· 57 57 "lint": "eslint --cache --quiet src", 58 58 "lint-native": "swiftlint ./modules && ktlint ./modules", 59 59 "lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules", 60 - "typecheck": "tsc --project ./tsconfig.check.json", 60 + "typecheck": "tsgo --project ./tsconfig.check.json", 61 61 "e2e:mock-server": "cd dev-env && pnpm start", 62 62 "e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios", 63 63 "e2e:build-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android", ··· 290 290 "@types/react": "^19.2.14", 291 291 "@types/react-dom": "^19.2.3", 292 292 "@vitejs/plugin-react": "^6.0.1", 293 + "@typescript/native-preview": "^7.0.0-dev.20260428.1", 293 294 "babel-jest": "^29.7.0", 294 295 "babel-loader": "^10.1.1", 295 296 "babel-plugin-module-resolver": "^5.0.3",
+81
pnpm-lock.yaml
··· 709 709 '@types/react-dom': 710 710 specifier: ^19.2.3 711 711 version: 19.2.3(@types/react@19.2.14) 712 + '@typescript/native-preview': 713 + specifier: ^7.0.0-dev.20260428.1 714 + version: 7.0.0-dev.20260429.1 712 715 '@vitejs/plugin-react': 713 716 specifier: ^6.0.1 714 717 version: 6.0.1(babel-plugin-react-compiler@19.1.0-rc.3)(vite@8.0.10(@types/node@20.19.39)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.2)(yaml@2.8.3)) ··· 5687 5690 '@typescript-eslint/visitor-keys@8.59.1-alpha.4': 5688 5691 resolution: {integrity: sha512-dpyUVPlBGylXXnr4b3eUS2xhMQ/hbjlSElv6uUO5IE2Yej334qQ7SL+gcHdKHrdZytz7nsoNpqq5RRHaXnVlbQ==} 5689 5692 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 5693 + 5694 + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260429.1': 5695 + resolution: {integrity: sha512-+Rl8iPf+vYKq0fnb8euEOJxxvE/abEOWmhdllQIe+Shd8xhS7UVi+2WunsP1GyH2Ofc+N8rGYz0/dMnhrRYEZA==} 5696 + engines: {node: '>=16.20.0'} 5697 + cpu: [arm64] 5698 + os: [darwin] 5699 + 5700 + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260429.1': 5701 + resolution: {integrity: sha512-be6Y7VVJz+usdI1ifCHy5mcldpxf8KXGYoyIp8w5Rd54zUtvtkYEJJWKzV5/bJt4bsQLLcp1i0vD4KJSr06Tmg==} 5702 + engines: {node: '>=16.20.0'} 5703 + cpu: [x64] 5704 + os: [darwin] 5705 + 5706 + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260429.1': 5707 + resolution: {integrity: sha512-44amAEH/VxG6K/hrAmhiyOTnwoTzm7bj0ja7d8sV8Iuocv37oUiSB/8OgJLytLqfIh+Q6kipfTwY6Do3jh6THQ==} 5708 + engines: {node: '>=16.20.0'} 5709 + cpu: [arm64] 5710 + os: [linux] 5711 + 5712 + '@typescript/native-preview-linux-arm@7.0.0-dev.20260429.1': 5713 + resolution: {integrity: sha512-ngN6+qt5bPdp2zzasShoT4UONGXr+tvzHdz4NjuitwhiAF/d70CseXunb4syaudl1a+lJyTHro/ALTC0hRf6vA==} 5714 + engines: {node: '>=16.20.0'} 5715 + cpu: [arm] 5716 + os: [linux] 5717 + 5718 + '@typescript/native-preview-linux-x64@7.0.0-dev.20260429.1': 5719 + resolution: {integrity: sha512-haAOqc0fJCZkt4RDi0/ZQGBdDfpDzr2N+mEcR+FbiYQD3Y00kOK34hXSrjZafO2kq56ZDWunvCaUTCev0fJDbA==} 5720 + engines: {node: '>=16.20.0'} 5721 + cpu: [x64] 5722 + os: [linux] 5723 + 5724 + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260429.1': 5725 + resolution: {integrity: sha512-J5O0tGVGqOZHbqm9ijRnZ5ADfPqYTjFIwZtYKpQL1yj1dZnUzMszO8P3bnOSfYD//DJhZINQyJzpPJxu29uiwQ==} 5726 + engines: {node: '>=16.20.0'} 5727 + cpu: [arm64] 5728 + os: [win32] 5729 + 5730 + '@typescript/native-preview-win32-x64@7.0.0-dev.20260429.1': 5731 + resolution: {integrity: sha512-/OZ99Hi/32huvZQ5fdqTwqLvZtKC3QrCXmLuKfMyVuBisV/TSd6LhlFQLolvIpr7/E530mnFZ4sXjgDEzVFqAw==} 5732 + engines: {node: '>=16.20.0'} 5733 + cpu: [x64] 5734 + os: [win32] 5735 + 5736 + '@typescript/native-preview@7.0.0-dev.20260429.1': 5737 + resolution: {integrity: sha512-SGKnvs5EA+V1spnraYJqum/lEajE0IQ2bVVPC72hFfWjoCfQ6N7iVYxLUGreiE3VFyQWWQBPgXZrRUFnawVvpQ==} 5738 + engines: {node: '>=16.20.0'} 5739 + hasBin: true 5690 5740 5691 5741 '@ungap/structured-clone@1.3.0': 5692 5742 resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} ··· 19078 19128 dependencies: 19079 19129 '@typescript-eslint/types': 8.59.1-alpha.4 19080 19130 eslint-visitor-keys: 5.0.1 19131 + 19132 + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260429.1': 19133 + optional: true 19134 + 19135 + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260429.1': 19136 + optional: true 19137 + 19138 + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260429.1': 19139 + optional: true 19140 + 19141 + '@typescript/native-preview-linux-arm@7.0.0-dev.20260429.1': 19142 + optional: true 19143 + 19144 + '@typescript/native-preview-linux-x64@7.0.0-dev.20260429.1': 19145 + optional: true 19146 + 19147 + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260429.1': 19148 + optional: true 19149 + 19150 + '@typescript/native-preview-win32-x64@7.0.0-dev.20260429.1': 19151 + optional: true 19152 + 19153 + '@typescript/native-preview@7.0.0-dev.20260429.1': 19154 + optionalDependencies: 19155 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260429.1 19156 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260429.1 19157 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260429.1 19158 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260429.1 19159 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260429.1 19160 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260429.1 19161 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260429.1 19081 19162 19082 19163 '@ungap/structured-clone@1.3.0': {} 19083 19164
+2 -3
src/alf/fonts.ts
··· 1 - import {type TextStyle} from 'react-native' 1 + import {type FontVariant, type TextStyle} from 'react-native' 2 2 3 3 import {IS_ANDROID, IS_WEB} from '#/env' 4 4 import {type Device, device} from '#/storage' ··· 128 128 * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant} 129 129 */ 130 130 if (IS_WEB) { 131 - // @ts-expect-error - web supports 'unicode' as a valid value for fontVariant 132 131 style.fontVariant = (style.fontVariant || []).concat( 133 132 'no-contextual', 134 - 'unicode', 133 + 'unicode' as FontVariant, // web supports 'unicode' as a valid value for fontVariant 135 134 ) 136 135 } else { 137 136 style.fontVariant = (style.fontVariant || []).concat('no-contextual')
+6 -1
src/analytics/metrics/types.ts
··· 556 556 | 'FindContacts' 557 557 } 558 558 'chat:create': { 559 - logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' 559 + logContext: 560 + | 'ProfileHeader' 561 + | 'NewChatDialog' 562 + | 'SendViaChatDialog' 563 + | 'ConvoSettings' 560 564 } 561 565 'chat:open': { 562 566 logContext: ··· 564 568 | 'NewChatDialog' 565 569 | 'ChatsList' 566 570 | 'SendViaChatDialog' 571 + | 'ConvoSettings' 567 572 } 568 573 'groupchat:create': { 569 574 logContext: 'NewChatDialog'
+21 -11
src/components/Autocomplete/useAutocomplete/index.ts
··· 1 - import {useCallback} from 'react' 1 + import {useCallback, useMemo} from 'react' 2 2 import {moderateProfile, type ModerationOpts} from '@atproto/api' 3 3 import {keepPreviousData, useQuery} from '@tanstack/react-query' 4 4 ··· 90 90 } 91 91 } 92 92 93 - if (showSearchFallback && q) { 94 - results.unshift({ 95 - key: `search-${q}`, 96 - type: 'search' as const, 97 - value: q, 98 - }) 99 - } 100 - 101 93 return results 102 94 }, 103 - [q, showSearchFallback, moderationOpts], 95 + [q, moderationOpts], 104 96 ), 105 97 placeholderData: keepPreviousData, 106 98 }) 107 99 100 + const items = useMemo(() => { 101 + if (!query.data) { 102 + return [] 103 + } 104 + 105 + const results = [...query.data] 106 + 107 + if (showSearchFallback && q) { 108 + results.unshift({ 109 + key: `search-${q}`, 110 + type: 'search' as const, 111 + value: q, 112 + }) 113 + } 114 + 115 + return results 116 + }, [query.data, showSearchFallback, q]) 117 + 108 118 return { 109 119 query: q, 110 - items: query.data || [], 120 + items, 111 121 } 112 122 } 113 123
+87 -177
src/components/AvatarBubbles.tsx
··· 1 - import {useCallback, useEffect} from 'react' 2 - import {type StyleProp, View, type ViewStyle} from 'react-native' 1 + import {useEffect} from 'react' 2 + import {View} from 'react-native' 3 3 import Animated, { 4 4 Easing, 5 - interpolate, 6 5 type SharedValue, 7 6 useAnimatedStyle, 8 7 useSharedValue, ··· 16 15 import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person' 17 16 import type * as bsky from '#/types/bsky' 18 17 18 + type Layout = { 19 + size: number 20 + x: number 21 + y: number 22 + zIndex?: number 23 + border?: boolean 24 + } 25 + 19 26 type Props = { 20 27 animate?: boolean 21 28 profiles: bsky.profile.AnyProfileView[] 22 - size?: 'small' | 'medium' | 'large' | number 29 + size?: number 23 30 } 24 31 25 32 export function AvatarBubbles({ 26 33 animate = false, 27 34 profiles: allProfiles, 28 - size = 'large', 35 + size = 120, 29 36 }: Props) { 30 37 const {currentAccount} = useSession() 31 38 const profiles = 32 39 allProfiles.length > 2 33 40 ? allProfiles.filter(p => p.did !== currentAccount?.did) 34 41 : allProfiles 35 - const containerSize = 36 - typeof size === 'number' 37 - ? size 38 - : size === 'small' 39 - ? 40 40 - : size === 'medium' 41 - ? 56 42 - : 120 43 - const scale = 44 - typeof size === 'number' 45 - ? size / 120 46 - : size === 'small' 47 - ? 40 / 120 48 - : size === 'medium' 49 - ? 56 / 120 50 - : 1 51 - const marginOffset = 52 - (typeof size === 'number' && size < 120) || 53 - size === 'small' || 54 - size === 'medium' 55 - ? -2 56 - : 0 42 + const scale = size / 120 43 + const marginOffset = size < 120 ? -2 : 0 57 44 58 45 const initialValue = animate ? 0 : 1 59 46 const p0 = useSharedValue(initialValue) ··· 61 48 const p2 = useSharedValue(initialValue) 62 49 const p3 = useSharedValue(initialValue) 63 50 64 - const animateScale = (p: Animated.SharedValue<number>, index: number) => { 65 - p.set(0) 66 - p.set(() => 67 - withDelay( 68 - 500 + index * 100, 69 - withTiming(1, { 70 - duration: 250, 71 - easing: Easing.out(Easing.back(1.75)), 72 - }), 73 - ), 74 - ) 75 - } 76 - 77 - const playScaleAnimation = useCallback(() => { 78 - animateScale(p0, 0) 79 - animateScale(p1, 1) 80 - animateScale(p2, 2) 81 - animateScale(p3, 3) 82 - }, [p0, p1, p2, p3]) 83 - 84 51 useEffect(() => { 85 52 if (!animate) return 86 - playScaleAnimation() 87 - }, [animate, playScaleAnimation]) 88 - 89 - let avatars = ( 90 - <> 91 - <AvatarBubble 92 - profile={profiles[0]} 93 - scale={p0} 94 - size={76} 95 - x={-2} 96 - y={-2} 97 - style={[a.z_20]} 98 - includeProfileBorder 99 - /> 100 - <AvatarBubble 101 - profile={profiles[1]} 102 - scale={p1} 103 - size={76} 104 - x={42} 105 - y={42} 106 - style={[a.z_10]} 107 - includeProfileBorder 108 - /> 109 - </> 110 - ) 111 - 112 - if (profiles.length === 3) { 113 - avatars = ( 114 - <> 115 - <AvatarBubble 116 - profile={profiles[0]} 117 - scale={p0} 118 - size={68} 119 - x={-2} 120 - y={-2} 121 - /> 122 - <AvatarBubble 123 - profile={profiles[1]} 124 - scale={p1} 125 - size={56} 126 - x={38} 127 - y={62} 128 - /> 129 - <AvatarBubble 130 - profile={profiles[2]} 131 - scale={p2} 132 - size={46} 133 - x={71} 134 - y={18} 135 - /> 136 - </> 137 - ) 138 - } 53 + const animateBubble = (p: SharedValue<number>, i: number) => { 54 + p.set(0) 55 + p.set(() => 56 + withDelay( 57 + 500 + i * 100, 58 + withTiming(1, { 59 + duration: 250, 60 + easing: Easing.out(Easing.back(1.75)), 61 + }), 62 + ), 63 + ) 64 + } 65 + animateBubble(p0, 0) 66 + animateBubble(p1, 1) 67 + animateBubble(p2, 2) 68 + animateBubble(p3, 3) 69 + }, [animate, p0, p1, p2, p3]) 139 70 140 - if (profiles.length >= 4) { 141 - avatars = ( 142 - <> 143 - <AvatarBubble 144 - profile={profiles[0]} 145 - scale={p0} 146 - size={68} 147 - x={-2} 148 - y={-2} 149 - /> 150 - <AvatarBubble 151 - profile={profiles[1]} 152 - scale={p1} 153 - size={56} 154 - x={60} 155 - y={49} 156 - /> 157 - <AvatarBubble 158 - profile={profiles[2]} 159 - scale={p2} 160 - size={42} 161 - x={14} 162 - y={74} 163 - /> 164 - <AvatarBubble profile={profiles[3]} scale={p3} size={32} x={72} y={9} /> 165 - </> 166 - ) 167 - } 71 + const scales = [p0, p1, p2, p3] 72 + const layouts = getLayouts(profiles.length) 168 73 169 74 return ( 170 - <Animated.View 171 - style={[ 172 - a.p_2xs, 173 - { 174 - height: containerSize, 175 - width: containerSize, 176 - }, 177 - ]}> 75 + <Animated.View style={[a.p_2xs, {height: size, width: size}]}> 178 76 <View 179 - style={[ 180 - { 181 - marginTop: marginOffset, 182 - marginLeft: marginOffset, 183 - transform: [{scale}], 184 - transformOrigin: 'top left', 185 - }, 186 - ]}> 187 - {avatars} 77 + style={{ 78 + marginTop: marginOffset, 79 + marginLeft: marginOffset, 80 + transform: [{scale}], 81 + transformOrigin: 'top left', 82 + }}> 83 + {layouts.map((layout, i) => ( 84 + <AvatarBubble 85 + key={i} 86 + profile={profiles[i]} 87 + scale={scales[i]} 88 + size={layout.size} 89 + x={layout.x} 90 + y={layout.y} 91 + zIndex={layout.zIndex} 92 + includeProfileBorder={layout.border} 93 + /> 94 + ))} 188 95 </View> 189 96 </Animated.View> 190 97 ) ··· 194 101 profile, 195 102 scale, 196 103 size, 197 - style, 198 104 x, 199 105 y, 106 + zIndex, 200 107 includeProfileBorder, 201 108 }: { 202 109 profile?: bsky.profile.AnyProfileView 203 110 scale: SharedValue<number> 204 111 size: number 205 - style?: StyleProp<ViewStyle> 206 112 x: number 207 113 y: number 114 + zIndex?: number 208 115 includeProfileBorder?: boolean 209 116 }) { 210 117 const t = useTheme() 211 118 212 119 const animatedStyle = useAnimatedStyle(() => ({ 213 - transform: [ 214 - {translateX: x}, 215 - {translateY: y}, 216 - {scale: interpolate(scale.get(), [0, 1], [0, 1])}, 217 - ], 120 + transform: [{translateX: x}, {translateY: y}, {scale: scale.get()}], 218 121 })) 219 122 220 123 return ( ··· 227 130 borderColor: t.atoms.text_inverted.color, 228 131 borderWidth: 2, 229 132 }, 230 - style, 133 + zIndex != null && {zIndex}, 231 134 animatedStyle, 232 135 ]}> 233 136 {profile ? ( 234 - <Avatar profile={profile} size={size} /> 137 + <UserAvatar 138 + avatar={profile.avatar} 139 + size={size} 140 + type="user" 141 + hideLiveBadge 142 + noBorder 143 + /> 235 144 ) : ( 236 145 <AvatarPlaceholder size={size} /> 237 146 )} ··· 239 148 ) 240 149 } 241 150 242 - function Avatar({ 243 - profile, 244 - size = 76, 245 - }: { 246 - profile: bsky.profile.AnyProfileView 247 - size?: number 248 - }) { 249 - return ( 250 - <UserAvatar 251 - avatar={profile.avatar} 252 - size={size} 253 - type="user" 254 - hideLiveBadge 255 - noBorder 256 - /> 257 - ) 258 - } 259 - 260 - function AvatarPlaceholder({size = 76}: {size?: number}) { 151 + function AvatarPlaceholder({size}: {size: number}) { 261 152 const t = useTheme() 262 153 263 154 return ( ··· 267 158 a.justify_center, 268 159 a.rounded_full, 269 160 t.atoms.bg_contrast_200, 270 - { 271 - width: size, 272 - height: size, 273 - }, 161 + {width: size, height: size}, 274 162 ]}> 275 163 <PersonIcon 276 164 width={size * 0.5} ··· 280 168 </View> 281 169 ) 282 170 } 171 + 172 + function getLayouts(count: number): Layout[] { 173 + if (count === 3) { 174 + return [ 175 + {size: 68, x: -2, y: -2}, 176 + {size: 56, x: 38, y: 62}, 177 + {size: 46, x: 71, y: 18}, 178 + ] 179 + } 180 + if (count >= 4) { 181 + return [ 182 + {size: 68, x: -2, y: -2}, 183 + {size: 56, x: 60, y: 49}, 184 + {size: 42, x: 14, y: 74}, 185 + {size: 32, x: 72, y: 9}, 186 + ] 187 + } 188 + return [ 189 + {size: 76, x: -2, y: -2, zIndex: 20, border: true}, 190 + {size: 76, x: 42, y: 42, zIndex: 10, border: true}, 191 + ] 192 + }
+34 -21
src/components/Button.tsx
··· 82 82 focused: boolean 83 83 pressed: boolean 84 84 disabled: boolean 85 + /** 86 + * Alias for hovered || focused || pressed 87 + */ 88 + interacting: boolean 85 89 } 86 90 87 91 export type ButtonContext = VariantProps & ButtonState 88 92 89 93 type NonTextElements = 90 - | React.ReactElement<any> 91 - | Iterable<React.ReactElement<any> | null | undefined | boolean> 94 + | React.ReactElement<unknown> 95 + | Iterable<React.ReactElement<unknown> | null | undefined | boolean> 96 + 97 + type WebLongPressPressableProps = { 98 + onPointerDown?: (e: PointerEvent) => void 99 + onPointerUp?: () => void 100 + onPointerLeave?: () => void 101 + onContextMenu?: (e: Event) => void 102 + } 92 103 93 104 export type ButtonProps = Pick< 94 105 PressableProps, ··· 114 125 style?: StyleProp<ViewStyle> 115 126 hoverStyle?: StyleProp<ViewStyle> 116 127 children: NonTextElements | ((context: ButtonContext) => NonTextElements) 117 - PressableComponent?: React.ComponentType<PressableProps> 128 + PressableComponent?: React.ComponentType< 129 + PressableProps & WebLongPressPressableProps 130 + > 118 131 } 119 132 120 133 export type ButtonTextProps = TextProps & ··· 125 138 focused: false, 126 139 pressed: false, 127 140 disabled: false, 141 + interacting: false, 128 142 }) 129 143 Context.displayName = 'ButtonContext' 130 144 ··· 162 176 * If a `color` is set, then we want to use the existing codepaths for 163 177 * "solid" buttons. This is to maintain backwards compatibility. 164 178 */ 165 - if (!variant && color) { 166 - variant = 'solid' 167 - } 179 + const resolvedVariant = !variant && color ? 'solid' : variant 168 180 169 181 const enableSquareButtons = useEnableSquareButtons() 170 182 ··· 296 308 * deprecation of `variant` prop. This redundant `variant` check is here 297 309 * just to make this handling easier to understand. 298 310 */ 299 - if (variant === 'solid') { 311 + if (resolvedVariant === 'solid') { 300 312 if (color === 'primary') { 301 313 if (!disabled) { 302 314 baseStyles.push({ ··· 375 387 * BEGIN DEPRECATED STYLES 376 388 */ 377 389 if (color === 'primary') { 378 - if (variant === 'outline') { 390 + if (resolvedVariant === 'outline') { 379 391 baseStyles.push(a.border, t.atoms.bg, { 380 392 borderWidth: 1, 381 393 }) ··· 392 404 borderColor: t.palette.primary_200, 393 405 }) 394 406 } 395 - } else if (variant === 'ghost') { 407 + } else if (resolvedVariant === 'ghost') { 396 408 if (!disabled) { 397 409 baseStyles.push(t.atoms.bg) 398 410 hoverStyles.push({ ··· 401 413 } 402 414 } 403 415 } else if (color === 'secondary') { 404 - if (variant === 'outline') { 416 + if (resolvedVariant === 'outline') { 405 417 baseStyles.push(a.border, t.atoms.bg, { 406 418 borderWidth: 1, 407 419 }) ··· 416 428 borderColor: t.palette.contrast_200, 417 429 }) 418 430 } 419 - } else if (variant === 'ghost') { 431 + } else if (resolvedVariant === 'ghost') { 420 432 if (!disabled) { 421 433 baseStyles.push(t.atoms.bg) 422 434 hoverStyles.push({ ··· 425 437 } 426 438 } 427 439 } else if (color === 'secondary_inverted') { 428 - if (variant === 'outline') { 440 + if (resolvedVariant === 'outline') { 429 441 baseStyles.push(a.border, t.atoms.bg, { 430 442 borderWidth: 1, 431 443 }) ··· 440 452 borderColor: t.palette.contrast_200, 441 453 }) 442 454 } 443 - } else if (variant === 'ghost') { 455 + } else if (resolvedVariant === 'ghost') { 444 456 if (!disabled) { 445 457 baseStyles.push(t.atoms.bg) 446 458 hoverStyles.push({ ··· 449 461 } 450 462 } 451 463 } else if (color === 'negative') { 452 - if (variant === 'outline') { 464 + if (resolvedVariant === 'outline') { 453 465 baseStyles.push(a.border, t.atoms.bg, { 454 466 borderWidth: 1, 455 467 }) ··· 466 478 borderColor: t.palette.negative_200, 467 479 }) 468 480 } 469 - } else if (variant === 'ghost') { 481 + } else if (resolvedVariant === 'ghost') { 470 482 if (!disabled) { 471 483 baseStyles.push(t.atoms.bg) 472 484 hoverStyles.push({ ··· 475 487 } 476 488 } 477 489 } else if (color === 'negative_subtle') { 478 - if (variant === 'outline') { 490 + if (resolvedVariant === 'outline') { 479 491 baseStyles.push(a.border, t.atoms.bg, { 480 492 borderWidth: 1, 481 493 }) ··· 492 504 borderColor: t.palette.negative_200, 493 505 }) 494 506 } 495 - } else if (variant === 'ghost') { 507 + } else if (resolvedVariant === 'ghost') { 496 508 if (!disabled) { 497 509 baseStyles.push(t.atoms.bg) 498 510 hoverStyles.push({ ··· 591 603 baseStyles, 592 604 hoverStyles, 593 605 } 594 - }, [t, variant, color, size, shape, disabled, enableSquareButtons]) 606 + }, [t, resolvedVariant, color, size, shape, disabled, enableSquareButtons]) 595 607 596 608 const context = useMemo<ButtonContext>( 597 609 () => ({ 598 610 ...state, 599 - variant, 611 + interacting: state.hovered || state.focused || state.pressed, 612 + variant: resolvedVariant, 600 613 color, 601 614 size, 602 615 shape, 603 616 disabled: disabled || false, 604 617 }), 605 - [state, variant, color, size, shape, disabled], 618 + [state, resolvedVariant, color, size, shape, disabled], 606 619 ) 607 620 608 621 return ( ··· 617 630 onPointerLeave, 618 631 onContextMenu, 619 632 } 620 - : {}) as any)} 633 + : {}) satisfies WebLongPressPressableProps)} 621 634 // @ts-ignore - this will always be a pressable 622 635 ref={ref} 623 636 aria-label={label}
+6 -6
src/components/ProfileCard.tsx
··· 205 205 <View 206 206 style={[ 207 207 a.rounded_full, 208 - t.atoms.bg_contrast_25, 208 + t.atoms.bg_contrast_50, 209 209 { 210 210 width: size, 211 211 height: size, ··· 351 351 <View 352 352 style={[ 353 353 a.rounded_xs, 354 - t.atoms.bg_contrast_25, 354 + t.atoms.bg_contrast_50, 355 355 { 356 356 width: '60%', 357 357 height: 14, ··· 362 362 <View 363 363 style={[ 364 364 a.rounded_xs, 365 - t.atoms.bg_contrast_25, 365 + t.atoms.bg_contrast_50, 366 366 { 367 367 width: '40%', 368 368 height: 10, ··· 380 380 <View 381 381 style={[ 382 382 a.rounded_xs, 383 - t.atoms.bg_contrast_25, 383 + t.atoms.bg_contrast_50, 384 384 { 385 385 width: '60%', 386 386 height: 14, ··· 442 442 style={[ 443 443 a.rounded_xs, 444 444 a.w_full, 445 - t.atoms.bg_contrast_25, 445 + t.atoms.bg_contrast_50, 446 446 {height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'}, 447 447 ]} 448 448 /> ··· 637 637 <View 638 638 style={[ 639 639 a.rounded_sm, 640 - t.atoms.bg_contrast_25, 640 + t.atoms.bg_contrast_50, 641 641 a.w_full, 642 642 { 643 643 height: 33,
+22 -5
src/components/Prompt.tsx
··· 4 4 import {useLingui} from '@lingui/react' 5 5 6 6 import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 7 - import {atoms as a, useTheme, type ViewStyleProp, web} from '#/alf' 7 + import {atoms as a, type TextStyleProp, useTheme, web} from '#/alf' 8 8 import { 9 9 Button, 10 10 type ButtonColor, ··· 35 35 control, 36 36 testID, 37 37 nativeOptions, 38 + webOptions, 39 + onClose, 38 40 }: React.PropsWithChildren<{ 39 41 control: Dialog.DialogControlProps 40 42 testID?: string ··· 42 44 * Native-specific options for the prompt. Extends `BottomSheetViewProps` 43 45 */ 44 46 nativeOptions?: Omit<BottomSheetViewProps, 'children'> 47 + /** 48 + * Web-specific options for the prompt 49 + */ 50 + webOptions?: { 51 + onBackgroundPress?: (e: GestureResponderEvent) => void 52 + } 53 + onClose?: () => void 45 54 }>) { 46 55 const titleId = useId() 47 56 const descriptionId = useId() ··· 57 66 <Dialog.Outer 58 67 control={control} 59 68 testID={testID} 60 - webOptions={{alignCenter: true}} 69 + onClose={onClose} 70 + webOptions={{alignCenter: true, ...webOptions}} 61 71 nativeOptions={{preventExpansion: true, ...nativeOptions}}> 62 72 <Dialog.Handle /> 63 73 <Context.Provider value={context}> ··· 75 85 export function TitleText({ 76 86 children, 77 87 style, 78 - }: React.PropsWithChildren<ViewStyleProp>) { 88 + }: React.PropsWithChildren<TextStyleProp>) { 79 89 const {titleId} = useContext(Context) 80 90 return ( 81 91 <Text ··· 96 106 export function DescriptionText({ 97 107 children, 98 108 selectable, 99 - }: React.PropsWithChildren<{selectable?: boolean}>) { 109 + style, 110 + }: React.PropsWithChildren<{selectable?: boolean} & TextStyleProp>) { 100 111 const t = useTheme() 101 112 const {descriptionId} = useContext(Context) 102 113 return ( 103 114 <Text 104 115 nativeID={descriptionId} 105 116 selectable={selectable} 106 - style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high, a.pb_lg]}> 117 + style={[ 118 + a.text_md, 119 + a.leading_snug, 120 + t.atoms.text_contrast_high, 121 + a.pb_lg, 122 + style, 123 + ]}> 107 124 {children} 108 125 </Text> 109 126 )
+1 -1
src/components/dialogs/SearchablePeopleList.tsx
··· 519 519 ]}> 520 520 <ProfileCard.Header> 521 521 {convo.kind === 'group' ? ( 522 - <AvatarBubbles profiles={convo.members} size="small" /> 522 + <AvatarBubbles profiles={convo.members} size={40} /> 523 523 ) : ( 524 524 <ProfileCard.Avatar 525 525 profile={convo.primaryMember}
+7 -1
src/components/dms/ActionsWrapper.tsx
··· 4 4 5 5 import {atoms as a} from '#/alf' 6 6 import {MessageContextMenu} from '#/components/dms/MessageContextMenu' 7 + import type * as bsky from '#/types/bsky' 7 8 8 9 export function ActionsWrapper({ 9 10 message, 10 11 isFromSelf, 12 + senderProfile, 11 13 children, 12 14 onTap, 13 15 }: { 14 16 message: ChatBskyConvoDefs.MessageView 15 17 hasReactions?: boolean 16 18 isFromSelf: boolean 19 + senderProfile?: bsky.profile.AnyProfileView 17 20 children: React.ReactNode 18 21 onTap?: () => void 19 22 }) { 20 23 const {t: l} = useLingui() 21 24 22 25 return ( 23 - <MessageContextMenu message={message} onTap={onTap}> 26 + <MessageContextMenu 27 + message={message} 28 + senderProfile={senderProfile} 29 + onTap={onTap}> 24 30 {trigger => 25 31 // will always be true, since this file is platform split 26 32 trigger.IS_NATIVE && (
+4 -1
src/components/dms/ActionsWrapper.web.tsx
··· 11 11 import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' 12 12 import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' 13 13 import * as Toast from '#/components/Toast' 14 + import type * as bsky from '#/types/bsky' 14 15 import {EmojiReactionPicker} from './EmojiReactionPicker' 15 16 import {hasReachedReactionLimit} from './util' 16 17 ··· 18 19 message, 19 20 hasReactions, 20 21 isFromSelf, 22 + senderProfile, 21 23 children, 22 24 onTap, 23 25 }: { 24 26 message: ChatBskyConvoDefs.MessageView 25 27 hasReactions?: boolean 26 28 isFromSelf: boolean 29 + senderProfile?: bsky.profile.AnyProfileView 27 30 children: React.ReactNode 28 31 onTap?: () => void 29 32 }) { ··· 117 120 ) 118 121 }} 119 122 </EmojiReactionPicker> 120 - <MessageContextMenu message={message}> 123 + <MessageContextMenu message={message} senderProfile={senderProfile}> 121 124 {({props, state, IS_NATIVE, control}) => { 122 125 // always false, file is platform split 123 126 if (IS_NATIVE) return null
+93 -34
src/components/dms/AddMembersFlow.tsx
··· 11 11 12 12 import {useModerationOpts} from '#/state/preferences/moderation-opts' 13 13 import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 14 + import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members' 14 15 import {useProfileFollowsQuery} from '#/state/queries/profile-follows' 15 16 import {useSession} from '#/state/session' 16 17 import {type ListMethods} from '#/view/com/util/List' 17 18 import {android, atoms as a, native, useTheme, web} from '#/alf' 18 19 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19 20 import * as Dialog from '#/components/Dialog' 20 - import {canBeMessaged} from '#/components/dms/util' 21 + import {canBeMessaged, type ConvoWithDetails} from '#/components/dms/util' 21 22 import * as Toggle from '#/components/forms/Toggle' 22 23 import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' 23 24 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 25 + import {Loader} from '#/components/Loader' 24 26 import {Text} from '#/components/Typography' 25 27 import {IS_NATIVE, IS_WEB} from '#/env' 26 28 import type * as bsky from '#/types/bsky' ··· 54 56 key: string 55 57 } 56 58 57 - type ErrorItem = { 58 - type: 'error' 59 + type LoadingItem = { 60 + type: 'loading' 59 61 key: string 60 62 } 61 63 62 - type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | ErrorItem 64 + type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | LoadingItem 63 65 64 66 export type State = { 65 67 groupChatDids: string[] ··· 98 100 } 99 101 100 102 export function AddMembersFlow({ 103 + convo, 101 104 title, 102 105 onAddMembers, 103 106 }: { 107 + convo: Extract<ConvoWithDetails, {kind: 'group'}> 104 108 title: string 105 - onAddMembers: (dids: string[]) => void 109 + onAddMembers: ( 110 + dids: string[], 111 + profiles: bsky.profile.AnyProfileView[], 112 + ) => void 106 113 }) { 107 114 const t = useTheme() 108 115 const {t: l} = useLingui() 109 116 const moderationOpts = useModerationOpts() 117 + const {currentAccount} = useSession() 118 + 110 119 const control = Dialog.useDialogContext() 120 + 111 121 const [headerHeight, setHeaderHeight] = useState(0) 112 122 const [footerHeight, setFooterHeight] = useState(0) 123 + const [searchText, setSearchText] = useState('') 124 + 113 125 const listRef = useRef<ListMethods>(null) 114 - const {currentAccount} = useSession() 115 126 const inputRef = useRef<TextInput>(null) 116 127 117 - const [searchText, setSearchText] = useState('') 118 - 119 128 const { 120 - data: results, 129 + data: autocompleteResults, 121 130 isError, 122 - isFetching, 131 + isFetching: isAutocompleteFetching, 123 132 } = useActorAutocompleteQuery(searchText, true, 12) 124 133 const {data: follows} = useProfileFollowsQuery(currentAccount?.did) 134 + const {data: memberListData = [], isPending: isMemberListPending} = 135 + useListConvoMembersQuery({ 136 + convoId: convo.view.id, 137 + placeholderData: convo.members, 138 + }) 139 + const memberDidSet = useMemo( 140 + () => new Set(memberListData.map(profile => profile.did)), 141 + [memberListData], 142 + ) 125 143 126 144 const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, { 127 145 groupChatDids: [], ··· 142 160 [groupChatDids, groupChatProfiles], 143 161 ) 144 162 145 - const items = useMemo(() => { 146 - let _items: Item[] = [] 163 + const items = useMemo<Item[]>(() => { 164 + if (isMemberListPending) { 165 + // Still fetching chat member DIDs for filtering, so force the loading state. 166 + return [] 167 + } 168 + 169 + const _items: Item[] = [] 147 170 148 171 if (isError) { 149 172 _items.push({ ··· 152 175 message: l`We’re having network issues, try again`, 153 176 }) 154 177 } else if (searchText.length) { 155 - if (results?.length) { 156 - for (const profile of results) { 157 - if (profile.did === currentAccount?.did) continue 178 + if (autocompleteResults?.length) { 179 + for (const profile of autocompleteResults) { 180 + if ( 181 + profile.did === currentAccount?.did || 182 + memberDidSet.has(profile.did) 183 + ) 184 + continue 158 185 _items.push({ 159 186 type: 'profile', 160 187 key: profile.did, ··· 162 189 }) 163 190 } 164 191 165 - _items = _items.sort(item => { 192 + _items.sort(item => { 166 193 return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 167 194 }) 168 195 } 169 196 } else { 170 - const placeholders: Item[] = Array(10) 171 - .fill(0) 172 - .map((__, i) => ({ 173 - type: 'placeholder', 174 - key: i + '', 175 - })) 176 - 177 197 if (follows) { 178 198 for (const page of follows.pages) { 179 199 for (const profile of page.follows) { ··· 185 205 } 186 206 } 187 207 188 - _items = _items.sort(item => { 208 + _items.sort(item => { 189 209 return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 190 210 }) 191 211 } else { 192 - _items.push(...placeholders) 212 + for (let i = 0; i < 10; i++) { 213 + _items.push({type: 'placeholder', key: i + ''}) 214 + } 193 215 } 194 216 } 195 217 ··· 201 223 }) 202 224 } 203 225 204 - return _items 205 - }, [isError, searchText, l, results, currentAccount?.did, follows]) 226 + if (searchText && isAutocompleteFetching && _items.length > 0) { 227 + // Stale results are still showing while autocomplete refetches - 228 + // append an inline indicator so the user sees that work is happening. 229 + _items.push({type: 'loading', key: 'loading'}) 230 + } else if ( 231 + searchText && 232 + !isAutocompleteFetching && 233 + !_items.length && 234 + !isError 235 + ) { 236 + _items.push({type: 'empty', key: 'empty', message: l`No results`}) 237 + } 206 238 207 - if (searchText && !isFetching && !items.length && !isError) { 208 - items.push({type: 'empty', key: 'empty', message: l`No results`}) 209 - } 239 + return _items 240 + }, [ 241 + autocompleteResults, 242 + currentAccount?.did, 243 + follows, 244 + isAutocompleteFetching, 245 + isError, 246 + isMemberListPending, 247 + l, 248 + memberDidSet, 249 + searchText, 250 + ]) 210 251 211 252 const handlePressBack = useCallback(() => { 212 253 control.close() 213 254 }, [control]) 214 255 215 256 const handlePressAdd = useCallback(() => { 216 - onAddMembers(groupChatDids) 217 - }, [groupChatDids, onAddMembers]) 257 + onAddMembers(groupChatDids, groupChatProfiles) 258 + }, [groupChatDids, groupChatProfiles, onAddMembers]) 218 259 219 260 const renderItems = useCallback( 220 261 ({item}: {item: Item}) => { ··· 233 274 } 234 275 case 'placeholder': { 235 276 return <ProfileCardSkeleton key={item.key} /> 277 + } 278 + case 'loading': { 279 + return ( 280 + <View style={[a.px_lg, a.py_xl, a.align_center]}> 281 + <Loader size="lg" /> 282 + </View> 283 + ) 236 284 } 237 285 case 'empty': { 238 286 return <EmptyMemberList key={item.key} message={item.message} /> ··· 427 475 renderItem={renderItems} 428 476 ListHeaderComponent={listHeader} 429 477 stickyHeaderIndices={[0]} 478 + ListEmptyComponent={ 479 + isMemberListPending || isAutocompleteFetching ? ( 480 + <View style={[a.flex_1, a.align_center, a.justify_center]}> 481 + <Loader size="xl" /> 482 + </View> 483 + ) : null 484 + } 430 485 keyExtractor={(item: Item) => item.key} 431 486 style={[ 432 487 web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), 433 488 native({height: '100%'}), 434 489 ]} 435 - webInnerContentContainerStyle={[a.py_0, {paddingBottom: footerHeight}]} 490 + contentContainerStyle={items.length === 0 ? {flexGrow: 1} : undefined} 491 + webInnerContentContainerStyle={[ 492 + a.py_0, 493 + {paddingBottom: footerHeight}, 494 + items.length === 0 && {flexGrow: 1}, 495 + ]} 436 496 webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} 437 497 scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}} 438 498 keyboardDismissMode="on-drag" ··· 448 508 onPress={handlePressBack}> 449 509 <ButtonIcon icon={ArrowLeftIcon} size="md" /> 450 510 <ButtonText> 451 - {' '} 452 511 <Trans>Back</Trans> 453 512 </ButtonText> 454 513 </Button>
+1 -1
src/components/dms/DateDivider.tsx
··· 4 4 import {subDays} from 'date-fns' 5 5 6 6 import {atoms as a, useTheme} from '#/alf' 7 - import {Text} from '../Typography' 7 + import {Text} from '#/components/Typography' 8 8 import {localDateString} from './util' 9 9 10 10 const timeFormatter = new Intl.DateTimeFormat(undefined, {
+6 -5
src/components/dms/MessageContextMenu.tsx
··· 25 25 import * as Toast from '#/components/Toast' 26 26 import {useAnalytics} from '#/analytics' 27 27 import {IS_NATIVE} from '#/env' 28 + import type * as bsky from '#/types/bsky' 28 29 import {EmojiReactionPicker} from './EmojiReactionPicker' 29 30 import {hasReachedReactionLimit} from './util' 30 31 31 32 export let MessageContextMenu = ({ 32 33 message, 34 + senderProfile, 33 35 children, 34 36 onTap, 35 37 }: { 36 38 message: ChatBskyConvoDefs.MessageView 39 + senderProfile?: bsky.profile.AnyProfileView 37 40 children: TriggerProps['children'] 38 41 onTap?: () => void 39 42 }): React.ReactNode => { ··· 110 113 [l, convo, message, currentAccount?.did], 111 114 ) 112 115 113 - const sender = convo.convo.members.find( 114 - member => member.did === message.sender.did, 115 - ) 116 + const sender = senderProfile 116 117 117 118 return ( 118 119 <> ··· 183 184 control={reportControl} 184 185 subject={{ 185 186 view: 'message', 186 - convoId: convo.convo.id, 187 + convoId: convo.convo.view.id, 187 188 message, 188 189 }} 189 190 onAfterSubmit={() => { ··· 197 198 control={blockOrDeleteControl} 198 199 currentScreen="conversation" 199 200 params={{ 200 - convoId: convo.convo.id, 201 + convoId: convo.convo.view.id, 201 202 message, 202 203 }} 203 204 />
+19 -15
src/components/dms/MessageItem.tsx
··· 30 30 31 31 import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' 32 32 import {makeProfileLink} from '#/lib/routes/links' 33 - import {useConvoActive} from '#/state/messages/convo' 34 33 import {type ConvoItem} from '#/state/messages/convo/types' 35 34 import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 36 35 import {useModerationOpts} from '#/state/preferences/moderation-opts' ··· 44 43 import * as ProfileCard from '#/components/ProfileCard' 45 44 import {RichText} from '#/components/RichText' 46 45 import {Text} from '#/components/Typography' 47 - import type * as bsky from '#/types/bsky' 48 46 import {DateDivider} from './DateDivider' 49 47 import {useDateDividerToggle} from './DateDividerToggle' 50 48 import {MessageItemEmbed} from './MessageItemEmbed' ··· 93 91 let MessageItem = ({ 94 92 item, 95 93 isGroupChat = false, 96 - profile, 97 94 }: { 98 95 item: ConvoItem & {type: 'message' | 'pending-message'} 99 96 isGroupChat?: boolean 100 - profile?: bsky.profile.AnyProfileView 101 97 }): React.ReactNode => { 102 98 const enableSquareButtons = useEnableSquareButtons() 103 99 const t = useTheme() 104 100 const {currentAccount} = useSession() 105 101 const {t: l} = useLingui() 106 - const {convo} = useConvoActive() 107 102 const moderationOpts = useModerationOpts() 108 103 const queryClient = useQueryClient() 104 + 105 + const profile = item.relatedProfiles.get(item.message.sender.did) 109 106 110 107 const reactionsControl = useDialogControl() 111 108 const reactionTapRef = useRef(false) ··· 279 276 return l`You reacted ${reaction.value}` 280 277 } else { 281 278 const senderDid = reaction.sender.did 282 - const memberSender = convo.members.find( 283 - member => member.did === senderDid, 284 - ) 279 + const memberSender = item.relatedProfiles.get(senderDid) 285 280 if (memberSender) { 286 281 return l`${createSanitizedDisplayName(memberSender)} reacted ${reaction.value}` 287 282 } ··· 292 287 one: '# person', 293 288 other: '# people', 294 289 })} reacted – ${groupedReactions.map(g => g.value).join(' ')}` 295 - }, [reactions, groupedReactions, currentAccount?.did, convo.members, l]) 290 + }, [ 291 + reactions, 292 + groupedReactions, 293 + currentAccount?.did, 294 + item.relatedProfiles, 295 + l, 296 + ]) 296 297 297 298 const appliedReactions = ( 298 299 <LayoutAnimationConfig skipEntering skipExiting> ··· 377 378 ) : null} 378 379 <ReactionsDialog 379 380 control={reactionsControl} 380 - members={convo.members} 381 + relatedProfiles={item.relatedProfiles} 381 382 message={message} 382 383 reactions={message.reactions} 383 384 groupedReactions={groupedReactions} ··· 393 394 394 395 return ( 395 396 <> 396 - {(hasLargeGapFromPrev || isDateDividerToggled) && ( 397 - <Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}> 398 - <DateDivider date={message.sentAt} /> 399 - </Animated.View> 400 - )} 397 + <LayoutAnimationConfig skipExiting skipEntering> 398 + {(hasLargeGapFromPrev || isDateDividerToggled) && ( 399 + <Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}> 400 + <DateDivider date={message.sentAt} /> 401 + </Animated.View> 402 + )} 403 + </LayoutAnimationConfig> 401 404 <View style={[messageInset, effectiveFirstInCluster && a.mt_md]}> 402 405 <View style={[a.relative]}> 403 406 {showAvatar ? ( ··· 436 439 hasReactions={hasReactions} 437 440 isFromSelf={isFromSelf} 438 441 message={message} 442 + senderProfile={profile} 439 443 onTap={() => { 440 444 if (reactionTapRef.current) return 441 445 if (!hasLargeGapFromPrev) {
+4 -1
src/components/dms/MessagesListHeader.tsx
··· 161 161 }) 162 162 } 163 163 164 + const lockStatus = convo.details.lockStatus 165 + 164 166 return ( 165 167 <Wrapper 166 168 heading={ 167 169 <> 168 - <AvatarBubbles size="small" profiles={convo.members} /> 170 + <AvatarBubbles size={40} profiles={convo.members} /> 169 171 <Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}> 170 172 {convo.details.name} 171 173 </Text> ··· 175 177 settings={ 176 178 <Button 177 179 label={l`Open group chat settings`} 180 + disabled={lockStatus === 'locked-permanently'} 178 181 size="small" 179 182 color="secondary" 180 183 shape="round"
+4 -4
src/components/dms/ReactionsDialog.tsx
··· 7 7 View, 8 8 } from 'react-native' 9 9 import Animated from 'react-native-reanimated' 10 - import {type ChatBskyConvoDefs} from '@atproto/api' 10 + import {type ChatBskyActorDefs, type ChatBskyConvoDefs} from '@atproto/api' 11 11 import {Trans, useLingui} from '@lingui/react/macro' 12 12 13 13 import {HITSLOP_10} from '#/lib/constants' ··· 33 33 34 34 export function ReactionsDialog({ 35 35 control, 36 - members, 36 + relatedProfiles, 37 37 message, 38 38 reactions, 39 39 groupedReactions, 40 40 }: { 41 41 control: Dialog.DialogControlProps 42 - members: bsky.profile.AnyProfileView[] 42 + relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> 43 43 message: ChatBskyConvoDefs.MessageView 44 44 reactions?: ChatBskyConvoDefs.ReactionView[] 45 45 groupedReactions?: Reaction[] ··· 100 100 return 0 101 101 }) 102 102 .map(reaction => { 103 - const sender = members.find(m => m.did === reaction.sender.did) 103 + const sender = relatedProfiles.get(reaction.sender.did) 104 104 if (!sender) return null 105 105 return ( 106 106 <ReactionRow
+3 -3
src/components/dms/getSystemMessageInfo.ts
··· 23 23 24 24 function getReferredDisplayName( 25 25 user: ChatBskyConvoDefs.SystemMessageReferredUser, 26 - relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[], 26 + relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>, 27 27 ): string | null { 28 - const profile = relatedProfiles.find(p => p.did === user.did) 28 + const profile = relatedProfiles.get(user.did) 29 29 return profile ? createSanitizedDisplayName(profile) : null 30 30 } 31 31 32 32 export function getSystemMessageInfo( 33 33 data: ChatBskyConvoDefs.SystemMessageView['data'], 34 - relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[], 34 + relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>, 35 35 ): SystemMessageInfo | null { 36 36 if (ChatBskyConvoDefs.isSystemMessageDataAddMember(data)) { 37 37 const name = getReferredDisplayName(data.member, relatedProfiles)
+2 -2
src/components/dms/util.ts
··· 68 68 export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & ( 69 69 | { 70 70 kind: 'group' 71 - details: ChatBskyConvoDefs.GroupConvo 71 + details: $Typed<ChatBskyConvoDefs.GroupConvo> 72 72 primaryMember: GroupConvoMember // the owner 73 73 members: Array<GroupConvoMember> 74 74 } 75 75 | { 76 76 kind: 'direct' 77 - details: ChatBskyConvoDefs.DirectConvo 77 + details: $Typed<ChatBskyConvoDefs.DirectConvo> 78 78 primaryMember: DirectConvoMember // the other user 79 79 members: Array<DirectConvoMember> 80 80 }
+11 -1
src/components/forms/Toggle/index.tsx
··· 82 82 isInvalid?: boolean 83 83 children: ((props: ItemState) => React.ReactNode) | React.ReactNode 84 84 hitSlop?: PressableProps['hitSlop'] 85 + highlightRow?: boolean 85 86 } 86 87 87 88 export function useItemContext() { ··· 161 162 style, 162 163 type = 'checkbox', 163 164 label, 165 + highlightRow, 164 166 ...rest 165 167 }: ItemProps) { 168 + const t = useTheme() 169 + 166 170 const { 167 171 values: selectedValues, 168 172 type: groupType, ··· 208 212 [name, selected, disabled, hovered, pressed, focused, isInvalid], 209 213 ) 210 214 215 + const highlightStyle = highlightRow 216 + ? selected 217 + ? [a.rounded_full, a.p_md, {backgroundColor: t.palette.primary_50}] 218 + : [a.rounded_full, a.p_md] 219 + : null 220 + 211 221 return ( 212 222 <ItemContext.Provider value={state}> 213 223 <Pressable ··· 233 243 onPressOut={onPressOut} 234 244 onFocus={onFocus} 235 245 onBlur={onBlur} 236 - style={[a.flex_row, a.align_center, a.gap_sm, style]}> 246 + style={[a.flex_row, a.align_center, a.gap_sm, highlightStyle, style]}> 237 247 {typeof children === 'function' ? children(state) : children} 238 248 </Pressable> 239 249 </ItemContext.Provider>
+7
src/components/icons/EditBig.tsx
··· 5 5 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', 6 6 }) 7 7 8 + /** 9 + * @deprecated Use EditBig_Stroke2_Corner2_Rounded 10 + */ 8 11 export const EditBig_Stroke2_Corner0_Rounded = createSinglePathSVG({ 9 12 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', 10 13 }) 14 + 15 + export const EditBig_Stroke2_Corner2_Rounded = createSinglePathSVG({ 16 + path: 'M3 16.8V7.2c0-.544-.001-1.011.03-1.395.033-.395.104-.789.297-1.167a3 3 0 0 1 1.31-1.31c.379-.193.772-.265 1.168-.297C6.188 2.999 6.657 3 7.2 3H11a1 1 0 1 1 0 2H7.2c-.576 0-.949 0-1.232.023-.272.022-.373.06-.422.085a1 1 0 0 0-.437.437c-.025.05-.062.15-.085.422C5.001 6.251 5 6.623 5 7.2v9.6c0 .577.001.95.024 1.232.023.272.06.373.085.422a1 1 0 0 0 .437.437c.05.025.15.063.422.085.283.023.656.024 1.232.024h9.6c.576 0 .949-.001 1.232-.024.272-.022.373-.06.422-.085a1 1 0 0 0 .437-.437c.025-.049.062-.15.085-.422.023-.283.024-.655.024-1.232V13a1 1 0 1 1 2 0v3.8c0 .543.001 1.011-.03 1.395-.033.395-.104.788-.297 1.167a3 3 0 0 1-1.31 1.311c-.379.193-.772.264-1.168.296-.383.031-.852.031-1.395.031H7.2c-.543 0-1.012 0-1.395-.031-.396-.032-.789-.103-1.167-.296a3 3 0 0 1-1.31-1.311c-.194-.379-.265-.772-.298-1.167C3 17.81 3 17.343 3 16.8M16.629 2.957a3 3 0 0 1 4.242 0l.172.171a3 3 0 0 1 0 4.243L13 15.414a2 2 0 0 1-1.414.586H9a1 1 0 0 1-1-1v-2.586A2 2 0 0 1 8.586 11zM10 14h1.586l8.043-8.043a1 1 0 0 0 0-1.414l-.172-.172a1 1 0 0 0-1.414 0L10 12.414z', 17 + })
+1 -1
src/components/icons/Lock.tsx
··· 9 9 }) 10 10 11 11 export const Unlock_Stroke2_Corner2_Rounded = createSinglePathSVG({ 12 - path: 'M12 13a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z"/><path fill="#000" fill-rule="evenodd" d="M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5Zm-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7Z', 12 + path: 'M12 2a5 5 0 0 1 4.843 3.751 1 1 0 0 1-1.938.498A3.002 3.002 0 0 0 9 7v2h8a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-7a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m-5 9a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1zm5 2a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1', 13 13 })
+7
src/components/icons/SquareBehindSquare4.tsx
··· 1 1 import {createSinglePathSVG} from './TEMPLATE' 2 2 3 + /** 4 + * @deprecated Use SquareBehindSquare_Stroke2_Corner2_Rounded 5 + */ 3 6 export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 7 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', 5 8 }) 9 + 10 + export const SquareBehindSquare_Stroke2_Corner2_Rounded = createSinglePathSVG({ 11 + 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', 12 + })
+18 -5
src/lib/hooks/useNotificationHandler.ts
··· 29 29 | 'reply' 30 30 | 'quote' 31 31 | 'chat-message' 32 + | 'chat-reaction' 32 33 | 'starterpack-joined' 33 34 | 'like-via-repost' 34 35 | 'repost-via-repost' ··· 44 45 export type NotificationPayload = 45 46 | undefined 46 47 | { 47 - reason: Exclude<NotificationReason, 'chat-message'> 48 + reason: Exclude<NotificationReason, 'chat-message' | 'chat-reaction'> 48 49 uri: string 49 50 subject: string 50 51 recipientDid: string 51 52 } 52 53 | { 53 54 reason: 'chat-message' 55 + convoId: string 56 + messageId: string 57 + recipientDid: string 58 + } 59 + | { 60 + reason: 'chat-reaction' 54 61 convoId: string 55 62 messageId: string 56 63 recipientDid: string ··· 192 199 const handleNotification = (payload?: NotificationPayload) => { 193 200 if (!payload) return 194 201 195 - if (payload.reason === 'chat-message') { 196 - logger.debug(`useNotificationsHandler: handling chat message`, { 202 + if ( 203 + payload.reason === 'chat-message' || 204 + payload.reason === 'chat-reaction' 205 + ) { 206 + logger.debug(`useNotificationsHandler: handling chat notification`, { 197 207 payload, 198 208 }) 199 209 ··· 270 280 logger.debug('useNotificationsHandler: incoming', {e, payload}) 271 281 272 282 if ( 273 - payload.reason === 'chat-message' && 283 + (payload.reason === 'chat-message' || 284 + payload.reason === 'chat-reaction') && 274 285 payload.recipientDid === currentAccount?.did 275 286 ) { 276 287 const shouldAlert = payload.convoId !== currentConvoId ··· 341 352 // Whenever there's a stored payload, that means we had to switch accounts before handling the notification. 342 353 // Whenever currentAccount changes, we should try to handle it again. 343 354 if ( 344 - storedAccountSwitchPayload?.reason === 'chat-message' && 355 + (storedAccountSwitchPayload?.reason === 'chat-message' || 356 + storedAccountSwitchPayload?.reason === 'chat-reaction') && 345 357 currentAccount?.did === storedAccountSwitchPayload.recipientDid 346 358 ) { 347 359 handleNotification(storedAccountSwitchPayload) ··· 429 441 return `/profile/${urip.host}` 430 442 } 431 443 case 'chat-message': 444 + case 'chat-reaction': 432 445 // should be handled separately 433 446 return null 434 447 case 'verified':
+338 -177
src/locale/locales/en/messages.po
··· 13 13 "Language-Team: \n" 14 14 "Plural-Forms: \n" 15 15 16 - #: src/screens/Messages/ConversationSettings.tsx:966 17 - msgid "…" 18 - msgstr "…" 19 - 20 16 #. Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is currently selected. 21 17 #: src/components/InterestTabs.tsx:330 22 18 msgid "\"{interestsDisplayName}\" category (active)" ··· 87 83 88 84 #. placeholder {0}: reactions.length 89 85 #. placeholder {1}: groupedReactions.map(g => g.value).join(' ') 90 - #: src/components/dms/MessageItem.tsx:289 86 + #: src/components/dms/MessageItem.tsx:284 91 87 msgid "{0, plural, one {# person} other {# people}} reacted – {1}" 92 88 msgstr "{0, plural, one {# person} other {# people}} reacted – {1}" 93 89 ··· 227 223 228 224 #. placeholder {0}: createSanitizedDisplayName(memberSender) 229 225 #. placeholder {1}: reaction.value 230 - #: src/components/dms/MessageItem.tsx:284 226 + #: src/components/dms/MessageItem.tsx:279 231 227 msgid "{0} reacted {1}" 232 228 msgstr "" 233 229 ··· 252 248 msgstr "" 253 249 254 250 #. placeholder {0}: createSanitizedDisplayName(profile) 255 - #: src/components/dms/MessageItem.tsx:222 251 + #: src/components/dms/MessageItem.tsx:219 256 252 msgid "{0}’s avatar" 257 253 msgstr "{0}’s avatar" 258 254 ··· 497 493 msgstr "" 498 494 499 495 #. The number of group chat members out of the total number of permitted users. 500 - #: src/screens/Messages/ConversationSettings.tsx:264 496 + #: src/screens/Messages/ConversationSettings/MembersAndRequests.tsx:34 501 497 msgid "{memberCount}/{MEMBER_LIMIT}" 502 498 msgstr "{memberCount}/{MEMBER_LIMIT}" 503 499 ··· 565 561 msgstr "" 566 562 567 563 #. The number of requests to join a group chat. 568 - #: src/screens/Messages/ConversationSettings.tsx:282 564 + #: src/screens/Messages/ConversationSettings/MembersAndRequests.tsx:54 569 565 msgid "{requestCount, plural, one {# request} other {# requests}}" 570 566 msgstr "{requestCount, plural, one {# request} other {# requests}}" 571 567 572 568 #. Displayed when there are more than 50 requests to join a group chat 573 - #: src/screens/Messages/ConversationSettings.tsx:277 569 + #: src/screens/Messages/ConversationSettings/MembersAndRequests.tsx:49 574 570 msgid "{requestCount}+ requests" 575 571 msgstr "{requestCount}+ requests" 576 572 ··· 801 797 802 798 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:426 803 799 #: src/screens/Messages/components/RequestButtons.tsx:101 804 - #: src/screens/Messages/ConversationSettings.tsx:601 800 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:116 805 801 #: src/view/com/profile/ProfileMenu.tsx:188 806 802 msgctxt "toast" 807 803 msgid "Account blocked" ··· 847 843 msgid "Account removed from quick access" 848 844 msgstr "" 849 845 850 - #: src/screens/Messages/ConversationSettings.tsx:588 846 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:103 851 847 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:88 852 848 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:310 853 849 #: src/view/com/profile/ProfileMenu.tsx:176 ··· 870 866 msgid "Accounts with a scalloped blue check mark <0><1/></0> can verify others. These trusted verifiers are selected by Bluesky." 871 867 msgstr "" 872 868 873 - #: src/lib/hooks/useNotificationHandler.ts:185 869 + #: src/lib/hooks/useNotificationHandler.ts:192 874 870 #: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:105 875 871 #: src/screens/Settings/NotificationSettings/index.tsx:193 876 872 msgid "Activity from others" ··· 884 880 #: src/components/dialogs/MutedWords.tsx:337 885 881 #: src/components/dialogs/StarterPackDialog.tsx:376 886 882 #: src/components/dialogs/StarterPackDialog.tsx:388 887 - #: src/components/dms/AddMembersFlow.tsx:341 883 + #: src/components/dms/AddMembersFlow.tsx:389 888 884 #: src/view/com/modals/UserAddRemoveLists.tsx:236 889 885 msgid "Add" 890 886 msgstr "" ··· 964 960 msgid "Add emoji reaction" 965 961 msgstr "" 966 962 967 - #: src/components/dms/AddMembersFlow.tsx:422 963 + #: src/components/dms/AddMembersFlow.tsx:470 968 964 msgid "Add group chat members" 969 965 msgstr "Add group chat members" 970 966 ··· 977 973 msgid "Add media to post" 978 974 msgstr "" 979 975 980 - #: src/screens/Messages/ConversationSettings.tsx:356 981 - #: src/screens/Messages/ConversationSettings.tsx:373 976 + #: src/screens/Messages/ConversationSettings/AddMembersLink.tsx:45 977 + #: src/screens/Messages/ConversationSettings/AddMembersLink.tsx:79 978 + #: src/screens/Messages/ConversationSettings/AddMembersLink.tsx:98 982 979 msgid "Add members" 983 980 msgstr "Add members" 984 981 ··· 1000 997 msgid "Add muted words and tags" 1001 998 msgstr "" 1002 999 1003 - #: src/screens/Messages/components/MessagesListInfoPanel.tsx:101 1004 - #: src/screens/Messages/components/MessagesListInfoPanel.tsx:126 1000 + #: src/screens/Messages/components/MessagesListInfoPanel.tsx:119 1001 + #: src/screens/Messages/components/MessagesListInfoPanel.tsx:154 1005 1002 #: src/screens/ProfileList/AboutSection.tsx:72 1006 1003 #: src/screens/ProfileList/AboutSection.tsx:90 1007 1004 msgid "Add people" ··· 1058 1055 msgid "Add your birthdate" 1059 1056 msgstr "" 1060 1057 1058 + #. placeholder {0}: createSanitizedDisplayName( profile.kind.addedBy, true, moderateProfile(profile.kind.addedBy, moderationOpts).ui('displayName'), ) 1059 + #: src/screens/Messages/ConversationSettings/Member.tsx:70 1060 + msgid "Added by {0}" 1061 + msgstr "Added by {0}" 1062 + 1061 1063 #: src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx:113 1062 1064 #: src/view/com/modals/UserAddRemoveLists.tsx:163 1063 1065 msgid "Added to list" ··· 1080 1082 msgid "Additional details (limit 300 characters)" 1081 1083 msgstr "" 1082 1084 1083 - #: src/screens/Messages/ConversationSettings.tsx:419 1084 - #: src/screens/Messages/ConversationSettings.tsx:639 1085 + #: src/screens/Messages/ConversationSettings/Member.tsx:55 1086 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:151 1085 1087 msgid "Admin" 1086 1088 msgstr "Admin" 1087 1089 ··· 1360 1362 msgid "An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts." 1361 1363 msgstr "" 1362 1364 1363 - #: src/screens/Messages/ConversationSettings.tsx:1112 1364 - msgid "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." 1365 - msgstr "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." 1365 + #: src/screens/Messages/components/InviteLinkDialog.tsx:158 1366 + msgid "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." 1367 + msgstr "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." 1366 1368 1367 1369 #: src/components/moderation/ReportDialog/utils/useReportOptions.ts:239 1368 1370 msgid "An issue not included in these options" ··· 1438 1440 msgid "Anyone can interact" 1439 1441 msgstr "" 1440 1442 1443 + #: src/screens/Messages/components/InviteLinkDialog.tsx:129 1444 + #: src/screens/Messages/components/InviteLinkDialog.tsx:130 1445 + msgid "Anyone can join instantly" 1446 + msgstr "Anyone can join instantly" 1447 + 1448 + #: src/screens/Messages/components/InviteLinkDialog.tsx:134 1449 + #: src/screens/Messages/components/InviteLinkDialog.tsx:135 1450 + msgid "Anyone can request to join" 1451 + msgstr "Anyone can request to join" 1452 + 1441 1453 #: src/screens/Settings/ActivityPrivacySettings.tsx:112 1442 1454 #: src/screens/Settings/ActivityPrivacySettings.tsx:117 1443 1455 #: src/screens/Settings/PrivacyAndSecuritySettings.tsx:163 ··· 1560 1572 msgid "Are you sure you want to delete the app password \"{0}\"?" 1561 1573 msgstr "" 1562 1574 1563 - #: src/components/dms/MessageContextMenu.tsx:207 1575 + #: src/components/dms/MessageContextMenu.tsx:208 1564 1576 msgid "Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants." 1565 1577 msgstr "Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants." 1566 1578 ··· 1573 1585 msgid "Are you sure you want to discard your changes?" 1574 1586 msgstr "" 1575 1587 1576 - #: src/screens/Messages/ConversationSettings.tsx:1155 1588 + #: src/screens/Messages/ConversationSettings/prompts.tsx:90 1577 1589 msgid "Are you sure you want to leave {groupName}?" 1578 1590 msgstr "Are you sure you want to leave {groupName}?" 1579 1591 ··· 1652 1664 msgid "Available" 1653 1665 msgstr "" 1654 1666 1655 - #: src/components/dms/AddMembersFlow.tsx:290 1656 - #: src/components/dms/AddMembersFlow.tsx:445 1657 - #: src/components/dms/AddMembersFlow.tsx:452 1667 + #: src/components/dms/AddMembersFlow.tsx:338 1668 + #: src/components/dms/AddMembersFlow.tsx:505 1669 + #: src/components/dms/AddMembersFlow.tsx:511 1658 1670 #: src/components/dms/InitiateChatFlow.tsx:489 1659 1671 #: src/components/dms/InitiateChatFlow.tsx:674 1660 1672 #: src/components/dms/InitiateChatFlow.tsx:681 ··· 1720 1732 #: src/components/dms/MessageProfileButton.tsx:60 1721 1733 #: src/screens/Messages/ChatList.tsx:380 1722 1734 #: src/screens/Messages/Conversation.tsx:235 1723 - #: src/screens/Messages/ConversationSettings.tsx:578 1735 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:93 1724 1736 msgid "Before you can message another user, you must first verify your email." 1725 1737 msgstr "" 1726 1738 ··· 1749 1761 msgstr "" 1750 1762 1751 1763 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:853 1752 - #: src/screens/Messages/ConversationSettings.tsx:700 1753 - #: src/screens/Messages/ConversationSettings.tsx:1180 1764 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:217 1765 + #: src/screens/Messages/ConversationSettings/prompts.tsx:115 1754 1766 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:207 1755 1767 #: src/view/com/profile/ProfileMenu.tsx:563 1756 1768 msgid "Block" 1757 1769 msgstr "" 1758 1770 1759 - #: src/screens/Messages/ConversationSettings.tsx:696 1771 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:209 1760 1772 msgid "Block {displayName}" 1761 1773 msgstr "Block {displayName}" 1762 1774 ··· 1771 1783 msgid "Block account" 1772 1784 msgstr "" 1773 1785 1774 - #: src/screens/Messages/ConversationSettings.tsx:1177 1786 + #: src/screens/Messages/ConversationSettings/prompts.tsx:112 1775 1787 msgid "Block account?" 1776 1788 msgstr "Block account?" 1777 1789 ··· 1793 1805 msgid "Block list" 1794 1806 msgstr "" 1795 1807 1796 - #: src/screens/Messages/components/ChatStatusInfo.tsx:46 1808 + #: src/screens/Messages/components/ChatStatusInfo.tsx:44 1797 1809 msgid "Block or report" 1798 1810 msgstr "" 1799 1811 ··· 1828 1840 msgstr "" 1829 1841 1830 1842 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:851 1831 - #: src/screens/Messages/ConversationSettings.tsx:1178 1843 + #: src/screens/Messages/ConversationSettings/prompts.tsx:113 1832 1844 #: src/view/com/profile/ProfileMenu.tsx:558 1833 1845 msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." 1834 1846 msgstr "" ··· 2055 2067 #: src/components/dialogs/lists/CreateOrEditListDialog.tsx:299 2056 2068 #: src/components/Menu/index.tsx:355 2057 2069 #: src/components/PostControls/RepostButton.tsx:210 2058 - #: src/components/Prompt.tsx:136 2059 - #: src/components/Prompt.tsx:138 2070 + #: src/components/Prompt.tsx:153 2071 + #: src/components/Prompt.tsx:155 2060 2072 #: src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx:152 2061 2073 #: src/features/liveEvents/components/LiveEventFeedOptionsMenu.tsx:157 2062 2074 #: src/features/liveNow/components/GoLiveDialog.tsx:248 2063 2075 #: src/features/liveNow/components/GoLiveDialog.tsx:254 2064 2076 #: src/lib/media/picker.tsx:38 2065 2077 #: src/screens/Deactivated.tsx:150 2066 - #: src/screens/Messages/ConversationSettings.tsx:1114 2067 - #: src/screens/Messages/ConversationSettings.tsx:1135 2068 - #: src/screens/Messages/ConversationSettings.tsx:1159 2078 + #: src/screens/Messages/ConversationSettings/prompts.tsx:70 2079 + #: src/screens/Messages/ConversationSettings/prompts.tsx:94 2069 2080 #: src/screens/Profile/Header/EditProfileDialog.tsx:215 2070 2081 #: src/screens/Profile/Header/EditProfileDialog.tsx:223 2071 2082 #: src/screens/Search/Shell.tsx:396 ··· 2193 2204 msgid "Changes to the starter pack will not be reflected in the list after creation. The list will be an independent copy." 2194 2205 msgstr "" 2195 2206 2196 - #: src/lib/hooks/useNotificationHandler.ts:102 2207 + #: src/lib/hooks/useNotificationHandler.ts:109 2197 2208 #: src/Navigation.tsx:570 2198 2209 #: src/view/shell/bottom-bar/BottomBar.tsx:224 2199 2210 #: src/view/shell/desktop/LeftNav.tsx:611 ··· 2215 2226 msgid "Chat locked permanently" 2216 2227 msgstr "Chat locked permanently" 2217 2228 2218 - #: src/lib/hooks/useNotificationHandler.ts:117 2229 + #: src/lib/hooks/useNotificationHandler.ts:124 2219 2230 msgid "Chat messages - silent" 2220 2231 msgstr "" 2221 2232 2222 - #: src/lib/hooks/useNotificationHandler.ts:108 2233 + #: src/lib/hooks/useNotificationHandler.ts:115 2223 2234 msgid "Chat messages - sound" 2224 2235 msgstr "" 2225 2236 ··· 2326 2337 msgid "Choose this color as your avatar" 2327 2338 msgstr "" 2328 2339 2340 + #: src/screens/Messages/components/InviteLinkDialog.tsx:194 2341 + msgid "Choose who can join this group chat and how." 2342 + msgstr "Choose who can join this group chat and how." 2343 + 2329 2344 #: src/components/contacts/components/InviteInfo.tsx:57 2330 2345 msgid "Choose who to invite" 2331 2346 msgstr "" ··· 2371 2386 msgid "click here" 2372 2387 msgstr "" 2373 2388 2374 - #: src/screens/Messages/components/MessagesListInfoPanel.tsx:97 2389 + #: src/screens/Messages/components/MessagesListInfoPanel.tsx:115 2375 2390 msgid "Click here to add people to this group chat" 2376 2391 msgstr "Click here to add people to this group chat" 2377 2392 2378 2393 #: src/ageAssurance/components/NoAccessScreen.tsx:129 2379 2394 msgid "Click here to contact our support team" 2380 2395 msgstr "" 2396 + 2397 + #: src/screens/Messages/components/MessagesListInfoPanel.tsx:129 2398 + msgid "Click here to create or manage an invite link for this group chat" 2399 + msgstr "Click here to create or manage an invite link for this group chat" 2381 2400 2382 2401 #: src/ageAssurance/components/NoAccessScreen.tsx:263 2383 2402 msgid "Click here to delete your account" ··· 2405 2424 msgid "Click here to update your email" 2406 2425 msgstr "" 2407 2426 2408 - #: src/screens/Messages/components/MessagesListInfoPanel.tsx:109 2409 - msgid "Click here to view or create an invite link for this group chat" 2410 - msgstr "Click here to view or create an invite link for this group chat" 2427 + #: src/screens/Messages/components/MessagesListInfoPanel.tsx:130 2428 + msgid "Click here to view the invite link for this group chat" 2429 + msgstr "Click here to view the invite link for this group chat" 2411 2430 2412 2431 #. placeholder {0}: isCashtag ? tag : `#${tag}` 2413 2432 #: src/components/RichTextTag.tsx:56 2414 2433 msgid "Click to open tag menu for {0}" 2415 2434 msgstr "" 2416 2435 2417 - #: src/components/dms/MessageItem.tsx:553 2436 + #: src/components/dms/MessageItem.tsx:557 2418 2437 msgid "Click to retry failed message" 2419 2438 msgstr "" 2420 2439 2421 - #: src/components/dms/ActionsWrapper.web.tsx:142 2440 + #: src/components/dms/ActionsWrapper.web.tsx:145 2422 2441 msgid "Click to view the date and time" 2423 2442 msgstr "Click to view the date and time" 2424 2443 ··· 2447 2466 #: src/components/dialogs/nuxs/LiveNowBetaDialog.tsx:199 2448 2467 #: src/components/dialogs/SearchablePeopleList.tsx:339 2449 2468 #: src/components/dialogs/StarterPackDialog.tsx:187 2450 - #: src/components/dms/AddMembersFlow.tsx:315 2469 + #: src/components/dms/AddMembersFlow.tsx:363 2451 2470 #: src/components/dms/AfterReportDialog.tsx:93 2452 2471 #: src/components/dms/AfterReportDialog.tsx:98 2453 2472 #: src/components/dms/AfterReportDialog.tsx:212 ··· 2466 2485 #: src/components/WhoCanReply.tsx:243 2467 2486 #: src/features/liveNow/components/EditLiveDialog.tsx:217 2468 2487 #: src/features/liveNow/components/EditLiveDialog.tsx:223 2488 + #: src/screens/Messages/components/InviteLinkDialog.tsx:405 2489 + #: src/screens/Messages/components/InviteLinkDialog.tsx:410 2469 2490 #: src/screens/Settings/components/ChangePasswordDialog.tsx:288 2470 2491 #: src/screens/Settings/components/ChangePasswordDialog.tsx:293 2471 2492 #: src/view/com/feeds/MissingFeed.tsx:211 ··· 2614 2635 msgid "Configured in <0>moderation settings</0>." 2615 2636 msgstr "" 2616 2637 2617 - #: src/components/Prompt.tsx:195 2618 - #: src/components/Prompt.tsx:198 2638 + #: src/components/Prompt.tsx:212 2639 + #: src/components/Prompt.tsx:215 2619 2640 #: src/screens/Settings/components/DisableEmail2FADialog.tsx:186 2620 2641 #: src/screens/Settings/components/DisableEmail2FADialog.tsx:189 2621 2642 msgid "Confirm" ··· 2756 2777 msgid "Continue thread..." 2757 2778 msgstr "" 2758 2779 2759 - #: src/components/dms/AddMembersFlow.tsx:255 2780 + #: src/components/dms/AddMembersFlow.tsx:303 2760 2781 #: src/components/dms/InitiateChatFlow.tsx:441 2761 2782 msgid "Continue to group name" 2762 2783 msgstr "Continue to group name" ··· 2787 2808 msgid "Copied build version to clipboard" 2788 2809 msgstr "" 2789 2810 2790 - #: src/components/dms/MessageContextMenu.tsx:64 2811 + #: src/components/dms/MessageContextMenu.tsx:67 2791 2812 #: src/components/PostControls/DiscoverDebug.tsx:36 2792 2813 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:272 2793 2814 #: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:77 ··· 2797 2818 msgstr "" 2798 2819 2799 2820 #: src/components/dialogs/Embed.tsx:197 2821 + #: src/screens/Messages/components/CopyTextButton.tsx:71 2800 2822 #: src/screens/Settings/components/CopyButton.tsx:66 2801 2823 msgid "Copied!" 2802 2824 msgstr "" ··· 2868 2890 msgid "Copy link to starter pack" 2869 2891 msgstr "" 2870 2892 2871 - #: src/components/dms/MessageContextMenu.tsx:154 2872 - #: src/components/dms/MessageContextMenu.tsx:157 2893 + #: src/components/dms/MessageContextMenu.tsx:155 2894 + #: src/components/dms/MessageContextMenu.tsx:158 2873 2895 msgid "Copy message text" 2874 2896 msgstr "" 2875 2897 ··· 3033 3055 msgid "Create an avatar instead" 3034 3056 msgstr "" 3035 3057 3036 - #: src/screens/Messages/ConversationSettings.tsx:891 3037 - msgid "Create an invite link for this group chat" 3038 - msgstr "Create an invite link for this group chat" 3039 - 3040 3058 #: src/components/StarterPack/ProfileStarterPacks.tsx:214 3041 3059 msgid "Create another" 3042 3060 msgstr "" ··· 3068 3086 msgid "Create new account" 3069 3087 msgstr "" 3070 3088 3089 + #: src/screens/Messages/ConversationSettings/index.tsx:459 3090 + msgid "Create or modify an invite link for this group chat" 3091 + msgstr "Create or modify an invite link for this group chat" 3092 + 3071 3093 #. Accessibility label for button to create a moderation report for the selected option 3072 3094 #. placeholder {0}: option.title 3073 3095 #: src/components/moderation/ReportDialog/index.tsx:713 ··· 3084 3106 msgid "Create user list" 3085 3107 msgstr "" 3086 3108 3109 + #. placeholder {0}: dateFormatter.format(createdAt) 3087 3110 #. placeholder {0}: i18n.date(appPassword.createdAt, { year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', }) 3111 + #: src/screens/Messages/ConversationSettings/index.tsx:424 3088 3112 #: src/screens/Settings/AppPasswords.tsx:174 3089 3113 msgid "Created {0}" 3090 3114 msgstr "" 3091 3115 3116 + #. placeholder {0}: timeFormatter.format(createdAt) 3117 + #. placeholder {1}: dateFormatter.format(createdAt) 3118 + #: src/screens/Messages/components/InviteLinkDialog.tsx:289 3119 + msgid "Created {0} {1}" 3120 + msgstr "Created {0} {1}" 3121 + 3092 3122 #: src/screens/List/ListHiddenScreen.tsx:131 3093 3123 msgid "Creator has been blocked" 3094 3124 msgstr "" ··· 3168 3198 msgid "Default icons" 3169 3199 msgstr "" 3170 3200 3171 - #: src/components/dms/MessageContextMenu.tsx:208 3201 + #: src/components/dms/MessageContextMenu.tsx:209 3172 3202 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:803 3173 - #: src/screens/Messages/components/ChatStatusInfo.tsx:55 3203 + #: src/screens/Messages/components/ChatStatusInfo.tsx:53 3174 3204 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:275 3175 3205 #: src/screens/Settings/AppPasswords.tsx:213 3176 3206 #: src/screens/StarterPack/StarterPackScreen.tsx:617 ··· 3221 3251 msgid "Delete Conversation" 3222 3252 msgstr "" 3223 3253 3224 - #: src/components/dms/MessageContextMenu.tsx:168 3254 + #: src/components/dms/MessageContextMenu.tsx:169 3225 3255 msgid "Delete for me" 3226 3256 msgstr "" 3227 3257 ··· 3230 3260 msgid "Delete list" 3231 3261 msgstr "" 3232 3262 3233 - #: src/components/dms/MessageContextMenu.tsx:206 3263 + #: src/components/dms/MessageContextMenu.tsx:207 3234 3264 msgid "Delete message" 3235 3265 msgstr "" 3236 3266 3237 - #: src/components/dms/MessageContextMenu.tsx:166 3267 + #: src/components/dms/MessageContextMenu.tsx:167 3238 3268 msgid "Delete message for me" 3239 3269 msgstr "" 3240 3270 ··· 3271 3301 3272 3302 #: src/components/dms/MessagesListHeader.tsx:105 3273 3303 #: src/screens/Messages/components/ChatListItem.tsx:118 3274 - #: src/screens/Messages/ConversationSettings.tsx:409 3275 - #: src/screens/Messages/ConversationSettings.tsx:625 3304 + #: src/screens/Messages/ConversationSettings/Member.tsx:48 3276 3305 msgid "Deleted Account" 3277 3306 msgstr "" 3278 3307 ··· 3333 3362 #: src/screens/Settings/AppearanceSettings.tsx:110 3334 3363 msgid "Dim" 3335 3364 msgstr "" 3365 + 3366 + #: src/screens/Messages/components/InviteLinkDialog.tsx:323 3367 + #: src/screens/Messages/components/InviteLinkDialog.tsx:331 3368 + msgid "Disable" 3369 + msgstr "Disable" 3336 3370 3337 3371 #: src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx:234 3338 3372 #: src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx:243 ··· 3537 3571 msgid "Done" 3538 3572 msgstr "" 3539 3573 3540 - #: src/components/dms/MessageItem.tsx:448 3574 + #: src/components/dms/MessageItem.tsx:452 3541 3575 msgid "Double tap or long press the message to add a reaction" 3542 3576 msgstr "" 3543 3577 ··· 3627 3661 msgid "Eating disorders" 3628 3662 msgstr "" 3629 3663 3664 + #: src/screens/Messages/components/EditTextButton.tsx:51 3630 3665 #: src/screens/Settings/AccountSettings.tsx:150 3631 3666 #: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:255 3632 3667 #: src/screens/StarterPack/StarterPackScreen.tsx:606 ··· 3650 3685 msgid "Edit Feeds" 3651 3686 msgstr "" 3652 3687 3653 - #: src/screens/Messages/ConversationSettings.tsx:1067 3654 - #: src/screens/Messages/ConversationSettings.tsx:1072 3688 + #: src/screens/Messages/ConversationSettings/prompts.tsx:27 3689 + #: src/screens/Messages/ConversationSettings/prompts.tsx:32 3655 3690 msgid "Edit group name" 3656 3691 msgstr "Edit group name" 3657 3692 ··· 3671 3706 msgid "Edit interests" 3672 3707 msgstr "" 3673 3708 3709 + #: src/screens/Messages/components/InviteLinkDialog.tsx:300 3710 + msgid "Edit link settings" 3711 + msgstr "Edit link settings" 3712 + 3674 3713 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:191 3675 3714 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:194 3676 3715 msgid "Edit list details" ··· 3690 3729 msgid "Edit My Feeds" 3691 3730 msgstr "" 3692 3731 3693 - #: src/screens/Messages/ConversationSettings.tsx:885 3732 + #: src/screens/Messages/ConversationSettings/index.tsx:450 3694 3733 msgid "Edit name" 3695 3734 msgstr "Edit name" 3696 3735 ··· 3724 3763 msgid "Edit starter pack" 3725 3764 msgstr "" 3726 3765 3727 - #: src/screens/Messages/ConversationSettings.tsx:884 3766 + #: src/screens/Messages/ConversationSettings/index.tsx:449 3728 3767 msgid "Edit this group chat’s name" 3729 3768 msgstr "Edit this group chat’s name" 3730 3769 ··· 3871 3910 msgid "Enabled" 3872 3911 msgstr "" 3873 3912 3874 - #: src/screens/Profile/Sections/Feed.tsx:132 3913 + #: src/screens/Profile/Sections/Feed.tsx:131 3875 3914 msgid "End of feed" 3876 3915 msgstr "" 3877 3916 ··· 4119 4158 msgid "Failed to accept chat" 4120 4159 msgstr "" 4121 4160 4122 - #: src/components/dms/ActionsWrapper.web.tsx:67 4123 - #: src/components/dms/MessageContextMenu.tsx:104 4161 + #: src/components/dms/ActionsWrapper.web.tsx:70 4162 + #: src/components/dms/MessageContextMenu.tsx:107 4124 4163 msgid "Failed to add emoji reaction" 4125 4164 msgstr "" 4126 4165 4166 + #: src/screens/Messages/components/MessagesListInfoPanel.tsx:39 4167 + #: src/screens/Messages/ConversationSettings/AddMembersLink.tsx:36 4168 + msgid "Failed to add members" 4169 + msgstr "Failed to add members" 4170 + 4127 4171 #: src/components/dialogs/StarterPackDialog.tsx:280 4128 4172 msgid "Failed to add to starter pack" 4129 4173 msgstr "" ··· 4137 4181 msgstr "" 4138 4182 4139 4183 #: src/components/dms/MessageProfileButton.tsx:38 4140 - #: src/screens/Messages/ConversationSettings.tsx:555 4184 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:63 4141 4185 msgid "Failed to create conversation" 4142 4186 msgstr "" 4143 4187 4188 + #: src/screens/Messages/components/InviteLinkDialog.tsx:85 4189 + msgid "Failed to create invite link" 4190 + msgstr "Failed to create invite link" 4191 + 4144 4192 #: src/screens/StarterPack/Wizard/index.tsx:250 4145 4193 #: src/screens/StarterPack/Wizard/index.tsx:260 4146 4194 msgid "Failed to create starter pack" ··· 4152 4200 msgid "Failed to delete chat" 4153 4201 msgstr "" 4154 4202 4155 - #: src/components/dms/MessageContextMenu.tsx:86 4203 + #: src/components/dms/MessageContextMenu.tsx:89 4156 4204 msgid "Failed to delete message" 4157 4205 msgstr "" 4158 4206 ··· 4164 4212 msgid "Failed to delete starter pack" 4165 4213 msgstr "" 4166 4214 4215 + #: src/screens/Messages/components/InviteLinkDialog.tsx:108 4216 + msgid "Failed to disable invite link" 4217 + msgstr "Failed to disable invite link" 4218 + 4167 4219 #. placeholder {0}: error?.message 4168 4220 #: src/screens/Profile/components/GermButton.tsx:195 4169 4221 msgid "Failed to disconnect Germ DM. Error: {0}" 4170 4222 msgstr "" 4171 4223 4172 - #: src/screens/Messages/ConversationSettings.tsx:757 4224 + #: src/screens/Messages/ConversationSettings/index.tsx:312 4173 4225 msgid "Failed to edit group chat name" 4174 4226 msgstr "Failed to edit group chat name" 4175 4227 4228 + #: src/screens/Messages/components/InviteLinkDialog.tsx:98 4229 + msgid "Failed to edit invite link" 4230 + msgstr "Failed to edit invite link" 4231 + 4232 + #: src/screens/Messages/components/InviteLinkDialog.tsx:118 4233 + msgid "Failed to enable invite link" 4234 + msgstr "Failed to enable invite link" 4235 + 4176 4236 #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:143 4177 4237 msgid "Failed to follow all suggested accounts, please try again" 4178 4238 msgstr "" ··· 4189 4249 msgid "Failed to launch SMS app" 4190 4250 msgstr "" 4191 4251 4192 - #: src/screens/Messages/ConversationSettings.tsx:785 4252 + #: src/screens/Messages/ConversationSettings/index.tsx:337 4193 4253 msgctxt "toast" 4194 4254 msgid "Failed to leave group chat" 4195 4255 msgstr "Failed to leave group chat" ··· 4253 4313 msgid "Failed to load suggested follows" 4254 4314 msgstr "" 4255 4315 4316 + #: src/screens/Messages/ConversationSettings/index.tsx:355 4317 + msgid "Failed to lock group chat" 4318 + msgstr "Failed to lock group chat" 4319 + 4256 4320 #: src/screens/Messages/Inbox.tsx:321 4257 4321 #: src/screens/Messages/Inbox.tsx:348 4258 4322 msgid "Failed to mark all requests as read" 4259 4323 msgstr "" 4260 4324 4261 - #: src/screens/Messages/ConversationSettings.tsx:773 4325 + #: src/screens/Messages/ConversationSettings/index.tsx:326 4262 4326 msgid "Failed to mute group chat" 4263 4327 msgstr "Failed to mute group chat" 4264 4328 ··· 4280 4344 msgid "Failed to remove data. {0}" 4281 4345 msgstr "" 4282 4346 4283 - #: src/components/dms/ActionsWrapper.web.tsx:63 4284 - #: src/components/dms/MessageContextMenu.tsx:100 4347 + #: src/components/dms/ActionsWrapper.web.tsx:66 4348 + #: src/components/dms/MessageContextMenu.tsx:103 4285 4349 #: src/components/dms/ReactionsDialog.tsx:174 4286 4350 msgid "Failed to remove emoji reaction" 4287 4351 msgstr "" ··· 4289 4353 #: src/components/dialogs/StarterPackDialog.tsx:293 4290 4354 msgid "Failed to remove from starter pack" 4291 4355 msgstr "" 4356 + 4357 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:70 4358 + msgid "Failed to remove group chat member" 4359 + msgstr "Failed to remove group chat member" 4292 4360 4293 4361 #: src/components/verification/VerificationRemovePrompt.tsx:34 4294 4362 msgid "Failed to remove verification" ··· 4335 4403 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:251 4336 4404 msgid "Failed to toggle thread mute, please try again" 4337 4405 msgstr "" 4406 + 4407 + #: src/screens/Messages/ConversationSettings/index.tsx:358 4408 + msgid "Failed to unlock group chat" 4409 + msgstr "Failed to unlock group chat" 4338 4410 4339 4411 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:107 4340 4412 msgid "Failed to unpin list" ··· 4817 4889 msgid "Generate a starter pack" 4818 4890 msgstr "" 4819 4891 4892 + #: src/screens/Messages/components/InviteLinkDialog.tsx:189 4893 + #: src/screens/Messages/components/InviteLinkDialog.tsx:224 4894 + #: src/screens/Messages/components/InviteLinkDialog.tsx:247 4895 + msgid "Generate invite link" 4896 + msgstr "Generate invite link" 4897 + 4898 + #: src/screens/Messages/components/InviteLinkDialog.tsx:378 4899 + msgid "Generate new invite link" 4900 + msgstr "Generate new invite link" 4901 + 4902 + #: src/screens/Messages/components/InviteLinkDialog.tsx:383 4903 + msgid "Generate new link" 4904 + msgstr "Generate new link" 4905 + 4820 4906 #: src/screens/Profile/components/GermButton.tsx:86 4821 4907 #: src/screens/Profile/components/GermButton.tsx:224 4822 4908 msgid "Germ DM" ··· 4897 4983 4898 4984 #: src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx:77 4899 4985 #: src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx:87 4900 - #: src/screens/Messages/ConversationSettings.tsx:1113 4986 + #: src/screens/Messages/components/InviteLinkDialog.tsx:173 4987 + #: src/screens/Messages/components/InviteLinkDialog.tsx:180 4901 4988 msgid "Get started" 4902 4989 msgstr "" 4903 4990 ··· 4939 5026 4940 5027 #: src/components/Error.tsx:77 4941 5028 #: src/screens/List/ListHiddenScreen.tsx:228 4942 - #: src/screens/Messages/Conversation.tsx:360 5029 + #: src/screens/Messages/Conversation.tsx:366 4943 5030 #: src/screens/Profile/ErrorState.tsx:63 4944 5031 #: src/screens/Profile/ErrorState.tsx:67 4945 5032 #: src/screens/StarterPack/StarterPackScreen.tsx:809 ··· 5014 5101 msgstr "" 5015 5102 5016 5103 #: src/components/dms/ConvoMenu.tsx:255 5017 - #: src/screens/Messages/ConversationSettings.tsx:676 5104 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:189 5018 5105 #: src/view/shell/desktop/LeftNav.tsx:319 5019 5106 #: src/view/shell/desktop/LeftNav.tsx:325 5020 5107 msgid "Go to profile" ··· 5052 5139 msgid "Grooming or predatory behavior" 5053 5140 msgstr "" 5054 5141 5055 - #: src/screens/Messages/ConversationSettings.tsx:766 5142 + #: src/screens/Messages/components/InviteLinkDialog.tsx:437 5143 + msgid "Group chat invite link dialog" 5144 + msgstr "Group chat invite link dialog" 5145 + 5146 + #: src/screens/Messages/ConversationSettings/index.tsx:347 5147 + msgctxt "toast" 5148 + msgid "Group chat locked" 5149 + msgstr "Group chat locked" 5150 + 5151 + #: src/screens/Messages/ConversationSettings/index.tsx:319 5056 5152 msgctxt "toast" 5057 5153 msgid "Group chat muted" 5058 5154 msgstr "Group chat muted" 5059 5155 5060 5156 #: src/Navigation.tsx:575 5061 - #: src/screens/Messages/ConversationSettings.tsx:107 5157 + #: src/screens/Messages/ConversationSettings/index.tsx:92 5062 5158 msgid "Group chat settings" 5063 5159 msgstr "Group chat settings" 5064 5160 5065 - #: src/screens/Messages/ConversationSettings.tsx:768 5161 + #: src/screens/Messages/ConversationSettings/index.tsx:349 5162 + msgctxt "toast" 5163 + msgid "Group chat unlocked" 5164 + msgstr "Group chat unlocked" 5165 + 5166 + #: src/screens/Messages/ConversationSettings/index.tsx:321 5066 5167 msgctxt "toast" 5067 5168 msgid "Group chat unmuted" 5068 5169 msgstr "Group chat unmuted" 5069 5170 5070 - #: src/screens/Messages/Conversation.tsx:345 5171 + #: src/screens/Messages/Conversation.tsx:350 5071 5172 msgid "Group chats are not yet available" 5072 5173 msgstr "Group chats are not yet available" 5073 5174 5074 - #: src/screens/Messages/Conversation.tsx:343 5175 + #: src/screens/Messages/Conversation.tsx:348 5075 5176 msgid "Group chats are now available" 5076 5177 msgstr "Group chats are now available" 5077 5178 ··· 5081 5182 5082 5183 #: src/components/dms/InitiateChatFlow.tsx:231 5083 5184 #: src/components/dms/InitiateChatFlow.tsx:549 5084 - #: src/screens/Messages/ConversationSettings.tsx:1073 5185 + #: src/screens/Messages/ConversationSettings/prompts.tsx:33 5085 5186 msgid "Group name" 5086 5187 msgstr "Group name" 5087 5188 ··· 5338 5439 #: src/view/com/composer/state/video.ts:414 5339 5440 msgid "Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!" 5340 5441 msgstr "" 5442 + 5443 + #: src/screens/Messages/Conversation.tsx:357 5444 + msgid "Hold your horses! This feature isn't available to you yet. Please check back later." 5445 + msgstr "Hold your horses! This feature isn't available to you yet. Please check back later." 5341 5446 5342 5447 #: src/Navigation.tsx:819 5343 5448 #: src/Navigation.tsx:839 ··· 5685 5790 msgid "Invite friends <0/>" 5686 5791 msgstr "" 5687 5792 5688 - #: src/screens/Messages/components/MessagesListInfoPanel.tsx:113 5689 - #: src/screens/Messages/ConversationSettings.tsx:892 5690 - #: src/screens/Messages/ConversationSettings.tsx:1111 5793 + #: src/screens/Messages/components/InviteLinkDialog.tsx:153 5794 + #: src/screens/Messages/components/InviteLinkDialog.tsx:267 5795 + #: src/screens/Messages/components/InviteLinkDialog.tsx:273 5796 + #: src/screens/Messages/components/InviteLinkDialog.tsx:395 5797 + #: src/screens/Messages/components/MessagesListInfoPanel.tsx:135 5798 + #: src/screens/Messages/ConversationSettings/index.tsx:462 5691 5799 msgid "Invite link" 5692 5800 msgstr "Invite link" 5693 5801 ··· 5696 5804 msgstr "Invite link created" 5697 5805 5698 5806 #: src/components/dms/getSystemMessageInfo.ts:86 5807 + #: src/screens/Messages/components/InviteLinkDialog.tsx:267 5699 5808 msgid "Invite link disabled" 5700 5809 msgstr "Invite link disabled" 5701 5810 ··· 5715 5824 msgid "Invite your friends to follow your favorite feeds and people" 5716 5825 msgstr "" 5717 5826 5718 - #: src/screens/Messages/ConversationSettings.tsx:639 5827 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:151 5719 5828 msgid "Invited" 5720 5829 msgstr "Invited" 5721 5830 ··· 5925 6034 msgstr "" 5926 6035 5927 6036 #: src/components/dms/LeaveConvoPrompt.tsx:52 5928 - #: src/screens/Messages/ConversationSettings.tsx:920 6037 + #: src/screens/Messages/ConversationSettings/index.tsx:495 5929 6038 msgid "Leave" 5930 6039 msgstr "" 5931 6040 ··· 5942 6051 msgid "Leave conversation" 5943 6052 msgstr "" 5944 6053 5945 - #: src/screens/Messages/ConversationSettings.tsx:1157 6054 + #: src/screens/Messages/ConversationSettings/prompts.tsx:92 5946 6055 msgid "Leave group chat" 5947 6056 msgstr "Leave group chat" 5948 6057 5949 - #: src/screens/Messages/ConversationSettings.tsx:919 6058 + #: src/screens/Messages/ConversationSettings/index.tsx:494 5950 6059 msgid "Leave this group chat" 5951 6060 msgstr "Leave this group chat" 5952 6061 ··· 6039 6148 msgid "Liked by {likeCount, plural, one {# user} other {# users}}" 6040 6149 msgstr "" 6041 6150 6042 - #: src/lib/hooks/useNotificationHandler.ts:129 6151 + #: src/lib/hooks/useNotificationHandler.ts:136 6043 6152 #: src/screens/Settings/NotificationSettings/index.tsx:127 6044 6153 #: src/screens/Settings/NotificationSettings/LikeNotificationSettings.tsx:41 6045 6154 #: src/view/screens/Profile.tsx:237 6046 6155 msgid "Likes" 6047 6156 msgstr "" 6048 6157 6049 - #: src/lib/hooks/useNotificationHandler.ts:171 6158 + #: src/lib/hooks/useNotificationHandler.ts:178 6050 6159 #: src/screens/Settings/NotificationSettings/index.tsx:208 6051 6160 #: src/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings.tsx:41 6052 6161 msgid "Likes of your reposts" ··· 6240 6349 msgid "Loading..." 6241 6350 msgstr "" 6242 6351 6243 - #: src/screens/Messages/ConversationSettings.tsx:1031 6352 + #: src/screens/Messages/ConversationSettings/index.tsx:616 6244 6353 msgid "Loading…" 6245 6354 msgstr "Loading…" 6246 6355 6247 - #: src/screens/Messages/ConversationSettings.tsx:902 6356 + #: src/screens/Messages/ConversationSettings/index.tsx:475 6248 6357 msgid "Lock" 6249 6358 msgstr "Lock" 6250 6359 6251 - #: src/screens/Messages/ConversationSettings.tsx:1134 6360 + #: src/screens/Messages/ConversationSettings/prompts.tsx:69 6252 6361 msgid "Lock group chat" 6253 6362 msgstr "Lock group chat" 6254 6363 6255 - #: src/screens/Messages/ConversationSettings.tsx:1132 6364 + #: src/screens/Messages/ConversationSettings/prompts.tsx:67 6256 6365 msgid "Lock group chat?" 6257 6366 msgstr "Lock group chat?" 6258 6367 6259 - #: src/screens/Messages/ConversationSettings.tsx:900 6368 + #: src/screens/Messages/ConversationSettings/index.tsx:473 6260 6369 msgid "Lock this group chat" 6261 6370 msgstr "Lock this group chat" 6262 6371 6263 - #: src/screens/Messages/ConversationSettings.tsx:902 6372 + #: src/screens/Messages/ConversationSettings/index.tsx:475 6264 6373 msgid "Locked" 6265 6374 msgstr "Locked" 6266 6375 ··· 6370 6479 msgid "Media that may be disturbing or inappropriate for some audiences." 6371 6480 msgstr "" 6372 6481 6373 - #: src/screens/Messages/ConversationSettings.tsx:260 6482 + #: src/screens/Messages/ConversationSettings/MembersAndRequests.tsx:28 6374 6483 msgid "Members" 6375 6484 msgstr "Members" 6376 6485 6377 - #: src/screens/Messages/ConversationSettings.tsx:1133 6486 + #: src/screens/Messages/ConversationSettings/prompts.tsx:68 6378 6487 msgid "Members can still read chat history but can’t send new messages." 6379 6488 msgstr "Members can still read chat history but can’t send new messages." 6380 6489 ··· 6386 6495 msgid "mentioned users" 6387 6496 msgstr "" 6388 6497 6389 - #: src/lib/hooks/useNotificationHandler.ts:150 6498 + #: src/lib/hooks/useNotificationHandler.ts:157 6390 6499 #: src/screens/Settings/NotificationSettings/index.tsx:160 6391 6500 #: src/screens/Settings/NotificationSettings/MentionNotificationSettings.tsx:41 6392 6501 #: src/view/screens/Notifications.tsx:100 ··· 6398 6507 msgstr "" 6399 6508 6400 6509 #: src/screens/Messages/components/MessageComposer.tsx:208 6401 - #: src/screens/Messages/components/MessageInput.tsx:171 6510 + #: src/screens/Messages/components/MessageInput.tsx:172 6402 6511 #: src/screens/Messages/components/MessageInput.web.tsx:195 6403 6512 msgid "Message" 6404 6513 msgstr "Message" 6405 6514 6406 - #: src/screens/Messages/ConversationSettings.tsx:684 6515 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:197 6407 6516 msgctxt "action" 6408 6517 msgid "Message" 6409 6518 msgstr "Message" ··· 6413 6522 msgid "Message {0}" 6414 6523 msgstr "" 6415 6524 6416 - #: src/screens/Messages/ConversationSettings.tsx:681 6525 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:194 6417 6526 msgid "Message {displayName}" 6418 6527 msgstr "Message {displayName}" 6419 6528 ··· 6421 6530 msgid "Message deleted" 6422 6531 msgstr "" 6423 6532 6424 - #: src/components/dms/MessageContextMenu.tsx:85 6533 + #: src/components/dms/MessageContextMenu.tsx:88 6425 6534 msgctxt "toast" 6426 6535 msgid "Message deleted" 6427 6536 msgstr "" 6428 6537 6429 - #: src/components/dms/MessageItem.tsx:547 6538 + #: src/components/dms/MessageItem.tsx:551 6430 6539 msgid "Message failed to send." 6431 6540 msgstr "Message failed to send." 6432 6541 6433 6542 #. placeholder {0}: sender?.handle ?? 'unknown' 6434 6543 #. placeholder {1}: message.text 6435 - #: src/components/dms/MessageContextMenu.tsx:133 6544 + #: src/components/dms/MessageContextMenu.tsx:134 6436 6545 msgid "Message from @{0}: {1}" 6437 6546 msgstr "" 6438 6547 ··· 6442 6551 msgstr "" 6443 6552 6444 6553 #: src/screens/Messages/components/MessageComposer.tsx:207 6445 - #: src/screens/Messages/components/MessageInput.tsx:169 6554 + #: src/screens/Messages/components/MessageInput.tsx:170 6446 6555 msgid "Message input field" 6447 6556 msgstr "" 6448 6557 ··· 6455 6564 msgid "Message is too long ({graphemeCount}/{MAX_DM_GRAPHEME_LENGTH})" 6456 6565 msgstr "Message is too long ({graphemeCount}/{MAX_DM_GRAPHEME_LENGTH})" 6457 6566 6458 - #: src/components/dms/MessageContextMenu.tsx:132 6567 + #: src/components/dms/MessageContextMenu.tsx:133 6459 6568 msgid "Message options" 6460 6569 msgstr "" 6461 6570 ··· 6583 6692 msgid "Music" 6584 6693 msgstr "" 6585 6694 6586 - #: src/screens/Messages/ConversationSettings.tsx:878 6695 + #: src/screens/Messages/ConversationSettings/index.tsx:443 6587 6696 msgid "Mute" 6588 6697 msgstr "Mute" 6589 6698 ··· 6628 6737 msgid "Mute these accounts?" 6629 6738 msgstr "" 6630 6739 6631 - #: src/screens/Messages/ConversationSettings.tsx:876 6740 + #: src/screens/Messages/ConversationSettings/index.tsx:441 6632 6741 msgid "Mute this group chat" 6633 6742 msgstr "Mute this group chat" 6634 6743 ··· 6666 6775 msgid "Mute words & tags" 6667 6776 msgstr "" 6668 6777 6669 - #: src/screens/Messages/ConversationSettings.tsx:878 6778 + #: src/screens/Messages/ConversationSettings/index.tsx:443 6670 6779 msgid "Muted" 6671 6780 msgstr "Muted" 6672 6781 ··· 6778 6887 msgstr "" 6779 6888 6780 6889 #. placeholder {0}: members[0].displayName 6781 - #: src/screens/Messages/components/MessagesListInfoPanel.tsx:38 6890 + #: src/screens/Messages/components/MessagesListInfoPanel.tsx:57 6782 6891 msgid "New chat with {0}" 6783 6892 msgstr "New chat with {0}" 6784 6893 6785 6894 #. placeholder {0}: members[0].displayName 6786 6895 #. placeholder {1}: members[1].displayName 6787 - #: src/screens/Messages/components/MessagesListInfoPanel.tsx:42 6896 + #: src/screens/Messages/components/MessagesListInfoPanel.tsx:60 6788 6897 msgid "New chat with {0} and {1}" 6789 6898 msgstr "New chat with {0} and {1}" 6790 6899 6791 6900 #. placeholder {0}: members[0].displayName 6792 6901 #. placeholder {1}: members[1].displayName 6793 - #. placeholder {2}: members.length - 2 6794 - #. placeholder {3}: members.length - 2 6795 - #. placeholder {4}: members.length - 2 6796 - #: src/screens/Messages/components/MessagesListInfoPanel.tsx:49 6797 - msgid "New chat with {0}, {1}, and {2, plural, one {{3} more} other {{4} more}}." 6798 - msgstr "New chat with {0}, {1}, and {2, plural, one {{3} more} other {{4} more}}." 6902 + #: src/screens/Messages/components/MessagesListInfoPanel.tsx:67 6903 + msgid "New chat with {0}, {1}, and {memberCount, plural, one {{memberCount} more} other {{memberCount} more}}." 6904 + msgstr "New chat with {0}, {1}, and {memberCount, plural, one {{memberCount} more} other {{memberCount} more}}." 6799 6905 6800 6906 #: src/components/dialogs/EmailDialog/screens/Update.tsx:223 6801 6907 msgid "New email address" ··· 6812 6918 msgid "New follower notifications" 6813 6919 msgstr "" 6814 6920 6815 - #: src/lib/hooks/useNotificationHandler.ts:164 6921 + #: src/lib/hooks/useNotificationHandler.ts:171 6816 6922 #: src/screens/Settings/NotificationSettings/index.tsx:138 6817 6923 #: src/screens/Settings/NotificationSettings/NewFollowerNotificationSettings.tsx:41 6818 6924 msgid "New followers" ··· 6894 7000 6895 7001 #: src/components/contacts/screens/ViewMatches.tsx:395 6896 7002 #: src/components/contacts/screens/ViewMatches.tsx:410 6897 - #: src/components/dms/AddMembersFlow.tsx:256 7003 + #: src/components/dms/AddMembersFlow.tsx:304 6898 7004 #: src/components/dms/InitiateChatFlow.tsx:442 6899 7005 #: src/screens/Login/ForgotPasswordForm.tsx:149 6900 7006 #: src/screens/Login/ForgotPasswordForm.tsx:156 ··· 7052 7158 msgstr "" 7053 7159 7054 7160 #: src/components/dialogs/SearchablePeopleList.tsx:249 7055 - #: src/components/dms/AddMembersFlow.tsx:208 7161 + #: src/components/dms/AddMembersFlow.tsx:236 7056 7162 #: src/components/dms/InitiateChatFlow.tsx:338 7057 7163 #: src/components/ProgressGuide/FollowDialog.tsx:221 7058 7164 msgid "No results" ··· 7338 7444 msgid "Open camera" 7339 7445 msgstr "" 7340 7446 7341 - #: src/screens/Messages/ConversationSettings.tsx:634 7447 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:138 7342 7448 msgid "Open chat member options for {displayName}" 7343 7449 msgstr "Open chat member options for {displayName}" 7344 7450 7345 - #: src/screens/Messages/components/ChatListItem.tsx:410 7346 - #: src/screens/Messages/components/ChatListItem.tsx:414 7451 + #: src/screens/Messages/components/ChatListItem.tsx:413 7452 + #: src/screens/Messages/components/ChatListItem.tsx:417 7347 7453 msgid "Open conversation options" 7348 7454 msgstr "" 7349 7455 ··· 7378 7484 msgid "Open Germ DM" 7379 7485 msgstr "" 7380 7486 7381 - #: src/components/dms/MessagesListHeader.tsx:177 7487 + #: src/components/dms/MessagesListHeader.tsx:179 7382 7488 msgid "Open group chat settings" 7383 7489 msgstr "Open group chat settings" 7384 7490 ··· 7386 7492 msgid "Open link to {niceUrl}" 7387 7493 msgstr "" 7388 7494 7389 - #: src/components/dms/ActionsWrapper.tsx:37 7495 + #: src/components/dms/ActionsWrapper.tsx:43 7390 7496 msgid "Open message options" 7391 7497 msgstr "" 7392 7498 ··· 7530 7636 msgid "Opens this draft in the composer" 7531 7637 msgstr "" 7532 7638 7533 - #: src/components/dms/MessageItem.tsx:223 7639 + #: src/components/dms/MessageItem.tsx:220 7534 7640 #: src/view/com/notifications/NotificationFeedItem.tsx:1019 7535 7641 #: src/view/com/util/UserAvatar.tsx:599 7536 7642 msgid "Opens this profile" ··· 7672 7778 msgid "People" 7673 7779 msgstr "" 7674 7780 7781 + #: src/screens/Messages/components/InviteLinkDialog.tsx:140 7782 + msgid "People {ownerName} follows can join instantly" 7783 + msgstr "People {ownerName} follows can join instantly" 7784 + 7785 + #: src/screens/Messages/components/InviteLinkDialog.tsx:145 7786 + msgid "People {ownerName} follows can request to join" 7787 + msgstr "People {ownerName} follows can request to join" 7788 + 7675 7789 #. placeholder {0}: route.params.name 7676 7790 #: src/Navigation.tsx:243 7677 7791 msgid "People followed by @{0}" ··· 7686 7800 #: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:193 7687 7801 msgid "People I follow" 7688 7802 msgstr "" 7803 + 7804 + #: src/screens/Messages/components/InviteLinkDialog.tsx:139 7805 + msgid "People I follow can join instantly" 7806 + msgstr "People I follow can join instantly" 7807 + 7808 + #: src/screens/Messages/components/InviteLinkDialog.tsx:144 7809 + msgid "People I follow can request to join" 7810 + msgstr "People I follow can request to join" 7689 7811 7690 7812 #: src/components/dialogs/PostInteractionSettingsDialog.tsx:510 7691 7813 msgid "People you follow" ··· 8051 8173 #: src/view/com/composer/select-language/PostLanguageSelect.tsx:195 8052 8174 msgid "Post language selection" 8053 8175 msgstr "" 8176 + 8177 + #: src/screens/Messages/components/InviteLinkDialog.tsx:336 8178 + #: src/screens/Messages/components/InviteLinkDialog.tsx:348 8179 + msgid "Post link" 8180 + msgstr "Post link" 8054 8181 8055 8182 #: src/screens/PostThread/components/ThreadError.tsx:33 8056 8183 #: src/screens/PostThread/components/ThreadItemPostTombstone.tsx:25 ··· 8293 8420 msgid "Quote posts disabled" 8294 8421 msgstr "" 8295 8422 8296 - #: src/lib/hooks/useNotificationHandler.ts:157 8423 + #: src/lib/hooks/useNotificationHandler.ts:164 8297 8424 #: src/screens/Post/PostQuotes.tsx:31 8298 8425 #: src/screens/Settings/NotificationSettings/index.tsx:171 8299 8426 #: src/screens/Settings/NotificationSettings/QuoteNotificationSettings.tsx:41 ··· 8317 8444 msgid "Re-attach quote" 8318 8445 msgstr "" 8319 8446 8447 + #: src/screens/Messages/components/InviteLinkDialog.tsx:365 8448 + msgid "Re-enable invite link" 8449 + msgstr "Re-enable invite link" 8450 + 8451 + #: src/screens/Messages/components/InviteLinkDialog.tsx:373 8452 + msgid "Re-enable link" 8453 + msgstr "Re-enable link" 8454 + 8320 8455 #: src/components/dms/EmojiReactionPicker.tsx:88 8321 8456 msgid "React with {emoji}" 8322 8457 msgstr "" ··· 8449 8584 msgid "Remove {displayName} from starter pack" 8450 8585 msgstr "" 8451 8586 8452 - #: src/screens/Messages/ConversationSettings.tsx:707 8587 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:224 8453 8588 msgid "Remove {displayName} from this group chat" 8454 8589 msgstr "Remove {displayName} from this group chat" 8455 8590 ··· 8499 8634 msgid "Remove feed?" 8500 8635 msgstr "" 8501 8636 8502 - #: src/screens/Messages/ConversationSettings.tsx:710 8637 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:227 8503 8638 msgid "Remove from chat" 8504 8639 msgstr "Remove from chat" 8505 8640 ··· 8658 8793 8659 8794 #: src/components/activity-notifications/SubscribeProfileDialog.tsx:274 8660 8795 #: src/components/activity-notifications/SubscribeProfileDialog.tsx:286 8661 - #: src/lib/hooks/useNotificationHandler.ts:143 8796 + #: src/lib/hooks/useNotificationHandler.ts:150 8662 8797 #: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:221 8663 8798 #: src/screens/Settings/NotificationSettings/index.tsx:149 8664 8799 #: src/screens/Settings/NotificationSettings/ReplyNotificationSettings.tsx:41 ··· 8716 8851 msgid "Reply was successfully hidden" 8717 8852 msgstr "" 8718 8853 8719 - #: src/components/dms/MessageContextMenu.tsx:176 8854 + #: src/components/dms/MessageContextMenu.tsx:177 8720 8855 #: src/components/dms/MessagesListBlockedFooter.tsx:86 8721 8856 #: src/components/dms/MessagesListBlockedFooter.tsx:93 8722 8857 #: src/features/liveNow/components/LiveStatusDialog.tsx:266 8723 - #: src/screens/Messages/ConversationSettings.tsx:911 8858 + #: src/screens/Messages/ConversationSettings/index.tsx:486 8724 8859 msgid "Report" 8725 8860 msgstr "" 8726 8861 ··· 8752 8887 msgid "Report list" 8753 8888 msgstr "" 8754 8889 8755 - #: src/components/dms/MessageContextMenu.tsx:174 8890 + #: src/components/dms/MessageContextMenu.tsx:175 8756 8891 msgid "Report message" 8757 8892 msgstr "" 8758 8893 ··· 8779 8914 msgid "Report this feed" 8780 8915 msgstr "" 8781 8916 8782 - #: src/screens/Messages/ConversationSettings.tsx:910 8917 + #: src/screens/Messages/ConversationSettings/index.tsx:485 8783 8918 msgid "Report this group chat" 8784 8919 msgstr "Report this group chat" 8785 8920 ··· 8847 8982 msgid "Reposted by you" 8848 8983 msgstr "" 8849 8984 8850 - #: src/lib/hooks/useNotificationHandler.ts:136 8985 + #: src/lib/hooks/useNotificationHandler.ts:143 8851 8986 #: src/screens/Settings/NotificationSettings/index.tsx:182 8852 8987 #: src/screens/Settings/NotificationSettings/RepostNotificationSettings.tsx:41 8853 8988 msgid "Reposts" ··· 8857 8992 msgid "Reposts of this post" 8858 8993 msgstr "" 8859 8994 8860 - #: src/lib/hooks/useNotificationHandler.ts:178 8995 + #: src/lib/hooks/useNotificationHandler.ts:185 8861 8996 #: src/screens/Settings/NotificationSettings/index.tsx:223 8862 8997 #: src/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings.tsx:41 8863 8998 msgid "Reposts of your reposts" ··· 9010 9145 #: src/components/StarterPack/QrCodeDialog.tsx:207 9011 9146 #: src/features/liveNow/components/EditLiveDialog.tsx:204 9012 9147 #: src/features/liveNow/components/EditLiveDialog.tsx:211 9013 - #: src/screens/Messages/ConversationSettings.tsx:1088 9148 + #: src/screens/Messages/ConversationSettings/prompts.tsx:47 9014 9149 #: src/screens/Profile/Header/EditProfileDialog.tsx:233 9015 9150 #: src/screens/Profile/Header/EditProfileDialog.tsx:247 9016 9151 #: src/screens/SavedFeeds.tsx:124 ··· 9493 9628 msgstr "" 9494 9629 9495 9630 #: src/screens/Messages/components/MessageComposer.tsx:266 9496 - #: src/screens/Messages/components/MessageInput.tsx:225 9631 + #: src/screens/Messages/components/MessageInput.tsx:226 9497 9632 #: src/screens/Messages/components/MessageInput.web.tsx:216 9498 9633 msgid "Send message" 9499 9634 msgstr "" ··· 9630 9765 9631 9766 #: src/components/StarterPack/QrCodeDialog.tsx:195 9632 9767 #: src/screens/Hashtag.tsx:130 9768 + #: src/screens/Messages/components/InviteLinkDialog.tsx:352 9769 + #: src/screens/Messages/components/InviteLinkDialog.tsx:359 9633 9770 #: src/screens/StarterPack/StarterPackScreen.tsx:447 9634 9771 #: src/screens/Topic.tsx:90 9635 9772 msgid "Share" ··· 10008 10145 msgstr "Someone left the group" 10009 10146 10010 10147 #. placeholder {0}: reaction.value 10011 - #: src/components/dms/MessageItem.tsx:286 10148 + #: src/components/dms/MessageItem.tsx:281 10012 10149 msgid "Someone reacted {0}" 10013 10150 msgstr "" 10014 10151 ··· 10030 10167 msgstr "" 10031 10168 10032 10169 #: src/screens/Messages/Conversation.tsx:144 10033 - #: src/screens/Messages/ConversationSettings.tsx:211 10170 + #: src/screens/Messages/ConversationSettings/index.tsx:112 10034 10171 msgid "Something went wrong" 10035 10172 msgstr "" 10036 10173 ··· 10265 10402 msgid "Successfully verified" 10266 10403 msgstr "" 10267 10404 10268 - #: src/components/dms/AddMembersFlow.tsx:200 10405 + #: src/components/dms/AddMembersFlow.tsx:222 10269 10406 #: src/components/dms/InitiateChatFlow.tsx:317 10270 10407 msgid "Suggested" 10271 10408 msgstr "Suggested" ··· 10380 10517 msgid "Tap to remove your {0} reaction" 10381 10518 msgstr "Tap to remove your {0} reaction" 10382 10519 10383 - #: src/components/dms/MessageItem.tsx:557 10520 + #: src/components/dms/MessageItem.tsx:561 10384 10521 msgid "Tap to retry" 10385 10522 msgstr "Tap to retry" 10386 10523 ··· 10393 10530 msgid "Tap to show all reactions" 10394 10531 msgstr "Tap to show all reactions" 10395 10532 10396 - #: src/components/dms/MessageItem.tsx:309 10533 + #: src/components/dms/MessageItem.tsx:310 10397 10534 msgid "Tap to view reactions" 10398 10535 msgstr "Tap to view reactions" 10399 10536 ··· 10617 10754 #: src/components/dialogs/BirthDateSettings.tsx:101 10618 10755 msgid "There is a limit to how often you can change your birthdate. You may need to wait a day or two before updating it again." 10619 10756 msgstr "" 10757 + 10758 + #: src/screens/Messages/components/InviteLinkDialog.tsx:400 10759 + msgid "There is no invite link for this group chat." 10760 + msgstr "There is no invite link for this group chat." 10620 10761 10621 10762 #: src/screens/Settings/components/DeactivateAccountDialog.tsx:86 10622 10763 msgid "There is no time limit for account deactivation, come back any time." ··· 10687 10828 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:431 10688 10829 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:454 10689 10830 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:474 10690 - #: src/screens/Messages/ConversationSettings.tsx:593 10691 - #: src/screens/Messages/ConversationSettings.tsx:606 10831 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:108 10832 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:121 10692 10833 #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:117 10693 10834 #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:130 10694 10835 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:93 ··· 10842 10983 #: src/lib/strings/errors.ts:31 10843 10984 msgid "This feature is not available while using an App Password. Please sign in with your main password." 10844 10985 msgstr "" 10845 - 10846 - #: src/screens/Messages/Conversation.tsx:352 10847 - msgid "This feature isn't available to you yet. Please check back later." 10848 - msgstr "This feature isn't available to you yet. Please check back later." 10849 10986 10850 10987 #: src/view/com/posts/PostFeedErrorMessage.tsx:128 10851 10988 msgid "This feed is currently receiving high traffic and is temporarily unavailable. Please try again later." ··· 10957 11094 msgid "This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others." 10958 11095 msgstr "" 10959 11096 11097 + #: src/screens/Messages/ConversationSettings/index.tsx:134 11098 + msgid "This screen is only available for group conversations." 11099 + msgstr "This screen is only available for group conversations." 11100 + 10960 11101 #: src/screens/Signup/StepInfo/Policies.tsx:32 10961 11102 msgid "This service has not provided terms of service or a privacy policy." 10962 11103 msgstr "" ··· 11125 11266 msgid "Topic" 11126 11267 msgstr "" 11127 11268 11128 - #: src/components/dms/MessageContextMenu.tsx:147 11129 - #: src/components/dms/MessageContextMenu.tsx:149 11269 + #: src/components/dms/MessageContextMenu.tsx:148 11270 + #: src/components/dms/MessageContextMenu.tsx:150 11130 11271 #: src/components/Post/Translated/index.tsx:150 11131 11272 #: src/components/Post/Translated/index.tsx:157 11132 11273 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:553 ··· 11199 11340 msgid "Two-factor authentication (2FA)" 11200 11341 msgstr "" 11201 11342 11202 - #: src/screens/Messages/components/MessageInput.tsx:170 11343 + #: src/screens/Messages/components/MessageInput.tsx:171 11203 11344 msgid "Type your message here" 11204 11345 msgstr "" 11205 11346 ··· 11271 11412 msgid "Unblock" 11272 11413 msgstr "" 11273 11414 11274 - #: src/screens/Messages/ConversationSettings.tsx:695 11415 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:208 11275 11416 msgid "Unblock {displayName}" 11276 11417 msgstr "Unblock {displayName}" 11277 11418 ··· 11346 11487 msgid "Unfortunately, your declared age indicates that you are not old enough to access Bluesky in your region." 11347 11488 msgstr "" 11348 11489 11349 - #: src/screens/Messages/ConversationSettings.tsx:720 11490 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:238 11350 11491 msgid "Uninvite" 11351 11492 msgstr "Uninvite" 11352 11493 11353 - #: src/screens/Messages/ConversationSettings.tsx:717 11494 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:234 11354 11495 msgid "Uninvite {displayName} from this group chat" 11355 11496 msgstr "Uninvite {displayName} from this group chat" 11356 11497 ··· 11376 11517 msgid "Unlike ({0, plural, one {# like} other {# likes}})" 11377 11518 msgstr "" 11378 11519 11379 - #: src/screens/Messages/ConversationSettings.tsx:900 11520 + #: src/screens/Messages/ConversationSettings/index.tsx:472 11380 11521 msgid "Unlock this group chat" 11381 11522 msgstr "Unlock this group chat" 11382 11523 ··· 11413 11554 msgid "Unmute list" 11414 11555 msgstr "" 11415 11556 11416 - #: src/screens/Messages/ConversationSettings.tsx:875 11557 + #: src/screens/Messages/ConversationSettings/index.tsx:440 11417 11558 msgid "Unmute this group chat" 11418 11559 msgstr "Unmute this group chat" 11419 11560 ··· 11511 11652 #: src/screens/Settings/AccountSettings.tsx:120 11512 11653 msgid "Update email" 11513 11654 msgstr "" 11655 + 11656 + #: src/screens/Messages/components/InviteLinkDialog.tsx:246 11657 + msgid "Update invite link" 11658 + msgstr "Update invite link" 11514 11659 11515 11660 #: src/screens/Settings/components/ChangeHandleDialog.tsx:544 11516 11661 #: src/screens/Settings/components/ChangeHandleDialog.tsx:565 11517 11662 msgid "Update to {domain}" 11518 11663 msgstr "" 11519 11664 11520 - #: src/screens/Messages/Conversation.tsx:350 11665 + #: src/screens/Messages/Conversation.tsx:355 11521 11666 msgid "Update your app to the latest version to join in!" 11522 11667 msgstr "Update your app to the latest version to join in!" 11523 11668 ··· 11897 12042 msgstr "View {0}’s profile" 11898 12043 11899 12044 #: src/components/dms/MessagesListHeader.tsx:118 11900 - #: src/screens/Messages/ConversationSettings.tsx:671 12045 + #: src/screens/Messages/ConversationSettings/MemberMenu.tsx:184 11901 12046 msgid "View {displayName}’s profile" 11902 12047 msgstr "View {displayName}’s profile" 11903 12048 ··· 11923 12068 msgid "View full thread" 11924 12069 msgstr "" 11925 12070 11926 - #: src/screens/Messages/ConversationSettings.tsx:273 12071 + #: src/screens/Messages/ConversationSettings/MembersAndRequests.tsx:44 11927 12072 msgid "View incoming group chat requests" 11928 12073 msgstr "View incoming group chat requests" 11929 12074 ··· 11963 12108 msgid "View the avatar" 11964 12109 msgstr "" 11965 12110 12111 + #: src/screens/Messages/ConversationSettings/index.tsx:460 12112 + msgid "View the invite link for this group chat" 12113 + msgstr "View the invite link for this group chat" 12114 + 11966 12115 #. placeholder {0}: labeler.creator.handle 11967 12116 #: src/components/LabelingServiceCard/index.tsx:164 11968 12117 msgid "View the labeling service provided by @{0}" ··· 12078 12227 msgid "We couldn't load this conversation" 12079 12228 msgstr "" 12080 12229 12081 - #: src/screens/Messages/ConversationSettings.tsx:212 12230 + #: src/screens/Messages/ConversationSettings/index.tsx:113 12082 12231 msgid "We couldn’t load this conversation’s settings" 12083 12232 msgstr "We couldn’t load this conversation’s settings" 12084 12233 ··· 12188 12337 msgid "We're having network issues, try again" 12189 12338 msgstr "" 12190 12339 12191 - #: src/components/dms/AddMembersFlow.tsx:152 12340 + #: src/components/dms/AddMembersFlow.tsx:175 12192 12341 #: src/components/dms/InitiateChatFlow.tsx:254 12193 12342 msgid "We’re having network issues, try again" 12194 12343 msgstr "We’re having network issues, try again" ··· 12287 12436 msgid "Who can interact with this post?" 12288 12437 msgstr "" 12289 12438 12439 + #: src/screens/Messages/components/InviteLinkDialog.tsx:199 12440 + msgid "Who can join this group chat and how" 12441 + msgstr "Who can join this group chat and how" 12442 + 12290 12443 #: src/components/dialogs/PostInteractionSettingsDialog.tsx:427 12291 12444 #: src/components/WhoCanReply.tsx:116 12292 12445 msgid "Who can reply" ··· 12377 12530 #: src/screens/Settings/components/ChangeHandleDialog.tsx:368 12378 12531 msgid "Wrong DID returned from server. Received: {0}" 12379 12532 msgstr "" 12533 + 12534 + #: src/screens/Messages/ConversationSettings/index.tsx:133 12535 + msgid "Wrong kind of conversation" 12536 + msgstr "Wrong kind of conversation" 12380 12537 12381 12538 #: src/features/liveNow/components/EditLiveDialog.tsx:154 12382 12539 #: src/features/liveNow/components/GoLiveDialog.tsx:136 ··· 12491 12648 msgid "You can always opt out and delete your data" 12492 12649 msgstr "" 12493 12650 12494 - #: src/lib/hooks/useNotificationHandler.ts:104 12651 + #: src/lib/hooks/useNotificationHandler.ts:111 12495 12652 msgid "You can choose whether chat notifications have sound in the chat settings within the app" 12496 12653 msgstr "" 12497 12654 ··· 12735 12892 msgstr "" 12736 12893 12737 12894 #. placeholder {0}: reaction.value 12738 - #: src/components/dms/MessageItem.tsx:277 12895 + #: src/components/dms/MessageItem.tsx:274 12739 12896 msgid "You reacted {0}" 12740 12897 msgstr "" 12741 12898 ··· 12771 12928 msgid "You will receive an email with a \"reset code.\" Enter that code here, then enter your new password." 12772 12929 msgstr "" 12773 12930 12774 - #: src/screens/Messages/ConversationSettings.tsx:1156 12931 + #: src/screens/Messages/ConversationSettings/prompts.tsx:91 12775 12932 msgid "You won’t be able to rejoin unless you’re invited." 12776 12933 msgstr "You won’t be able to rejoin unless you’re invited." 12777 12934 ··· 12995 13152 #: src/components/dialogs/MutedWords.tsx:378 12996 13153 msgid "Your muted words" 12997 13154 msgstr "" 13155 + 13156 + #: src/screens/Messages/components/InviteLinkDialog.tsx:165 13157 + msgid "Your name, avatar, and the name of the group chat will be visible to everyone." 13158 + msgstr "Your name, avatar, and the name of the group chat will be visible to everyone." 12998 13159 12999 13160 #: src/screens/Settings/components/ChangePasswordDialog.tsx:72 13000 13161 msgid "Your password has been changed successfully! Please use your new password when you sign in to Bluesky from now on."
+13 -7
src/screens/Messages/Conversation.tsx
··· 1 - import {useCallback, useEffect, useMemo, useState} from 'react' 1 + import {useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 2 import {type LayoutChangeEvent, View} from 'react-native' 3 3 import {useSafeAreaInsets} from 'react-native-safe-area-context' 4 4 import {moderateProfile} from '@atproto/api' ··· 295 295 /> 296 296 )} 297 297 298 - {convo?.kind === 'group' && <GroupChatGate />} 298 + {/*{!IS_INTERNAL && convo?.kind === 'group' && <GroupChatGate />}*/} 299 299 </> 300 300 ) 301 301 } 302 302 303 + // eslint-disable-next-line @typescript-eslint/no-unused-vars 303 304 function GroupChatGate() { 304 305 const {t: l} = useLingui() 305 306 const ax = useAnalytics() ··· 319 320 ax.features.GroupChatsHasBeenReleased, 320 321 ) 321 322 323 + const isAlreadyGoingBackRef = useRef(false) 322 324 const onGoBack = () => { 325 + if (isAlreadyGoingBackRef.current) return 326 + isAlreadyGoingBackRef.current = true 323 327 if (navigation.canGoBack()) { 324 328 navigation.goBack() 325 329 } else { ··· 330 334 return ( 331 335 <Prompt.Outer 332 336 control={groupChatGateDialogControl} 337 + onClose={onGoBack} 333 338 nativeOptions={{preventDismiss: true, preventExpansion: true}} 334 339 testID="groupChatGateDialog"> 335 340 <Prompt.Content> 336 - <View style={[a.w_full, a.align_center, a.py_2xl]}> 337 - <Text style={{fontSize: 48}} emoji> 341 + <View style={[a.w_full, a.align_center, a.py_3xl]}> 342 + <Text style={{fontSize: 72}} emoji> 338 343 🐴 339 344 </Text> 340 345 </View> 341 - <Prompt.TitleText> 346 + <Prompt.TitleText style={[a.text_center]}> 342 347 {hasBeenReleased ? ( 343 348 <Trans>Group chats are now available</Trans> 344 349 ) : ( 345 350 <Trans>Group chats are not yet available</Trans> 346 351 )} 347 352 </Prompt.TitleText> 348 - <Prompt.DescriptionText> 353 + <Prompt.DescriptionText style={[a.text_center]}> 349 354 {hasBeenReleased ? ( 350 355 <Trans>Update your app to the latest version to join in!</Trans> 351 356 ) : ( 352 357 <Trans> 353 - This feature isn't available to you yet. Please check back later. 358 + Hold your horses! This feature isn't available to you yet. Please 359 + check back later. 354 360 </Trans> 355 361 )} 356 362 </Prompt.DescriptionText>
-1202
src/screens/Messages/ConversationSettings.tsx
··· 1 - import {useMemo, useState} from 'react' 2 - import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' 3 - import {moderateProfile} from '@atproto/api' 4 - import {plural} from '@lingui/core/macro' 5 - import {Trans, useLingui} from '@lingui/react/macro' 6 - import {StackActions, useNavigation} from '@react-navigation/native' 7 - 8 - import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' 9 - import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 10 - import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 11 - import { 12 - type CommonNavigatorParams, 13 - type NativeStackScreenProps, 14 - type NavigationProp, 15 - } from '#/lib/routes/types' 16 - import {sanitizeDisplayName} from '#/lib/strings/display-names' 17 - import {sanitizeHandle} from '#/lib/strings/handles' 18 - import {logger} from '#/logger' 19 - import {type Shadow} from '#/state/cache/types' 20 - import {ConvoProvider, useConvo} from '#/state/messages/convo' 21 - import {ConvoStatus} from '#/state/messages/convo/types' 22 - import {useModerationOpts} from '#/state/preferences/moderation-opts' 23 - import {useEditGroupName} from '#/state/queries/messages/edit-group-name' 24 - import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' 25 - import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 26 - import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' 27 - import {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests' 28 - import {useMuteConvo} from '#/state/queries/messages/mute-conversation' 29 - import {useProfileBlockMutationQueue} from '#/state/queries/profile' 30 - import {useSession} from '#/state/session' 31 - import {List} from '#/view/com/util/List' 32 - import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 33 - import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 34 - import {AvatarBubbles} from '#/components/AvatarBubbles' 35 - import {Button, type ButtonColor, ButtonIcon} from '#/components/Button' 36 - import * as Dialog from '#/components/Dialog' 37 - import {AddMembersFlow} from '#/components/dms/AddMembersFlow' 38 - import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util' 39 - import {Error} from '#/components/Error' 40 - import * as TextField from '#/components/forms/TextField' 41 - import {useInteractionState} from '#/components/hooks/useInteractionState' 42 - import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' 43 - import { 44 - Bell2_Stroke2_Corner0_Rounded as BellIcon, 45 - Bell2Off_Stroke2_Corner0_Rounded as BellOffIcon, 46 - } from '#/components/icons/Bell2' 47 - import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' 48 - import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron' 49 - import {type Props as SVGIconProps} from '#/components/icons/common' 50 - import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' 51 - import {EditBig_Stroke2_Corner0_Rounded as EditIcon} from '#/components/icons/EditBig' 52 - import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' 53 - import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' 54 - import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' 55 - import { 56 - Person_Stroke2_Corner2_Rounded as PersonIcon, 57 - PersonX_Stroke2_Corner0_Rounded as PersonXIcon, 58 - } from '#/components/icons/Person' 59 - import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 60 - import * as Layout from '#/components/Layout' 61 - import {InlineLinkText} from '#/components/Link' 62 - import * as Menu from '#/components/Menu' 63 - import {type TriggerChildProps} from '#/components/Menu/types' 64 - import * as Prompt from '#/components/Prompt' 65 - import {SubtleHover} from '#/components/SubtleHover' 66 - import * as Toast from '#/components/Toast' 67 - import {Text} from '#/components/Typography' 68 - import {useAnalytics} from '#/analytics' 69 - import {IS_NATIVE} from '#/env' 70 - import type * as bsky from '#/types/bsky' 71 - 72 - const MEMBER_LIMIT = 50 73 - const ROW_SPACING = 10 74 - 75 - type Item = 76 - | { 77 - type: 'MEMBERS_AND_REQUESTS' 78 - } 79 - | { 80 - type: 'ADD_MEMBERS_LINK' 81 - } 82 - | { 83 - type: 'CHAT_MEMBER' 84 - profile: Shadow<bsky.profile.AnyProfileView> 85 - status: 'owner' | 'member' | 'invited' 86 - } 87 - 88 - type Props = NativeStackScreenProps< 89 - CommonNavigatorParams, 90 - 'MessagesConversationSettings' 91 - > 92 - 93 - /** 94 - * TODO This is just layout for now. 95 - */ 96 - export function MessagesConversationSettingsScreen({route}: Props) { 97 - const {gtTablet} = useBreakpoints() 98 - 99 - const convoId = route.params.conversation 100 - 101 - return ( 102 - <Layout.Screen> 103 - <Layout.Header.Outer> 104 - <Layout.Header.BackButton /> 105 - <Layout.Header.Content align={gtTablet ? 'left' : 'platform'}> 106 - <Layout.Header.TitleText> 107 - <Trans>Group chat settings</Trans> 108 - </Layout.Header.TitleText> 109 - </Layout.Header.Content> 110 - <Layout.Header.Slot /> 111 - </Layout.Header.Outer> 112 - <ConvoProvider key={convoId} convoId={convoId}> 113 - <SettingsInner convoId={convoId} /> 114 - </ConvoProvider> 115 - </Layout.Screen> 116 - ) 117 - } 118 - 119 - function keyExtractor(item: Item) { 120 - return item.type === 'CHAT_MEMBER' ? item.profile.did : item.type 121 - } 122 - 123 - function SettingsInner({convoId}: {convoId: string}) { 124 - const {t: l} = useLingui() 125 - 126 - const initialNumToRender = useInitialNumToRender({minItemHeight: 68}) 127 - const bottomBarOffset = useBottomBarOffset() 128 - 129 - const convoState = useConvo() 130 - const {currentAccount} = useSession() 131 - 132 - const convo = convoState.convo 133 - ? parseConvoView(convoState.convo, currentAccount?.did) 134 - : null 135 - const primaryMember = convo?.primaryMember 136 - const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did 137 - 138 - const data: bsky.profile.AnyProfileView[] = convo?.members ?? [] 139 - const invites: string[] = [] 140 - 141 - const {data: joinRequestsData, hasNextPage: hasMoreRequests} = 142 - useListJoinRequestsQuery({ 143 - convoId, 144 - enabled: isOwner, 145 - }) 146 - const requestCount = 147 - joinRequestsData?.pages.reduce( 148 - (sum, page) => sum + page.requests.length, 149 - 0, 150 - ) ?? 0 151 - 152 - const items = [ 153 - { 154 - type: 'MEMBERS_AND_REQUESTS', 155 - }, 156 - { 157 - type: 'ADD_MEMBERS_LINK', 158 - }, 159 - ...[...data] 160 - .sort((a, b) => { 161 - const aIsOwner = a.did === primaryMember?.did 162 - const bIsOwner = b.did === primaryMember?.did 163 - const aIsSelf = a.did === currentAccount?.did 164 - const bIsSelf = b.did === currentAccount?.did 165 - if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1 166 - if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1 167 - return 0 168 - }) 169 - .map(profile => ({ 170 - type: 'CHAT_MEMBER', 171 - profile, 172 - status: 173 - primaryMember?.did === profile.did 174 - ? 'owner' 175 - : invites.includes(profile.did) 176 - ? 'invited' 177 - : 'member', 178 - })), 179 - ] 180 - 181 - function renderItem({item}: {item: Item}) { 182 - switch (item.type) { 183 - case 'MEMBERS_AND_REQUESTS': 184 - return ( 185 - <MembersAndRequests 186 - memberCount={data.length} 187 - requestCount={requestCount} 188 - hasMoreRequests={!!hasMoreRequests} 189 - isOwner={isOwner} 190 - /> 191 - ) 192 - case 'ADD_MEMBERS_LINK': 193 - return <AddMembersLink isOwner={isOwner} /> 194 - case 'CHAT_MEMBER': 195 - return ( 196 - <Member 197 - profile={item.profile} 198 - status={item.status} 199 - isOwner={isOwner} 200 - /> 201 - ) 202 - default: 203 - return null 204 - } 205 - } 206 - 207 - if (convoState.status === ConvoStatus.Error) { 208 - return ( 209 - <> 210 - <Error 211 - title={l`Something went wrong`} 212 - message={l`We couldn’t load this conversation’s settings`} 213 - onRetry={() => convoState.error.retry()} 214 - sideBorders={false} 215 - /> 216 - </> 217 - ) 218 - } 219 - 220 - return ( 221 - <List 222 - data={items} 223 - contentContainerStyle={{paddingBottom: bottomBarOffset + ROW_SPACING}} 224 - desktopFixedHeight 225 - initialNumToRender={initialNumToRender} 226 - keyExtractor={keyExtractor} 227 - ListHeaderComponent={ 228 - convo ? ( 229 - <SettingsHeader convo={convo} isOwner={isOwner} /> 230 - ) : ( 231 - <SettingsHeaderPlaceholder /> 232 - ) 233 - } 234 - renderItem={renderItem} 235 - sideBorders={false} 236 - windowSize={11} 237 - onEndReachedThreshold={IS_NATIVE ? 1.5 : 0} 238 - /> 239 - ) 240 - } 241 - 242 - function MembersAndRequests({ 243 - memberCount, 244 - requestCount, 245 - hasMoreRequests, 246 - isOwner, 247 - }: { 248 - memberCount: number 249 - requestCount: number 250 - hasMoreRequests: boolean 251 - isOwner: boolean 252 - }) { 253 - const t = useTheme() 254 - const {t: l} = useLingui() 255 - 256 - return ( 257 - <View style={[a.flex_row, a.justify_between, a.mx_xl, a.mt_lg, a.mb_sm]}> 258 - <View style={[a.flex_row, a.align_center]}> 259 - <Text style={[a.text_lg, a.font_semi_bold, t.atoms.text]}> 260 - <Trans>Members</Trans>{' '} 261 - </Text> 262 - <Text 263 - style={[a.text_xs, a.font_medium, {color: t.palette.contrast_500}]}> 264 - {l({ 265 - message: `${memberCount}/${MEMBER_LIMIT}`, 266 - comment: 267 - 'The number of group chat members out of the total number of permitted users.', 268 - })} 269 - </Text> 270 - </View> 271 - {isOwner && requestCount > 0 ? ( 272 - <InlineLinkText 273 - label={l`View incoming group chat requests`} 274 - style={[a.text_sm, a.text_right, a.font_semi_bold]} 275 - to="#"> 276 - {hasMoreRequests 277 - ? l({ 278 - message: `${requestCount}+ requests`, 279 - comment: 280 - 'Displayed when there are more than 50 requests to join a group chat', 281 - }) 282 - : l({ 283 - message: `${plural(requestCount, { 284 - one: '# request', 285 - other: '# requests', 286 - })}`, 287 - comment: 'The number of requests to join a group chat.', 288 - })} 289 - </InlineLinkText> 290 - ) : null} 291 - </View> 292 - ) 293 - } 294 - 295 - function AddMembersLink({isOwner}: {isOwner: boolean}) { 296 - const t = useTheme() 297 - const {t: l} = useLingui() 298 - 299 - const addMembersControl = Dialog.useDialogControl() 300 - 301 - if (!isOwner) { 302 - return null 303 - } 304 - 305 - return ( 306 - <> 307 - <SubtleHoverWrapper> 308 - <View 309 - style={[ 310 - a.mx_xl, 311 - { 312 - marginTop: ROW_SPACING, 313 - marginBottom: ROW_SPACING, 314 - }, 315 - ]}> 316 - <Pressable 317 - accessibilityRole="button" 318 - style={({pressed}) => [ 319 - a.flex_row, 320 - a.align_center, 321 - a.justify_between, 322 - pressed && web({outline: 'none'}), 323 - ]} 324 - onPress={() => addMembersControl.open()}> 325 - {({pressed}) => ( 326 - <> 327 - <View> 328 - <View style={[a.flex_row, a.align_center]}> 329 - <View 330 - style={[ 331 - a.flex_row, 332 - a.align_center, 333 - a.justify_center, 334 - a.p_lg, 335 - a.rounded_full, 336 - pressed 337 - ? t.atoms.bg_contrast_100 338 - : t.atoms.bg_contrast_50, 339 - { 340 - height: 48, 341 - width: 48, 342 - }, 343 - ]}> 344 - <PlusIcon 345 - style={[t.atoms.text_contrast_high]} 346 - size="sm" 347 - /> 348 - </View> 349 - <Text 350 - style={[ 351 - a.text_md, 352 - a.font_semi_bold, 353 - a.pl_sm, 354 - t.atoms.text, 355 - ]}> 356 - <Trans>Add members</Trans> 357 - </Text> 358 - </View> 359 - </View> 360 - <ChevronIcon style={[t.atoms.text_contrast_medium]} size="md" /> 361 - </> 362 - )} 363 - </Pressable> 364 - </View> 365 - </SubtleHoverWrapper> 366 - 367 - <Dialog.Outer 368 - control={addMembersControl} 369 - testID="addChatMembersDialog" 370 - nativeOptions={{fullHeight: true}}> 371 - <Dialog.Handle /> 372 - <AddMembersFlow 373 - title={l`Add members`} 374 - onAddMembers={(_dids: string[]) => { 375 - // TODO Add members here 376 - addMembersControl.close() 377 - }} 378 - /> 379 - </Dialog.Outer> 380 - </> 381 - ) 382 - } 383 - 384 - function Member({ 385 - profile, 386 - status, 387 - isOwner, 388 - }: { 389 - profile: Shadow<bsky.profile.AnyProfileView> 390 - status: 'owner' | 'member' | 'invited' 391 - isOwner: boolean 392 - }) { 393 - const navigation = useNavigation<NavigationProp>() 394 - const t = useTheme() 395 - const {t: l} = useLingui() 396 - 397 - const {currentAccount} = useSession() 398 - const moderationOpts = useModerationOpts() 399 - const moderation = useMemo( 400 - () => 401 - moderationOpts ? moderateProfile(profile, moderationOpts) : undefined, 402 - [profile, moderationOpts], 403 - ) 404 - 405 - if (!moderation) return null 406 - 407 - const isDeletedAccount = profile.handle === 'missing.invalid' 408 - const displayName = isDeletedAccount 409 - ? l`Deleted Account` 410 - : sanitizeDisplayName( 411 - profile.displayName || profile.handle, 412 - moderation.ui('displayName'), 413 - ) 414 - 415 - let statusBadge: React.ReactNode | null = null 416 - if (currentAccount?.did === profile.did) { 417 - switch (status) { 418 - case 'owner': 419 - statusBadge = <StatusBadge label={l`Admin`} /> 420 - break 421 - } 422 - } else { 423 - statusBadge = ( 424 - <MemberMenu profile={profile} type={status} isOwner={isOwner} /> 425 - ) 426 - } 427 - 428 - return ( 429 - <SubtleHoverWrapper> 430 - <Pressable 431 - accessibilityRole="button" 432 - style={[ 433 - a.mx_xl, 434 - { 435 - marginTop: ROW_SPACING, 436 - marginBottom: ROW_SPACING, 437 - }, 438 - ]} 439 - onPress={() => { 440 - navigation.navigate('Profile', {name: profile.did}) 441 - }}> 442 - <View style={[a.flex_row, a.align_center, a.justify_between]}> 443 - <View style={[a.flex_row, a.align_center]}> 444 - <PreviewableUserAvatar 445 - profile={profile} 446 - size={48} 447 - moderation={moderation.ui('avatar')} 448 - /> 449 - <View style={[a.mx_sm]}> 450 - <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> 451 - {displayName} 452 - </Text> 453 - <Text 454 - style={[ 455 - a.text_xs, 456 - {color: t.palette.contrast_500}, 457 - web(a.pt_2xs), 458 - ]}> 459 - {sanitizeHandle(profile.handle, '@')} 460 - </Text> 461 - </View> 462 - </View> 463 - <View>{statusBadge}</View> 464 - </View> 465 - </Pressable> 466 - </SubtleHoverWrapper> 467 - ) 468 - } 469 - 470 - function StatusBadge({ 471 - label, 472 - style, 473 - }: { 474 - label: string 475 - style?: StyleProp<ViewStyle> 476 - }) { 477 - const t = useTheme() 478 - 479 - return ( 480 - <View 481 - style={[ 482 - a.rounded_xs, 483 - t.atoms.bg_contrast_50, 484 - { 485 - paddingTop: 3, 486 - paddingBottom: 3, 487 - paddingLeft: 6, 488 - paddingRight: 6, 489 - }, 490 - style, 491 - ]}> 492 - <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}> 493 - {label} 494 - </Text> 495 - </View> 496 - ) 497 - } 498 - 499 - function StatusButton({ 500 - label, 501 - style, 502 - ...rest 503 - }: { 504 - label: string 505 - style?: StyleProp<ViewStyle> 506 - } & TriggerChildProps['props']) { 507 - const t = useTheme() 508 - 509 - return ( 510 - <Pressable 511 - style={[ 512 - a.rounded_xs, 513 - t.atoms.bg_contrast_50, 514 - { 515 - paddingTop: 3, 516 - paddingBottom: 3, 517 - paddingLeft: 6, 518 - paddingRight: 6, 519 - }, 520 - style, 521 - ]} 522 - {...rest}> 523 - <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}> 524 - {label} 525 - </Text> 526 - </Pressable> 527 - ) 528 - } 529 - 530 - function MemberMenu({ 531 - profile, 532 - type, 533 - isOwner, 534 - }: { 535 - profile: Shadow<bsky.profile.AnyProfileView> 536 - type: 'owner' | 'member' | 'invited' 537 - isOwner: boolean 538 - }) { 539 - const navigation = useNavigation<NavigationProp>() 540 - const t = useTheme() 541 - const {t: l} = useLingui() 542 - const ax = useAnalytics() 543 - 544 - const requireEmailVerification = useRequireEmailVerification() 545 - 546 - const blockMemberPrompt = Prompt.usePromptControl() 547 - 548 - const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) 549 - const {mutate: initiateConvo} = useGetConvoForMembers({ 550 - onSuccess: ({convo}) => { 551 - ax.metric('chat:open', {logContext: 'ProfileHeader'}) 552 - navigation.navigate('MessagesConversation', {conversation: convo.id}) 553 - }, 554 - onError: () => { 555 - Toast.show(l`Failed to create conversation`) 556 - }, 557 - }) 558 - const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 559 - 560 - const messageMember = () => { 561 - if (!convoAvailability?.canChat) { 562 - return 563 - } 564 - 565 - if (convoAvailability.convo) { 566 - ax.metric('chat:open', {logContext: 'ProfileHeader'}) 567 - navigation.navigate('MessagesConversation', { 568 - conversation: convoAvailability.convo.id, 569 - }) 570 - } else { 571 - ax.metric('chat:create', {logContext: 'ProfileHeader'}) 572 - initiateConvo([profile.did]) 573 - } 574 - } 575 - 576 - const handleMessageMember = requireEmailVerification(messageMember, { 577 - instructions: [ 578 - <Trans key="message"> 579 - Before you can message another user, you must first verify your email. 580 - </Trans>, 581 - ], 582 - }) 583 - 584 - const handleBlockMember = async () => { 585 - if (profile.viewer?.blocking) { 586 - try { 587 - await queueUnblock() 588 - Toast.show(l({message: 'Account unblocked', context: 'toast'})) 589 - } catch (err) { 590 - const e = err as Error 591 - if (e?.name !== 'AbortError') { 592 - ax.logger.error('Failed to unblock account', {message: e}) 593 - Toast.show(l`There was an issue! ${e.toString()}`, { 594 - type: 'error', 595 - }) 596 - } 597 - } 598 - } else { 599 - try { 600 - await queueBlock() 601 - Toast.show(l({message: 'Account blocked', context: 'toast'})) 602 - } catch (err) { 603 - const e = err as Error 604 - if (e?.name !== 'AbortError') { 605 - ax.logger.error('Failed to block account', {message: e}) 606 - Toast.show(l`There was an issue! ${e.toString()}`, { 607 - type: 'error', 608 - }) 609 - } 610 - } 611 - } 612 - } 613 - 614 - const moderationOpts = useModerationOpts() 615 - const moderation = useMemo( 616 - () => 617 - moderationOpts ? moderateProfile(profile, moderationOpts) : undefined, 618 - [profile, moderationOpts], 619 - ) 620 - 621 - if (!moderation) return null 622 - 623 - const isDeletedAccount = profile.handle === 'missing.invalid' 624 - const displayName = isDeletedAccount 625 - ? l`Deleted Account` 626 - : sanitizeDisplayName( 627 - profile.displayName || profile.handle, 628 - moderation.ui('displayName'), 629 - ) 630 - 631 - return ( 632 - <> 633 - <Menu.Root> 634 - <Menu.Trigger label={l`Open chat member options for ${displayName}`}> 635 - {({props, state, control: menuControl}) => 636 - type === 'owner' || type === 'invited' ? ( 637 - <StatusButton 638 - {...props} 639 - label={type === 'owner' ? l`Admin` : l`Invited`} 640 - style={[ 641 - state.hovered || state.pressed || menuControl.isOpen 642 - ? { 643 - backgroundColor: t.palette.contrast_0, 644 - } 645 - : null, 646 - ]} 647 - /> 648 - ) : ( 649 - <Pressable 650 - {...props} 651 - style={[ 652 - a.rounded_full, 653 - a.p_sm, 654 - state.hovered || state.pressed || menuControl.isOpen 655 - ? { 656 - backgroundColor: t.palette.contrast_0, 657 - } 658 - : null, 659 - ]}> 660 - <EllipsisIcon 661 - style={[t.atoms.text_contrast_medium]} 662 - size="md" 663 - /> 664 - </Pressable> 665 - ) 666 - } 667 - </Menu.Trigger> 668 - <Menu.Outer> 669 - <Menu.Group> 670 - <Menu.Item 671 - label={l`View ${displayName}’s profile`} 672 - onPress={() => { 673 - navigation.navigate('Profile', {name: profile.did}) 674 - }}> 675 - <Menu.ItemText> 676 - <Trans>Go to profile</Trans> 677 - </Menu.ItemText> 678 - <Menu.ItemIcon icon={PersonIcon} /> 679 - </Menu.Item> 680 - <Menu.Item 681 - label={l`Message ${displayName}`} 682 - onPress={handleMessageMember}> 683 - <Menu.ItemText> 684 - <Trans context="action">Message</Trans> 685 - </Menu.ItemText> 686 - <Menu.ItemIcon icon={MessageIcon} /> 687 - </Menu.Item> 688 - </Menu.Group> 689 - <Menu.Divider /> 690 - <Menu.Group> 691 - {type === 'owner' || type === 'member' ? ( 692 - <Menu.Item 693 - label={ 694 - profile.viewer?.blocking 695 - ? l`Unblock ${displayName}` 696 - : l`Block ${displayName}` 697 - } 698 - onPress={() => blockMemberPrompt.open()}> 699 - <Menu.ItemText> 700 - <Trans>Block</Trans> 701 - </Menu.ItemText> 702 - <Menu.ItemIcon icon={PersonXIcon} /> 703 - </Menu.Item> 704 - ) : null} 705 - {isOwner ? ( 706 - <Menu.Item 707 - label={l`Remove ${displayName} from this group chat`} 708 - onPress={() => {}}> 709 - <Menu.ItemText> 710 - <Trans>Remove from chat</Trans> 711 - </Menu.ItemText> 712 - <Menu.ItemIcon icon={ArrowBoxLeftIcon} /> 713 - </Menu.Item> 714 - ) : null} 715 - {isOwner && type === 'invited' ? ( 716 - <Menu.Item 717 - label={l`Uninvite ${displayName} from this group chat`} 718 - onPress={() => {}}> 719 - <Menu.ItemText> 720 - <Trans>Uninvite</Trans> 721 - </Menu.ItemText> 722 - <Menu.ItemIcon icon={ArrowBoxLeftIcon} /> 723 - </Menu.Item> 724 - ) : null} 725 - </Menu.Group> 726 - </Menu.Outer> 727 - </Menu.Root> 728 - <BlockMemberPrompt 729 - control={blockMemberPrompt} 730 - onConfirm={() => void handleBlockMember()} 731 - /> 732 - </> 733 - ) 734 - } 735 - 736 - function SettingsHeader({ 737 - convo, 738 - isOwner, 739 - }: { 740 - convo: ConvoWithDetails 741 - isOwner: boolean 742 - }) { 743 - const t = useTheme() 744 - const {t: l} = useLingui() 745 - 746 - const navigation = useNavigation<NavigationProp>() 747 - 748 - const groupName = convo.kind === 'group' ? convo.details.name : '' 749 - const [newGroupName, setNewGroupName] = useState(groupName) 750 - 751 - const [isLocked, setIsLocked] = useState(false) 752 - 753 - const {mutate: editGroupName} = useEditGroupName(convo.view.id, { 754 - onError: e => { 755 - setNewGroupName(groupName) 756 - logger.error('Failed to edit group chat name', {message: e}) 757 - Toast.show(l`Failed to edit group chat name`, { 758 - type: 'error', 759 - }) 760 - }, 761 - }) 762 - 763 - const {mutate: muteConvo} = useMuteConvo(convo.view.id, { 764 - onSuccess: data => { 765 - if (data.convo.muted) { 766 - Toast.show(l({message: 'Group chat muted', context: 'toast'})) 767 - } else { 768 - Toast.show(l({message: 'Group chat unmuted', context: 'toast'})) 769 - } 770 - }, 771 - onError: e => { 772 - logger.error('Failed to mute group chat', {message: e}) 773 - Toast.show(l`Failed to mute group chat`, { 774 - type: 'error', 775 - }) 776 - }, 777 - }) 778 - 779 - const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, { 780 - onMutate: () => { 781 - navigation.dispatch(StackActions.pop(2)) 782 - }, 783 - onError: e => { 784 - logger.error('Failed to leave group chat', {message: e}) 785 - Toast.show(l({message: 'Failed to leave group chat', context: 'toast'}), { 786 - type: 'error', 787 - }) 788 - }, 789 - }) 790 - 791 - const editNamePrompt = Prompt.usePromptControl() 792 - const inviteLinkPrompt = Prompt.usePromptControl() 793 - const lockChatPrompt = Prompt.usePromptControl() 794 - const leaveChatPrompt = Prompt.usePromptControl() 795 - 796 - const handleToggleMute = () => { 797 - muteConvo({mute: !convo.view.muted}) 798 - } 799 - 800 - const handleLeaveChat = () => { 801 - leaveChatPrompt.open() 802 - } 803 - 804 - const handleReportChat = () => {} 805 - 806 - const handlePromptName = () => { 807 - editNamePrompt.open() 808 - } 809 - 810 - const handleEditName = () => { 811 - editGroupName({name: newGroupName}) 812 - editNamePrompt.close() 813 - } 814 - 815 - const handlePromptInviteLink = () => { 816 - inviteLinkPrompt.open() 817 - } 818 - 819 - const handleConfirmInviteLink = () => { 820 - inviteLinkPrompt.close() 821 - } 822 - 823 - const handlePromptLock = () => { 824 - lockChatPrompt.open() 825 - } 826 - 827 - const handleConfirmLock = () => { 828 - setIsLocked(true) 829 - } 830 - 831 - const handleUnlock = () => { 832 - setIsLocked(false) 833 - } 834 - 835 - return ( 836 - <> 837 - <View 838 - style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}> 839 - <View style={[a.align_center, a.justify_center]}> 840 - <AvatarBubbles profiles={convo.members} /> 841 - </View> 842 - <Text 843 - style={[ 844 - a.text_2xl, 845 - a.font_bold, 846 - a.text_center, 847 - a.pt_lg, 848 - t.atoms.text, 849 - ]}> 850 - {groupName} 851 - </Text> 852 - <Text 853 - style={[ 854 - a.text_sm, 855 - a.text_center, 856 - a.pt_xs, 857 - a.px_xl, 858 - t.atoms.text_contrast_high, 859 - ]}> 860 - Created April 2, 2026 861 - </Text> 862 - <View 863 - style={[ 864 - a.flex_row, 865 - a.align_center, 866 - a.justify_center, 867 - a.gap_2xl, 868 - a.pt_2xl, 869 - ]}> 870 - <SettingsButton 871 - color={convo.view.muted ? 'negative_subtle' : 'secondary'} 872 - icon={convo.view.muted ? BellOffIcon : BellIcon} 873 - label={ 874 - convo.view.muted 875 - ? l`Unmute this group chat` 876 - : l`Mute this group chat` 877 - } 878 - text={convo.view.muted ? l`Muted` : l`Mute`} 879 - onPress={handleToggleMute} 880 - /> 881 - {isOwner ? ( 882 - <SettingsButton 883 - icon={EditIcon} 884 - label={l`Edit this group chat’s name`} 885 - text={l`Edit name`} 886 - onPress={handlePromptName} 887 - /> 888 - ) : null} 889 - <SettingsButton 890 - icon={ChainLinkIcon} 891 - label={l`Create an invite link for this group chat`} 892 - text={l`Invite link`} 893 - onPress={handlePromptInviteLink} 894 - /> 895 - {isOwner ? ( 896 - <SettingsButton 897 - color={isLocked ? 'negative_subtle' : 'secondary'} 898 - icon={LockIcon} 899 - label={ 900 - isLocked ? l`Unlock this group chat` : l`Lock this group chat` 901 - } 902 - text={isLocked ? l`Locked` : l`Lock`} 903 - onPress={isLocked ? handleUnlock : handlePromptLock} 904 - /> 905 - ) : null} 906 - {isOwner ? null : ( 907 - <SettingsButton 908 - color="secondary" 909 - icon={FlagIcon} 910 - label={l`Report this group chat`} 911 - text={l`Report`} 912 - onPress={handleReportChat} 913 - /> 914 - )} 915 - {isOwner ? null : ( 916 - <SettingsButton 917 - color="secondary" 918 - icon={ArrowBoxLeftIcon} 919 - label={l`Leave this group chat`} 920 - text={l`Leave`} 921 - onPress={handleLeaveChat} 922 - /> 923 - )} 924 - </View> 925 - </View> 926 - <EditNamePrompt 927 - control={editNamePrompt} 928 - value={newGroupName} 929 - onChangeText={setNewGroupName} 930 - onConfirm={handleEditName} 931 - /> 932 - <InviteLinkPrompt 933 - control={inviteLinkPrompt} 934 - onConfirm={handleConfirmInviteLink} 935 - /> 936 - <LockChatPrompt control={lockChatPrompt} onConfirm={handleConfirmLock} /> 937 - <LeaveChatPrompt 938 - control={leaveChatPrompt} 939 - groupName={groupName} 940 - onConfirm={leaveConvo} 941 - /> 942 - </> 943 - ) 944 - } 945 - 946 - function SettingsHeaderPlaceholder() { 947 - const t = useTheme() 948 - 949 - return ( 950 - <View style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}> 951 - <View style={[a.align_center, a.justify_center]}> 952 - <AvatarBubbles profiles={[]} /> 953 - </View> 954 - <Text 955 - style={[a.text_2xl, a.font_bold, a.text_center, a.pt_lg, t.atoms.text]}> 956 - 957 - </Text> 958 - <Text 959 - style={[ 960 - a.text_sm, 961 - a.text_center, 962 - a.pt_xs, 963 - a.px_xl, 964 - t.atoms.text_contrast_high, 965 - ]}> 966 - <Trans>…</Trans> 967 - </Text> 968 - <View 969 - style={[ 970 - a.flex_row, 971 - a.align_center, 972 - a.justify_center, 973 - a.gap_2xl, 974 - a.pt_2xl, 975 - ]}> 976 - <SettingsButtonPlaceholder /> 977 - <SettingsButtonPlaceholder /> 978 - <SettingsButtonPlaceholder /> 979 - <SettingsButtonPlaceholder /> 980 - </View> 981 - </View> 982 - ) 983 - } 984 - 985 - function SettingsButton({ 986 - color = 'secondary', 987 - icon, 988 - label, 989 - text, 990 - onPress, 991 - }: { 992 - color?: ButtonColor 993 - icon: React.ComponentType<SVGIconProps> 994 - label: string 995 - text: string 996 - onPress: () => void 997 - }) { 998 - const t = useTheme() 999 - 1000 - return ( 1001 - <View> 1002 - <Button 1003 - color={color} 1004 - size="large" 1005 - shape="round" 1006 - label={label} 1007 - onPress={onPress}> 1008 - <ButtonIcon icon={icon} size="md" /> 1009 - </Button> 1010 - <Text 1011 - numberOfLines={1} 1012 - style={[ 1013 - a.text_2xs, 1014 - a.font_medium, 1015 - a.text_center, 1016 - a.pt_xs, 1017 - t.atoms.text, 1018 - ]}> 1019 - {text} 1020 - </Text> 1021 - </View> 1022 - ) 1023 - } 1024 - 1025 - function SettingsButtonPlaceholder() { 1026 - const t = useTheme() 1027 - const {t: l} = useLingui() 1028 - 1029 - return ( 1030 - <View> 1031 - <Button color="secondary" size="large" shape="round" label={l`Loading…`}> 1032 - <ButtonIcon icon={EllipsisIcon} size="md" /> 1033 - </Button> 1034 - <Text 1035 - numberOfLines={1} 1036 - style={[ 1037 - a.text_2xs, 1038 - a.font_medium, 1039 - a.text_center, 1040 - a.pt_xs, 1041 - t.atoms.text, 1042 - ]}> 1043 - 1044 - </Text> 1045 - </View> 1046 - ) 1047 - } 1048 - 1049 - function EditNamePrompt({ 1050 - control, 1051 - value, 1052 - onChangeText, 1053 - onConfirm, 1054 - }: { 1055 - control: Dialog.DialogOuterProps['control'] 1056 - value: string 1057 - onChangeText: (value: string) => void 1058 - onConfirm: () => void 1059 - }) { 1060 - const {t: l} = useLingui() 1061 - 1062 - return ( 1063 - <Prompt.Outer control={control}> 1064 - <> 1065 - <Prompt.Content> 1066 - <Prompt.TitleText> 1067 - <Trans>Edit group name</Trans> 1068 - </Prompt.TitleText> 1069 - <View style={[a.my_sm]}> 1070 - <TextField.Root isInvalid={false}> 1071 - <TextField.Input 1072 - label={l`Edit group name`} 1073 - placeholder={l`Group name`} 1074 - value={value} 1075 - onChangeText={onChangeText} 1076 - returnKeyType="done" 1077 - autoCapitalize="none" 1078 - autoComplete="off" 1079 - autoCorrect={false} 1080 - autoFocus 1081 - onSubmitEditing={onConfirm} 1082 - /> 1083 - </TextField.Root> 1084 - </View> 1085 - </Prompt.Content> 1086 - <Prompt.Actions> 1087 - <Prompt.Action 1088 - cta={l`Save`} 1089 - shouldCloseOnPress={false} 1090 - onPress={onConfirm} 1091 - /> 1092 - <Prompt.Cancel /> 1093 - </Prompt.Actions> 1094 - </> 1095 - </Prompt.Outer> 1096 - ) 1097 - } 1098 - 1099 - function InviteLinkPrompt({ 1100 - control, 1101 - onConfirm, 1102 - }: { 1103 - control: Dialog.DialogOuterProps['control'] 1104 - onConfirm: () => void 1105 - }) { 1106 - const {t: l} = useLingui() 1107 - 1108 - return ( 1109 - <Prompt.Basic 1110 - control={control} 1111 - title={l`Invite link`} 1112 - 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.`} 1113 - confirmButtonCta={l`Get started`} 1114 - cancelButtonCta={l`Cancel`} 1115 - onConfirm={onConfirm} 1116 - /> 1117 - ) 1118 - } 1119 - 1120 - function LockChatPrompt({ 1121 - control, 1122 - onConfirm, 1123 - }: { 1124 - control: Dialog.DialogOuterProps['control'] 1125 - onConfirm: () => void 1126 - }) { 1127 - const {t: l} = useLingui() 1128 - 1129 - return ( 1130 - <Prompt.Basic 1131 - control={control} 1132 - title={l`Lock group chat?`} 1133 - description={l`Members can still read chat history but can’t send new messages.`} 1134 - confirmButtonCta={l`Lock group chat`} 1135 - cancelButtonCta={l`Cancel`} 1136 - onConfirm={onConfirm} 1137 - /> 1138 - ) 1139 - } 1140 - 1141 - function LeaveChatPrompt({ 1142 - control, 1143 - groupName, 1144 - onConfirm, 1145 - }: { 1146 - control: Dialog.DialogOuterProps['control'] 1147 - groupName: string 1148 - onConfirm: () => void 1149 - }) { 1150 - const {t: l} = useLingui() 1151 - 1152 - return ( 1153 - <Prompt.Basic 1154 - control={control} 1155 - title={l`Are you sure you want to leave ${groupName}?`} 1156 - description={l`You won’t be able to rejoin unless you’re invited.`} 1157 - confirmButtonCta={l`Leave group chat`} 1158 - confirmButtonColor="negative" 1159 - cancelButtonCta={l`Cancel`} 1160 - onConfirm={onConfirm} 1161 - /> 1162 - ) 1163 - } 1164 - 1165 - function BlockMemberPrompt({ 1166 - control, 1167 - onConfirm, 1168 - }: { 1169 - control: Dialog.DialogOuterProps['control'] 1170 - onConfirm: () => void 1171 - }) { 1172 - const {t: l} = useLingui() 1173 - 1174 - return ( 1175 - <Prompt.Basic 1176 - control={control} 1177 - title={l`Block account?`} 1178 - description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`} 1179 - onConfirm={onConfirm} 1180 - confirmButtonCta={l`Block`} 1181 - confirmButtonColor="negative" 1182 - /> 1183 - ) 1184 - } 1185 - 1186 - function SubtleHoverWrapper({children}: React.PropsWithChildren<unknown>) { 1187 - const { 1188 - state: hover, 1189 - onIn: onHoverIn, 1190 - onOut: onHoverOut, 1191 - } = useInteractionState() 1192 - 1193 - return ( 1194 - <View 1195 - onPointerEnter={onHoverIn} 1196 - onPointerLeave={onHoverOut} 1197 - style={a.pointer}> 1198 - <SubtleHover hover={hover} /> 1199 - {children} 1200 - </View> 1201 - ) 1202 - }
+106
src/screens/Messages/ConversationSettings/AddMembersLink.tsx
··· 1 + import {View} from 'react-native' 2 + import {Trans, useLingui} from '@lingui/react/macro' 3 + 4 + import {logger} from '#/logger' 5 + import {useAddGroupMembers} from '#/state/queries/messages/add-group-members' 6 + import {atoms as a, useTheme} from '#/alf' 7 + import {Button} from '#/components/Button' 8 + import * as Dialog from '#/components/Dialog' 9 + import {AddMembersFlow} from '#/components/dms/AddMembersFlow' 10 + import {type ConvoWithDetails} from '#/components/dms/util' 11 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron' 12 + import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 13 + import {Loader} from '#/components/Loader' 14 + import * as Toast from '#/components/Toast' 15 + import {Text} from '#/components/Typography' 16 + 17 + export function AddMembersLink({ 18 + convo, 19 + }: { 20 + convo: Extract<ConvoWithDetails, {kind: 'group'}> 21 + }) { 22 + const t = useTheme() 23 + const {t: l} = useLingui() 24 + 25 + const addMembersControl = Dialog.useDialogControl() 26 + 27 + const convoId = convo.view.id 28 + const {mutate: addGroupMembers, isPending: isAddPending} = useAddGroupMembers( 29 + convoId, 30 + { 31 + onSuccess: () => { 32 + addMembersControl.close() 33 + }, 34 + onError: e => { 35 + logger.error('Failed to add group chat members', {message: e}) 36 + Toast.show(l`Failed to add members`, {type: 'error'}) 37 + }, 38 + }, 39 + ) 40 + 41 + return ( 42 + <> 43 + <Button 44 + disabled={isAddPending} 45 + label={l`Add members`} 46 + onPress={addMembersControl.open}> 47 + {({interacting}) => ( 48 + <View 49 + style={[ 50 + a.w_full, 51 + a.flex_row, 52 + a.align_center, 53 + a.justify_between, 54 + a.px_xl, 55 + a.py_sm, 56 + interacting ? [t.atoms.bg_contrast_25] : [], 57 + ]}> 58 + <View style={[a.flex_row, a.align_center]}> 59 + <View 60 + style={[ 61 + a.flex_row, 62 + a.align_center, 63 + a.justify_center, 64 + a.p_lg, 65 + a.rounded_full, 66 + interacting 67 + ? t.atoms.bg_contrast_100 68 + : t.atoms.bg_contrast_50, 69 + { 70 + height: 48, 71 + width: 48, 72 + }, 73 + ]}> 74 + <PlusIcon style={[t.atoms.text_contrast_high]} size="sm" /> 75 + </View> 76 + <Text 77 + numberOfLines={1} 78 + style={[a.text_md, a.font_semi_bold, a.mx_sm, t.atoms.text]}> 79 + <Trans>Add members</Trans> 80 + </Text> 81 + </View> 82 + {isAddPending ? ( 83 + <Loader size="md" /> 84 + ) : ( 85 + <ChevronIcon style={[t.atoms.text_contrast_medium]} size="md" /> 86 + )} 87 + </View> 88 + )} 89 + </Button> 90 + 91 + <Dialog.Outer 92 + control={addMembersControl} 93 + testID="addChatMembersDialog" 94 + nativeOptions={{fullHeight: true}}> 95 + <Dialog.Handle /> 96 + <AddMembersFlow 97 + convo={convo} 98 + title={l`Add members`} 99 + onAddMembers={(members, profiles) => { 100 + addGroupMembers({members, profiles}) 101 + }} 102 + /> 103 + </Dialog.Outer> 104 + </> 105 + ) 106 + }
+129
src/screens/Messages/ConversationSettings/Member.tsx
··· 1 + import {View} from 'react-native' 2 + import {moderateProfile} from '@atproto/api' 3 + import {useLingui} from '@lingui/react/macro' 4 + 5 + import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' 6 + import {useProfileShadow} from '#/state/cache/profile-shadow' 7 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 8 + import {useSession} from '#/state/session' 9 + import {atoms as a, native, useTheme, web} from '#/alf' 10 + import { 11 + type ConvoWithDetails, 12 + type GroupConvoMember, 13 + } from '#/components/dms/util' 14 + import * as ProfileCard from '#/components/ProfileCard' 15 + import {Text} from '#/components/Typography' 16 + import {MemberMenu} from './MemberMenu' 17 + import {StatusBadge} from './StatusBadge' 18 + import {SubtleHoverWrapper} from './SubtleHoverWrapper' 19 + 20 + const outerStyles = [a.px_xl, a.py_sm, a.flex_row, a.align_center, a.gap_sm] 21 + 22 + export function Member({ 23 + convo, 24 + profile: profileUnshadowed, 25 + status, 26 + isOwner, 27 + }: { 28 + convo: ConvoWithDetails 29 + profile: GroupConvoMember 30 + status: 'owner' | 'standard' | 'invited' 31 + isOwner: boolean 32 + }) { 33 + const t = useTheme() 34 + const {t: l} = useLingui() 35 + 36 + const profile = useProfileShadow(profileUnshadowed) 37 + const {currentAccount} = useSession() 38 + const moderationOpts = useModerationOpts() 39 + 40 + if (!moderationOpts) { 41 + return <MemberPlaceholder /> 42 + } 43 + 44 + const moderation = moderateProfile(profile, moderationOpts) 45 + 46 + const isDeletedAccount = profile.handle === 'missing.invalid' 47 + const displayName = isDeletedAccount 48 + ? l`Deleted Account` 49 + : createSanitizedDisplayName(profile, true, moderation.ui('displayName')) 50 + const isProfileOwner = profile.did === convo.primaryMember.did 51 + const isSelf = currentAccount?.did === profile.did 52 + let statusBadge: React.ReactNode | null = null 53 + if (isSelf) { 54 + if (status === 'owner') { 55 + statusBadge = <StatusBadge label={l`Admin`} /> 56 + } 57 + } else { 58 + statusBadge = ( 59 + <MemberMenu 60 + convo={convo} 61 + profile={profile} 62 + displayName={displayName} 63 + type={status} 64 + isOwner={isOwner} 65 + /> 66 + ) 67 + } 68 + 69 + const joinedReason = profile.kind?.addedBy 70 + ? l`Added by ${createSanitizedDisplayName( 71 + profile.kind.addedBy, 72 + true, 73 + moderateProfile(profile.kind.addedBy, moderationOpts).ui('displayName'), 74 + )}` 75 + : `Added by invite link` 76 + 77 + return ( 78 + <SubtleHoverWrapper> 79 + <View style={outerStyles}> 80 + <ProfileCard.Link profile={profile} style={[a.flex_1]}> 81 + <ProfileCard.Outer> 82 + <ProfileCard.Header> 83 + <ProfileCard.Avatar 84 + size={48} 85 + profile={profile} 86 + moderationOpts={moderationOpts} 87 + /> 88 + <View style={[a.flex_1]}> 89 + <ProfileCard.Name 90 + profile={profile} 91 + moderationOpts={moderationOpts} 92 + /> 93 + <ProfileCard.Handle 94 + profile={profile} 95 + textStyle={[a.text_xs, native({top: -1})]} 96 + /> 97 + {!isProfileOwner && ( 98 + <Text 99 + style={[ 100 + a.text_xs, 101 + a.leading_snug, 102 + t.atoms.text_contrast_medium, 103 + web(a.pt_2xs), 104 + ]}> 105 + {joinedReason} 106 + </Text> 107 + )} 108 + </View> 109 + </ProfileCard.Header> 110 + </ProfileCard.Outer> 111 + </ProfileCard.Link> 112 + {statusBadge} 113 + </View> 114 + </SubtleHoverWrapper> 115 + ) 116 + } 117 + 118 + export function MemberPlaceholder() { 119 + return ( 120 + <View style={outerStyles}> 121 + <ProfileCard.Outer> 122 + <ProfileCard.Header> 123 + <ProfileCard.AvatarPlaceholder size={48} /> 124 + <ProfileCard.NameAndHandlePlaceholder /> 125 + </ProfileCard.Header> 126 + </ProfileCard.Outer> 127 + </View> 128 + ) 129 + }
+252
src/screens/Messages/ConversationSettings/MemberMenu.tsx
··· 1 + import {useState} from 'react' 2 + import {Pressable} from 'react-native' 3 + import {Trans, useLingui} from '@lingui/react/macro' 4 + import {useNavigation} from '@react-navigation/native' 5 + 6 + import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 7 + import {type NavigationProp} from '#/lib/routes/types' 8 + import {logger} from '#/logger' 9 + import {type Shadow} from '#/state/cache/types' 10 + import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' 11 + import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' 12 + import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group' 13 + import {useProfileBlockMutationQueue} from '#/state/queries/profile' 14 + import {atoms as a, useTheme} from '#/alf' 15 + import {type ConvoWithDetails} from '#/components/dms/util' 16 + import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' 17 + import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' 18 + import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' 19 + import { 20 + Person_Stroke2_Corner2_Rounded as PersonIcon, 21 + PersonX_Stroke2_Corner0_Rounded as PersonXIcon, 22 + } from '#/components/icons/Person' 23 + import * as Menu from '#/components/Menu' 24 + import * as Prompt from '#/components/Prompt' 25 + import * as Toast from '#/components/Toast' 26 + import {useAnalytics} from '#/analytics' 27 + import type * as bsky from '#/types/bsky' 28 + import {BlockMemberPrompt} from './prompts' 29 + import {StatusBadge} from './StatusBadge' 30 + 31 + export function MemberMenu({ 32 + convo, 33 + profile, 34 + displayName, 35 + type, 36 + isOwner, 37 + }: { 38 + convo: ConvoWithDetails 39 + profile: Shadow<bsky.profile.AnyProfileView> 40 + type: 'owner' | 'standard' | 'invited' 41 + displayName: string 42 + isOwner: boolean 43 + }) { 44 + const navigation = useNavigation<NavigationProp>() 45 + const t = useTheme() 46 + const {t: l} = useLingui() 47 + const ax = useAnalytics() 48 + 49 + const requireEmailVerification = useRequireEmailVerification() 50 + 51 + const blockMemberPrompt = Prompt.usePromptControl() 52 + 53 + const [menuDidOpen, setMenuDidOpen] = useState(false) 54 + const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, { 55 + enabled: menuDidOpen, 56 + }) 57 + const {mutate: initiateConvo} = useGetConvoForMembers({ 58 + onSuccess: ({convo}) => { 59 + ax.metric('chat:open', {logContext: 'ConvoSettings'}) 60 + navigation.navigate('MessagesConversation', {conversation: convo.id}) 61 + }, 62 + onError: () => { 63 + Toast.show(l`Failed to create conversation`, {type: 'error'}) 64 + }, 65 + }) 66 + const convoId = convo.view.id 67 + const {mutate: removeMembers} = useRemoveFromGroupChat(convoId, { 68 + onError: e => { 69 + logger.error('Failed to remove group chat member', {message: e}) 70 + Toast.show(l`Failed to remove group chat member`, {type: 'error'}) 71 + }, 72 + }) 73 + const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 74 + 75 + const messageMember = () => { 76 + if (!convoAvailability?.canChat) { 77 + return 78 + } 79 + 80 + if (convoAvailability.convo) { 81 + ax.metric('chat:open', {logContext: 'ConvoSettings'}) 82 + navigation.navigate('MessagesConversation', { 83 + conversation: convoAvailability.convo.id, 84 + }) 85 + } else { 86 + ax.metric('chat:create', {logContext: 'ConvoSettings'}) 87 + initiateConvo([profile.did]) 88 + } 89 + } 90 + 91 + const handleMessageMember = requireEmailVerification(messageMember, { 92 + instructions: [ 93 + <Trans key="message"> 94 + Before you can message another user, you must first verify your email. 95 + </Trans>, 96 + ], 97 + }) 98 + 99 + const handleBlockMember = async () => { 100 + if (profile.viewer?.blocking) { 101 + try { 102 + await queueUnblock() 103 + Toast.show(l({message: 'Account unblocked', context: 'toast'})) 104 + } catch (err) { 105 + const e = err as Error 106 + if (e?.name !== 'AbortError') { 107 + logger.error('Failed to unblock account', {message: e}) 108 + Toast.show(l`There was an issue! ${e.toString()}`, { 109 + type: 'error', 110 + }) 111 + } 112 + } 113 + } else { 114 + try { 115 + await queueBlock() 116 + Toast.show(l({message: 'Account blocked', context: 'toast'})) 117 + } catch (err) { 118 + const e = err as Error 119 + if (e?.name !== 'AbortError') { 120 + logger.error('Failed to block account', {message: e}) 121 + Toast.show(l`There was an issue! ${e.toString()}`, { 122 + type: 'error', 123 + }) 124 + } 125 + } 126 + } 127 + } 128 + 129 + const canBlockMember = type === 'owner' || type === 'standard' 130 + const canRemoveMember = isOwner && type !== 'invited' 131 + // TODO Need to integrate this. -dsb 132 + const canUninviteMember = false 133 + // const canUninviteMember = isOwner && type === 'invited' 134 + 135 + return ( 136 + <> 137 + <Menu.Root> 138 + <Menu.Trigger label={l`Open chat member options for ${displayName}`}> 139 + {({props, state, control: menuControl}) => { 140 + const isActive = 141 + state.hovered || state.pressed || menuControl.isOpen 142 + const triggerProps = { 143 + ...props, 144 + onPress: () => { 145 + setMenuDidOpen(true) 146 + props.onPress() 147 + }, 148 + } 149 + return type === 'owner' || type === 'invited' ? ( 150 + <StatusBadge 151 + label={type === 'owner' ? l`Admin` : l`Invited`} 152 + pressableProps={triggerProps} 153 + style={[ 154 + isActive 155 + ? { 156 + backgroundColor: t.palette.contrast_0, 157 + } 158 + : null, 159 + ]} 160 + /> 161 + ) : ( 162 + <Pressable 163 + {...triggerProps} 164 + style={[ 165 + a.rounded_full, 166 + a.p_sm, 167 + isActive 168 + ? { 169 + backgroundColor: t.palette.contrast_0, 170 + } 171 + : null, 172 + ]}> 173 + <EllipsisIcon 174 + style={[t.atoms.text_contrast_medium]} 175 + size="md" 176 + /> 177 + </Pressable> 178 + ) 179 + }} 180 + </Menu.Trigger> 181 + <Menu.Outer> 182 + <Menu.Group> 183 + <Menu.Item 184 + label={l`View ${displayName}’s profile`} 185 + onPress={() => { 186 + navigation.navigate('Profile', {name: profile.did}) 187 + }}> 188 + <Menu.ItemText> 189 + <Trans>Go to profile</Trans> 190 + </Menu.ItemText> 191 + <Menu.ItemIcon icon={PersonIcon} /> 192 + </Menu.Item> 193 + <Menu.Item 194 + label={l`Message ${displayName}`} 195 + onPress={handleMessageMember}> 196 + <Menu.ItemText> 197 + <Trans context="action">Message</Trans> 198 + </Menu.ItemText> 199 + <Menu.ItemIcon icon={MessageIcon} /> 200 + </Menu.Item> 201 + </Menu.Group> 202 + <Menu.Divider /> 203 + <Menu.Group> 204 + {canBlockMember ? ( 205 + <Menu.Item 206 + label={ 207 + profile.viewer?.blocking 208 + ? l`Unblock ${displayName}` 209 + : l`Block ${displayName}` 210 + } 211 + onPress={ 212 + profile.viewer?.blocking 213 + ? handleBlockMember 214 + : blockMemberPrompt.open 215 + }> 216 + <Menu.ItemText> 217 + <Trans>Block</Trans> 218 + </Menu.ItemText> 219 + <Menu.ItemIcon icon={PersonXIcon} /> 220 + </Menu.Item> 221 + ) : null} 222 + {canRemoveMember ? ( 223 + <Menu.Item 224 + label={l`Remove ${displayName} from this group chat`} 225 + onPress={() => removeMembers({members: [profile.did]})}> 226 + <Menu.ItemText> 227 + <Trans>Remove from chat</Trans> 228 + </Menu.ItemText> 229 + <Menu.ItemIcon icon={ArrowBoxLeftIcon} /> 230 + </Menu.Item> 231 + ) : null} 232 + {canUninviteMember ? ( 233 + <Menu.Item 234 + label={l`Uninvite ${displayName} from this group chat`} 235 + // TODO Need to wire up the uninvite flow. -dsb 236 + onPress={() => {}}> 237 + <Menu.ItemText> 238 + <Trans>Uninvite</Trans> 239 + </Menu.ItemText> 240 + <Menu.ItemIcon icon={ArrowBoxLeftIcon} /> 241 + </Menu.Item> 242 + ) : null} 243 + </Menu.Group> 244 + </Menu.Outer> 245 + </Menu.Root> 246 + <BlockMemberPrompt 247 + control={blockMemberPrompt} 248 + onConfirm={() => void handleBlockMember()} 249 + /> 250 + </> 251 + ) 252 + }
+65
src/screens/Messages/ConversationSettings/MembersAndRequests.tsx
··· 1 + import {View} from 'react-native' 2 + import {plural} from '@lingui/core/macro' 3 + import {Trans, useLingui} from '@lingui/react/macro' 4 + 5 + import {atoms as a, useTheme} from '#/alf' 6 + import {InlineLinkText} from '#/components/Link' 7 + import {Text} from '#/components/Typography' 8 + import {MEMBER_LIMIT} from './constants' 9 + 10 + export function MembersAndRequests({ 11 + memberCount, 12 + requestCount, 13 + hasMoreRequests, 14 + isOwner, 15 + }: { 16 + memberCount: number 17 + requestCount: number 18 + hasMoreRequests: boolean 19 + isOwner: boolean 20 + }) { 21 + const t = useTheme() 22 + const {t: l} = useLingui() 23 + 24 + return ( 25 + <View style={[a.flex_row, a.justify_between, a.px_xl, a.pt_xl, a.pb_sm]}> 26 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 27 + <Text style={[a.text_lg, a.font_semi_bold, t.atoms.text]}> 28 + <Trans>Members</Trans> 29 + </Text> 30 + <View 31 + style={[a.px_xs, a.py_2xs, t.atoms.bg_contrast_50, a.rounded_full]}> 32 + <Text 33 + style={[a.text_xs, a.font_medium, {color: t.palette.contrast_500}]}> 34 + {l({ 35 + message: `${memberCount}/${MEMBER_LIMIT}`, 36 + comment: 37 + 'The number of group chat members out of the total number of permitted users.', 38 + })} 39 + </Text> 40 + </View> 41 + </View> 42 + {isOwner && requestCount > 0 ? ( 43 + <InlineLinkText 44 + label={l`View incoming group chat requests`} 45 + style={[a.text_sm, a.text_right, a.font_semi_bold]} 46 + // TODO Need to implement this. -dsb 47 + to="#"> 48 + {hasMoreRequests 49 + ? l({ 50 + message: `${requestCount}+ requests`, 51 + comment: 52 + 'Displayed when there are more than 50 requests to join a group chat', 53 + }) 54 + : l({ 55 + message: plural(requestCount, { 56 + one: '# request', 57 + other: '# requests', 58 + }), 59 + comment: 'The number of requests to join a group chat.', 60 + })} 61 + </InlineLinkText> 62 + ) : null} 63 + </View> 64 + ) 65 + }
+44
src/screens/Messages/ConversationSettings/StatusBadge.tsx
··· 1 + import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' 2 + 3 + import {atoms as a, useTheme} from '#/alf' 4 + import {type TriggerChildProps} from '#/components/Menu/types' 5 + import {Text} from '#/components/Typography' 6 + 7 + export function StatusBadge({ 8 + label, 9 + style, 10 + pressableProps, 11 + }: { 12 + label: string 13 + style?: StyleProp<ViewStyle> 14 + pressableProps?: TriggerChildProps['props'] 15 + }) { 16 + const t = useTheme() 17 + 18 + const badgeStyle = [ 19 + a.rounded_xs, 20 + t.atoms.bg_contrast_50, 21 + { 22 + paddingTop: 3, 23 + paddingBottom: 3, 24 + paddingLeft: 6, 25 + paddingRight: 6, 26 + }, 27 + style, 28 + ] 29 + 30 + const labelText = ( 31 + <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}> 32 + {label} 33 + </Text> 34 + ) 35 + 36 + if (pressableProps) { 37 + return ( 38 + <Pressable style={badgeStyle} {...pressableProps}> 39 + {labelText} 40 + </Pressable> 41 + ) 42 + } 43 + return <View style={badgeStyle}>{labelText}</View> 44 + }
+27
src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx
··· 1 + import {View} from 'react-native' 2 + 3 + import {atoms as a} from '#/alf' 4 + import {useInteractionState} from '#/components/hooks/useInteractionState' 5 + import {SubtleHover} from '#/components/SubtleHover' 6 + 7 + export function SubtleHoverWrapper({ 8 + children, 9 + }: React.PropsWithChildren<unknown>) { 10 + const { 11 + state: hover, 12 + onIn: onHoverIn, 13 + onOut: onHoverOut, 14 + } = useInteractionState() 15 + 16 + return ( 17 + <View 18 + // Web-only 19 + onPointerEnter={onHoverIn} 20 + // Web-only 21 + onPointerLeave={onHoverOut} 22 + style={a.pointer}> 23 + <SubtleHover hover={hover} /> 24 + {children} 25 + </View> 26 + ) 27 + }
+1
src/screens/Messages/ConversationSettings/constants.ts
··· 1 + export const MEMBER_LIMIT = 50
+632
src/screens/Messages/ConversationSettings/index.tsx
··· 1 + import {useState} from 'react' 2 + import {View} from 'react-native' 3 + import {type ChatBskyConvoDefs} from '@atproto/api' 4 + import {Trans, useLingui} from '@lingui/react/macro' 5 + import {StackActions, useNavigation} from '@react-navigation/native' 6 + 7 + import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' 8 + import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 9 + import { 10 + type CommonNavigatorParams, 11 + type NativeStackScreenProps, 12 + type NavigationProp, 13 + } from '#/lib/routes/types' 14 + import {logger} from '#/logger' 15 + import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo' 16 + import {ConvoStatus} from '#/state/messages/convo/types' 17 + import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name' 18 + import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' 19 + import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members' 20 + import {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests' 21 + import {useLockConvo} from '#/state/queries/messages/lock-conversation' 22 + import {useMuteConvo} from '#/state/queries/messages/mute-conversation' 23 + import {useSession} from '#/state/session' 24 + import {List} from '#/view/com/util/List' 25 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 26 + import {AvatarBubbles} from '#/components/AvatarBubbles' 27 + import {Button, type ButtonColor, ButtonIcon} from '#/components/Button' 28 + import * as Dialog from '#/components/Dialog' 29 + import { 30 + type ConvoWithDetails, 31 + type GroupConvoMember, 32 + } from '#/components/dms/util' 33 + import {Error} from '#/components/Error' 34 + import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' 35 + import { 36 + Bell2_Stroke2_Corner0_Rounded as BellIcon, 37 + Bell2Off_Stroke2_Corner0_Rounded as BellOffIcon, 38 + } from '#/components/icons/Bell2' 39 + import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' 40 + import {type Props as SVGIconProps} from '#/components/icons/common' 41 + import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' 42 + import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig' 43 + import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' 44 + import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' 45 + import * as Layout from '#/components/Layout' 46 + import {Loader} from '#/components/Loader' 47 + import * as Prompt from '#/components/Prompt' 48 + import * as Toast from '#/components/Toast' 49 + import {Text} from '#/components/Typography' 50 + import {InviteLinkDialog} from '../components/InviteLinkDialog' 51 + import {AddMembersLink} from './AddMembersLink' 52 + import {Member, MemberPlaceholder} from './Member' 53 + import {MembersAndRequests} from './MembersAndRequests' 54 + import {EditNamePrompt, LeaveChatPrompt, LockChatPrompt} from './prompts' 55 + 56 + const dateFormatter = new Intl.DateTimeFormat(undefined, { 57 + month: 'long', 58 + day: 'numeric', 59 + year: 'numeric', 60 + }) 61 + 62 + type Item = 63 + | {type: 'MEMBERS_AND_REQUESTS'; key: string} 64 + | {type: 'ADD_MEMBERS_LINK'; key: string} 65 + | { 66 + type: 'CHAT_MEMBER' 67 + key: string 68 + profile: GroupConvoMember 69 + status: 'owner' | 'standard' | 'invited' 70 + } 71 + | { 72 + type: 'CHAT_MEMBER_PLACEHOLDER' 73 + key: string 74 + } 75 + 76 + type Props = NativeStackScreenProps< 77 + CommonNavigatorParams, 78 + 'MessagesConversationSettings' 79 + > 80 + 81 + export function MessagesConversationSettingsScreen({route}: Props) { 82 + const {gtTablet} = useBreakpoints() 83 + 84 + const convoId = route.params.conversation 85 + 86 + return ( 87 + <Layout.Screen> 88 + <Layout.Header.Outer> 89 + <Layout.Header.BackButton /> 90 + <Layout.Header.Content align={gtTablet ? 'left' : 'platform'}> 91 + <Layout.Header.TitleText> 92 + <Trans>Group chat settings</Trans> 93 + </Layout.Header.TitleText> 94 + </Layout.Header.Content> 95 + <Layout.Header.Slot /> 96 + </Layout.Header.Outer> 97 + <ConvoProvider key={convoId} convoId={convoId}> 98 + <SettingsInner /> 99 + </ConvoProvider> 100 + </Layout.Screen> 101 + ) 102 + } 103 + 104 + function SettingsInner() { 105 + const {t: l} = useLingui() 106 + const convoState = useConvo() 107 + const navigation = useNavigation<NavigationProp>() 108 + 109 + if (convoState.status === ConvoStatus.Error) { 110 + return ( 111 + <Error 112 + title={l`Something went wrong`} 113 + message={l`We couldn’t load this conversation’s settings`} 114 + onRetry={() => convoState.error.retry()} 115 + sideBorders={false} 116 + /> 117 + ) 118 + } 119 + 120 + if (!isConvoActive(convoState)) { 121 + return ( 122 + <Layout.Content> 123 + <View style={[a.align_center, a.justify_center, a.flex_1, a.py_4xl]}> 124 + <Loader size="xl" /> 125 + </View> 126 + </Layout.Content> 127 + ) 128 + } 129 + 130 + if (convoState.convo?.kind !== 'group') { 131 + return ( 132 + <Error 133 + title={l`Wrong kind of conversation`} 134 + message={l`This screen is only available for group conversations.`} 135 + onGoBack={() => { 136 + if (navigation.canGoBack()) { 137 + navigation.goBack() 138 + } else { 139 + navigation.replace('Messages', {animation: 'pop'}) 140 + } 141 + }} 142 + /> 143 + ) 144 + } 145 + 146 + return <GroupSettings convo={convoState.convo} /> 147 + } 148 + 149 + function keyExtractor(item: Item) { 150 + return item.key 151 + } 152 + 153 + function GroupSettings({ 154 + convo, 155 + }: { 156 + convo: Extract<ConvoWithDetails, {kind: 'group'}> 157 + }) { 158 + const initialNumToRender = useInitialNumToRender({minItemHeight: 68}) 159 + const bottomBarOffset = useBottomBarOffset() 160 + 161 + const {currentAccount} = useSession() 162 + 163 + const primaryMember = convo?.primaryMember 164 + const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did 165 + 166 + const {data: memberListData = [], isPending} = useListConvoMembersQuery({ 167 + convoId: convo.view.id, 168 + placeholderData: convo?.members, 169 + }) 170 + 171 + // TODO Need this data in order to populate this array. -dsb 172 + const invites: string[] = [] 173 + 174 + const {data: joinRequestsData, hasNextPage: hasMoreRequests} = 175 + useListJoinRequestsQuery({ 176 + convoId: convo.view.id, 177 + enabled: isOwner, 178 + }) 179 + const requestCount = 180 + joinRequestsData?.pages.reduce( 181 + (sum, page) => sum + page.requests.length, 182 + 0, 183 + ) ?? 0 184 + 185 + const items: Item[] = [ 186 + { 187 + type: 'MEMBERS_AND_REQUESTS', 188 + key: 'members-and-requests', 189 + }, 190 + ...(isOwner 191 + ? [{type: 'ADD_MEMBERS_LINK', key: 'add-members-link'} as const] 192 + : []), 193 + ] 194 + if (isPending) { 195 + // should never be pending if we correctly set the query cache data 196 + Array.from({length: 5}).forEach((_, i) => 197 + items.push({ 198 + type: 'CHAT_MEMBER_PLACEHOLDER', 199 + key: `chat-member-placeholder-${i}`, 200 + }), 201 + ) 202 + } else { 203 + items.push( 204 + ...memberListData 205 + .sort((a, b) => { 206 + const aIsOwner = a.did === primaryMember?.did 207 + const bIsOwner = b.did === primaryMember?.did 208 + const aIsSelf = a.did === currentAccount?.did 209 + const bIsSelf = b.did === currentAccount?.did 210 + if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1 211 + if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1 212 + return 0 213 + }) 214 + .map( 215 + (profile): Item => ({ 216 + type: 'CHAT_MEMBER', 217 + key: profile.did, 218 + profile: profile as GroupConvoMember, 219 + status: 220 + primaryMember?.did === profile.did 221 + ? 'owner' 222 + : invites.includes(profile.did) 223 + ? 'invited' 224 + : 'standard', 225 + }), 226 + ), 227 + ) 228 + } 229 + 230 + function renderItem({item}: {item: Item}) { 231 + switch (item.type) { 232 + case 'MEMBERS_AND_REQUESTS': 233 + return ( 234 + <MembersAndRequests 235 + memberCount={convo.details.memberCount} 236 + requestCount={requestCount} 237 + hasMoreRequests={!!hasMoreRequests} 238 + isOwner={isOwner} 239 + /> 240 + ) 241 + case 'ADD_MEMBERS_LINK': 242 + return convo ? <AddMembersLink convo={convo} /> : null 243 + case 'CHAT_MEMBER': 244 + return convo ? ( 245 + <Member 246 + convo={convo} 247 + profile={item.profile} 248 + status={item.status} 249 + isOwner={isOwner} 250 + /> 251 + ) : null 252 + case 'CHAT_MEMBER_PLACEHOLDER': 253 + return <MemberPlaceholder /> 254 + default: 255 + return null 256 + } 257 + } 258 + 259 + return ( 260 + <List 261 + data={items} 262 + contentContainerStyle={{ 263 + paddingBottom: bottomBarOffset + a.pb_xl.paddingBottom, 264 + }} 265 + desktopFixedHeight 266 + initialNumToRender={initialNumToRender} 267 + keyExtractor={keyExtractor} 268 + ListHeaderComponent={ 269 + convo?.kind === 'group' ? ( 270 + <SettingsHeader convo={convo} isOwner={isOwner} /> 271 + ) : ( 272 + <SettingsHeaderPlaceholder /> 273 + ) 274 + } 275 + renderItem={renderItem} 276 + sideBorders={false} 277 + windowSize={11} 278 + /> 279 + ) 280 + } 281 + 282 + function SettingsHeader({ 283 + convo, 284 + isOwner, 285 + }: { 286 + convo: Extract<ConvoWithDetails, {kind: 'group'}> 287 + isOwner: boolean 288 + }) { 289 + const t = useTheme() 290 + const {t: l} = useLingui() 291 + 292 + const navigation = useNavigation<NavigationProp>() 293 + 294 + const groupName = convo.details.name 295 + const [newGroupName, setNewGroupName] = useState(groupName) 296 + 297 + const lockStatus = convo.details.lockStatus 298 + 299 + // TODO Enable this once the feature is working end-to-end. -dsb 300 + // const {joinLink} = convo.details 301 + const isJoinLinkEnabled = false 302 + // const isJoinLinkEnabled = 303 + // isOwner || (!isOwner && joinLink?.enabledStatus === 'enabled') 304 + 305 + // TODO Enable this once the feature is working end-to-end. -dsb 306 + const isReportLinkEnabled = false 307 + 308 + const {mutate: editGroupName} = useEditGroupChatName(convo.view.id, { 309 + onError: e => { 310 + setNewGroupName(groupName) 311 + logger.error('Failed to edit group chat name', {message: e}) 312 + Toast.show(l`Failed to edit group chat name`, {type: 'error'}) 313 + }, 314 + }) 315 + 316 + const {mutate: muteConvo} = useMuteConvo(convo.view.id, { 317 + onSuccess: data => { 318 + if (data.convo.muted) { 319 + Toast.show(l({message: 'Group chat muted', context: 'toast'})) 320 + } else { 321 + Toast.show(l({message: 'Group chat unmuted', context: 'toast'})) 322 + } 323 + }, 324 + onError: e => { 325 + logger.error('Failed to mute group chat', {message: e}) 326 + Toast.show(l`Failed to mute group chat`, {type: 'error'}) 327 + }, 328 + }) 329 + 330 + const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, { 331 + onSuccess: () => { 332 + // Settings > Chat > Chat list 333 + navigation.dispatch(StackActions.pop(2)) 334 + }, 335 + onError: e => { 336 + logger.error('Failed to leave group chat', {message: e}) 337 + Toast.show(l({message: 'Failed to leave group chat', context: 'toast'}), { 338 + type: 'error', 339 + }) 340 + }, 341 + }) 342 + 343 + const {mutate: lockConvo} = useLockConvo(convo.view.id, { 344 + onSuccess: data => { 345 + const kind = data.convo.kind as ChatBskyConvoDefs.GroupConvo 346 + if (kind.lockStatus === 'locked') { 347 + Toast.show(l({message: 'Group chat locked', context: 'toast'})) 348 + } else { 349 + Toast.show(l({message: 'Group chat unlocked', context: 'toast'})) 350 + } 351 + }, 352 + onError: (e, {lock}) => { 353 + if (lock) { 354 + logger.error('Failed to lock group chat', {message: e}) 355 + Toast.show(l`Failed to lock group chat`, {type: 'error'}) 356 + } else { 357 + logger.error('Failed to unlock group chat', {message: e}) 358 + Toast.show(l`Failed to unlock group chat`, {type: 'error'}) 359 + } 360 + }, 361 + }) 362 + 363 + const inviteLinkDialog = Dialog.useDialogControl() 364 + const editNamePrompt = Prompt.usePromptControl() 365 + const lockChatPrompt = Prompt.usePromptControl() 366 + const leaveChatPrompt = Prompt.usePromptControl() 367 + 368 + const handleToggleMute = () => { 369 + muteConvo({mute: !convo.view.muted}) 370 + } 371 + 372 + // TODO Need to implement this when the backend is ready. -dsb 373 + const handleReportChat = () => {} 374 + 375 + const handlePromptName = () => { 376 + setNewGroupName(groupName) 377 + editNamePrompt.open() 378 + } 379 + 380 + const handleEditName = () => { 381 + editGroupName({name: newGroupName}) 382 + } 383 + 384 + const handleConfirmLock = () => { 385 + lockConvo({lock: true}) 386 + } 387 + 388 + const handleUnlock = () => { 389 + lockConvo({lock: false}) 390 + } 391 + 392 + // TODO The creation date doesn't exist yet. -dsb 393 + const showCreatedAt = false 394 + const createdAt = new Date() 395 + 396 + const canLockGroupChat = isOwner && lockStatus !== 'locked-permanently' 397 + 398 + return ( 399 + <> 400 + <View 401 + style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}> 402 + <View style={[a.align_center, a.justify_center]}> 403 + <AvatarBubbles profiles={convo.members} /> 404 + </View> 405 + <Text 406 + style={[ 407 + a.text_2xl, 408 + a.font_bold, 409 + a.text_center, 410 + a.pt_lg, 411 + t.atoms.text, 412 + ]}> 413 + {groupName} 414 + </Text> 415 + {showCreatedAt ? ( 416 + <Text 417 + style={[ 418 + a.text_sm, 419 + a.text_center, 420 + a.pt_xs, 421 + a.px_xl, 422 + t.atoms.text_contrast_high, 423 + ]}> 424 + <Trans>Created {dateFormatter.format(createdAt)}</Trans> 425 + </Text> 426 + ) : null} 427 + <View 428 + style={[ 429 + a.flex_row, 430 + a.align_center, 431 + a.justify_center, 432 + a.gap_2xl, 433 + a.pt_2xl, 434 + ]}> 435 + <SettingsButton 436 + color={convo.view.muted ? 'negative_subtle' : 'secondary'} 437 + icon={convo.view.muted ? BellOffIcon : BellIcon} 438 + label={ 439 + convo.view.muted 440 + ? l`Unmute this group chat` 441 + : l`Mute this group chat` 442 + } 443 + text={convo.view.muted ? l`Muted` : l`Mute`} 444 + onPress={handleToggleMute} 445 + /> 446 + {isOwner ? ( 447 + <SettingsButton 448 + icon={EditIcon} 449 + label={l`Edit this group chat’s name`} 450 + text={l`Edit name`} 451 + onPress={handlePromptName} 452 + /> 453 + ) : null} 454 + {isJoinLinkEnabled ? ( 455 + <SettingsButton 456 + icon={ChainLinkIcon} 457 + label={ 458 + isOwner 459 + ? l`Create or modify an invite link for this group chat` 460 + : l`View the invite link for this group chat` 461 + } 462 + text={l`Invite link`} 463 + onPress={inviteLinkDialog.open} 464 + /> 465 + ) : null} 466 + {canLockGroupChat ? ( 467 + <SettingsButton 468 + color={lockStatus === 'locked' ? 'negative_subtle' : 'secondary'} 469 + icon={LockIcon} 470 + label={ 471 + lockStatus === 'locked' 472 + ? l`Unlock this group chat` 473 + : l`Lock this group chat` 474 + } 475 + text={lockStatus === 'locked' ? l`Locked` : l`Lock`} 476 + onPress={ 477 + lockStatus === 'locked' ? handleUnlock : lockChatPrompt.open 478 + } 479 + /> 480 + ) : null} 481 + {isOwner ? null : isReportLinkEnabled ? ( 482 + <SettingsButton 483 + color="secondary" 484 + icon={FlagIcon} 485 + label={l`Report this group chat`} 486 + text={l`Report`} 487 + onPress={handleReportChat} 488 + /> 489 + ) : null} 490 + {isOwner ? null : ( 491 + <SettingsButton 492 + color="secondary" 493 + icon={ArrowBoxLeftIcon} 494 + label={l`Leave this group chat`} 495 + text={l`Leave`} 496 + onPress={leaveChatPrompt.open} 497 + /> 498 + )} 499 + </View> 500 + </View> 501 + <EditNamePrompt 502 + control={editNamePrompt} 503 + value={newGroupName} 504 + onChangeText={setNewGroupName} 505 + onConfirm={handleEditName} 506 + /> 507 + <InviteLinkDialog 508 + convo={convo} 509 + control={inviteLinkDialog} 510 + isOwner={isOwner} 511 + /> 512 + <LockChatPrompt control={lockChatPrompt} onConfirm={handleConfirmLock} /> 513 + <LeaveChatPrompt 514 + control={leaveChatPrompt} 515 + groupName={groupName} 516 + onConfirm={leaveConvo} 517 + /> 518 + </> 519 + ) 520 + } 521 + 522 + function SettingsHeaderPlaceholder() { 523 + const t = useTheme() 524 + 525 + return ( 526 + <View style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}> 527 + <View style={[a.align_center, a.justify_center]}> 528 + <AvatarBubbles profiles={[]} /> 529 + </View> 530 + <Text 531 + style={[a.text_2xl, a.font_bold, a.text_center, a.pt_lg, t.atoms.text]}> 532 + 533 + </Text> 534 + <Text 535 + style={[ 536 + a.text_sm, 537 + a.text_center, 538 + a.pt_xs, 539 + a.px_xl, 540 + t.atoms.text_contrast_high, 541 + ]}> 542 + 543 + </Text> 544 + <View 545 + style={[ 546 + a.flex_row, 547 + a.align_center, 548 + a.justify_center, 549 + a.gap_2xl, 550 + a.pt_2xl, 551 + ]}> 552 + <SettingsButtonPlaceholder /> 553 + <SettingsButtonPlaceholder /> 554 + <SettingsButtonPlaceholder /> 555 + <SettingsButtonPlaceholder /> 556 + </View> 557 + </View> 558 + ) 559 + } 560 + 561 + function SettingsButton({ 562 + color = 'secondary', 563 + disabled, 564 + icon, 565 + label, 566 + text, 567 + onPress, 568 + }: { 569 + color?: ButtonColor 570 + disabled?: boolean 571 + icon: React.ComponentType<SVGIconProps> 572 + label: string 573 + text: string 574 + onPress: () => void 575 + }) { 576 + const t = useTheme() 577 + 578 + return ( 579 + <View style={[a.align_center]}> 580 + <Button 581 + color={color} 582 + disabled={disabled} 583 + size="large" 584 + shape="round" 585 + label={label} 586 + onPress={onPress} 587 + style={[ 588 + { 589 + width: 48, 590 + height: 48, 591 + }, 592 + ]}> 593 + <ButtonIcon icon={icon} size="md" /> 594 + </Button> 595 + <Text 596 + numberOfLines={1} 597 + style={[ 598 + a.text_xs, 599 + a.font_medium, 600 + a.text_center, 601 + a.pt_xs, 602 + t.atoms.text_contrast_medium, 603 + ]}> 604 + {text} 605 + </Text> 606 + </View> 607 + ) 608 + } 609 + 610 + function SettingsButtonPlaceholder() { 611 + const t = useTheme() 612 + const {t: l} = useLingui() 613 + 614 + return ( 615 + <View style={[a.align_center]}> 616 + <Button color="secondary" size="large" shape="round" label={l`Loading…`}> 617 + <ButtonIcon icon={EllipsisIcon} size="md" /> 618 + </Button> 619 + <Text 620 + numberOfLines={1} 621 + style={[ 622 + a.text_xs, 623 + a.font_medium, 624 + a.text_center, 625 + a.pt_xs, 626 + t.atoms.text, 627 + ]}> 628 + 629 + </Text> 630 + </View> 631 + ) 632 + }
+119
src/screens/Messages/ConversationSettings/prompts.tsx
··· 1 + import {View} from 'react-native' 2 + import {Trans, useLingui} from '@lingui/react/macro' 3 + 4 + import {atoms as a} from '#/alf' 5 + import type * as Dialog from '#/components/Dialog' 6 + import * as TextField from '#/components/forms/TextField' 7 + import * as Prompt from '#/components/Prompt' 8 + 9 + export function EditNamePrompt({ 10 + control, 11 + value, 12 + onChangeText, 13 + onConfirm, 14 + }: { 15 + control: Dialog.DialogOuterProps['control'] 16 + value: string 17 + onChangeText: (value: string) => void 18 + onConfirm: () => void 19 + }) { 20 + const {t: l} = useLingui() 21 + 22 + return ( 23 + <Prompt.Outer control={control}> 24 + <> 25 + <Prompt.Content> 26 + <Prompt.TitleText> 27 + <Trans>Edit group name</Trans> 28 + </Prompt.TitleText> 29 + <View style={[a.my_sm]}> 30 + <TextField.Root isInvalid={false}> 31 + <TextField.Input 32 + label={l`Edit group name`} 33 + placeholder={l`Group name`} 34 + value={value} 35 + onChangeText={onChangeText} 36 + returnKeyType="done" 37 + autoCapitalize="none" 38 + autoComplete="off" 39 + autoCorrect={false} 40 + autoFocus 41 + onSubmitEditing={onConfirm} 42 + /> 43 + </TextField.Root> 44 + </View> 45 + </Prompt.Content> 46 + <Prompt.Actions> 47 + <Prompt.Action cta={l`Save`} onPress={onConfirm} /> 48 + <Prompt.Cancel /> 49 + </Prompt.Actions> 50 + </> 51 + </Prompt.Outer> 52 + ) 53 + } 54 + 55 + export function LockChatPrompt({ 56 + control, 57 + onConfirm, 58 + }: { 59 + control: Dialog.DialogOuterProps['control'] 60 + onConfirm: () => void 61 + }) { 62 + const {t: l} = useLingui() 63 + 64 + return ( 65 + <Prompt.Basic 66 + control={control} 67 + title={l`Lock group chat?`} 68 + description={l`Members can still read chat history but can’t send new messages.`} 69 + confirmButtonCta={l`Lock group chat`} 70 + cancelButtonCta={l`Cancel`} 71 + onConfirm={onConfirm} 72 + /> 73 + ) 74 + } 75 + 76 + export function LeaveChatPrompt({ 77 + control, 78 + groupName, 79 + onConfirm, 80 + }: { 81 + control: Dialog.DialogOuterProps['control'] 82 + groupName: string 83 + onConfirm: () => void 84 + }) { 85 + const {t: l} = useLingui() 86 + 87 + return ( 88 + <Prompt.Basic 89 + control={control} 90 + title={l`Are you sure you want to leave ${groupName}?`} 91 + description={l`You won’t be able to rejoin unless you’re invited.`} 92 + confirmButtonCta={l`Leave group chat`} 93 + confirmButtonColor="negative" 94 + cancelButtonCta={l`Cancel`} 95 + onConfirm={onConfirm} 96 + /> 97 + ) 98 + } 99 + 100 + export function BlockMemberPrompt({ 101 + control, 102 + onConfirm, 103 + }: { 104 + control: Dialog.DialogOuterProps['control'] 105 + onConfirm: () => void 106 + }) { 107 + const {t: l} = useLingui() 108 + 109 + return ( 110 + <Prompt.Basic 111 + control={control} 112 + title={l`Block account?`} 113 + description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`} 114 + onConfirm={onConfirm} 115 + confirmButtonCta={l`Block`} 116 + confirmButtonColor="negative" 117 + /> 118 + ) 119 + }
+4 -1
src/screens/Messages/components/ChatListItem.tsx
··· 302 302 303 303 // System message 304 304 if (ChatBskyConvoDefs.isSystemMessageView(convo.lastMessage)) { 305 - const info = getSystemMessageInfo(convo.lastMessage.data, convo.members) 305 + const info = getSystemMessageInfo( 306 + convo.lastMessage.data, 307 + new Map(convo.members.map(m => [m.did, m])), 308 + ) 306 309 if (info) { 307 310 lastMessage = i18n._(info.message) 308 311 lastMessageSentAt = convo.lastMessage.sentAt
+7 -9
src/screens/Messages/components/ChatStatusInfo.tsx
··· 5 5 6 6 import {type ActiveConvoStates} from '#/state/messages/convo' 7 7 import {useModerationOpts} from '#/state/preferences/moderation-opts' 8 - import {useSession} from '#/state/session' 9 8 import {atoms as a, useTheme} from '#/alf' 10 9 import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' 11 10 import {KnownFollowers} from '#/components/KnownFollowers' ··· 16 15 const t = useTheme() 17 16 const {_} = useLingui() 18 17 const moderationOpts = useModerationOpts() 19 - const {currentAccount} = useSession() 20 18 const leaveConvoControl = usePromptControl() 21 19 22 20 const onAcceptChat = useCallback(() => { 23 21 convoState.markConvoAccepted() 24 22 }, [convoState]) 25 23 26 - const otherUser = convoState.recipients.find( 27 - user => user.did !== currentAccount?.did, 28 - ) 24 + // either the other person, or the chat owner 25 + // if we ever allow someone other than the owner to invite people, this will need to change 26 + const otherUser = convoState.convo.primaryMember 29 27 30 28 if (!moderationOpts) { 31 29 return null ··· 44 42 {otherUser && ( 45 43 <RejectMenu 46 44 label={_(msg`Block or report`)} 47 - convo={convoState.convo} 45 + convo={convoState.convo.view} 48 46 profile={otherUser} 49 47 color="negative_subtle" 50 48 size="small" ··· 53 51 )} 54 52 <DeleteChatButton 55 53 label={_(msg`Delete`)} 56 - convo={convoState.convo} 54 + convo={convoState.convo.view} 57 55 color="secondary" 58 56 size="small" 59 57 currentScreen="conversation" 60 58 onPress={leaveConvoControl.open} 61 59 /> 62 60 <LeaveConvoPrompt 63 - convoId={convoState.convo.id} 61 + convoId={convoState.convo.view.id} 64 62 control={leaveConvoControl} 65 63 currentScreen="conversation" 66 64 hasMessages={false} ··· 69 67 <View style={[a.w_full, a.flex_row]}> 70 68 <AcceptChatButton 71 69 onAcceptConvo={onAcceptChat} 72 - convo={convoState.convo} 70 + convo={convoState.convo.view} 73 71 color="primary_subtle" 74 72 size="small" 75 73 currentScreen="conversation"
+90
src/screens/Messages/components/CopyTextButton.tsx
··· 1 + import {useCallback, useEffect, useState} from 'react' 2 + import {type GestureResponderEvent, View} from 'react-native' 3 + import Animated, { 4 + FadeOutUp, 5 + useReducedMotion, 6 + ZoomIn, 7 + } from 'react-native-reanimated' 8 + import * as Clipboard from 'expo-clipboard' 9 + import {Trans} from '@lingui/react/macro' 10 + 11 + import {atoms as a, useTheme} from '#/alf' 12 + import {Button, ButtonIcon, type ButtonProps} from '#/components/Button' 13 + import {SquareBehindSquare_Stroke2_Corner2_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4' 14 + import {Text} from '#/components/Typography' 15 + 16 + export function CopyTextButton({ 17 + children, 18 + disabled, 19 + style, 20 + value, 21 + onPress: onPressProp, 22 + ...props 23 + }: ButtonProps & {value: string}) { 24 + const t = useTheme() 25 + 26 + const [hasBeenCopied, setHasBeenCopied] = useState(false) 27 + 28 + const isReducedMotionEnabled = useReducedMotion() 29 + 30 + useEffect(() => { 31 + if (hasBeenCopied) { 32 + const timeout = setTimeout( 33 + () => setHasBeenCopied(false), 34 + isReducedMotionEnabled ? 2000 : 100, 35 + ) 36 + return () => clearTimeout(timeout) 37 + } 38 + }, [hasBeenCopied, isReducedMotionEnabled]) 39 + 40 + const onPress = useCallback( 41 + (evt: GestureResponderEvent) => { 42 + void Clipboard.setStringAsync(value) 43 + setHasBeenCopied(true) 44 + onPressProp?.(evt) 45 + }, 46 + [value, onPressProp], 47 + ) 48 + 49 + return ( 50 + <View style={[a.relative]}> 51 + {hasBeenCopied && ( 52 + <Animated.View 53 + entering={ZoomIn.duration(100)} 54 + exiting={FadeOutUp.duration(2000)} 55 + style={[ 56 + a.absolute, 57 + {bottom: '100%', right: 0}, 58 + a.justify_center, 59 + a.gap_sm, 60 + a.z_10, 61 + a.pb_sm, 62 + ]} 63 + pointerEvents="none"> 64 + <Text 65 + style={[ 66 + a.font_medium, 67 + a.text_right, 68 + a.text_sm, 69 + t.atoms.text_contrast_high, 70 + ]}> 71 + <Trans>Copied!</Trans> 72 + </Text> 73 + </Animated.View> 74 + )} 75 + <Button 76 + color="secondary" 77 + disabled={disabled} 78 + style={[a.flex_1, a.justify_between, {borderRadius: 10}, style]} 79 + onPress={onPress} 80 + {...props}> 81 + {context => ( 82 + <View style={[a.flex_1, a.flex_row, a.justify_between, a.p_md]}> 83 + {typeof children === 'function' ? children(context) : children} 84 + {disabled ? null : <ButtonIcon icon={CopyIcon} size="lg" />} 85 + </View> 86 + )} 87 + </Button> 88 + </View> 89 + ) 90 + }
+59
src/screens/Messages/components/EditTextButton.tsx
··· 1 + import {View} from 'react-native' 2 + import {Trans} from '@lingui/react/macro' 3 + 4 + import {atoms as a, useTheme} from '#/alf' 5 + import {Button, type ButtonProps} from '#/components/Button' 6 + import {Text} from '#/components/Typography' 7 + 8 + export function EditTextButton({ 9 + children, 10 + style, 11 + onPress, 12 + ...props 13 + }: ButtonProps & {value: string}) { 14 + const t = useTheme() 15 + 16 + return ( 17 + <View style={[a.relative]}> 18 + <Button 19 + color="secondary" 20 + style={[ 21 + a.flex_1, 22 + a.justify_between, 23 + a.rounded_full, 24 + a.border, 25 + t.atoms.bg, 26 + t.atoms.border_contrast_low, 27 + style, 28 + ]} 29 + onPress={onPress} 30 + {...props}> 31 + {context => ( 32 + <View 33 + style={[ 34 + a.flex_1, 35 + a.flex_row, 36 + a.align_center, 37 + a.justify_between, 38 + a.px_md, 39 + a.py_sm, 40 + ]}> 41 + {typeof children === 'function' ? children(context) : children} 42 + <View 43 + style={[ 44 + a.ml_sm, 45 + a.rounded_full, 46 + t.atoms.bg_contrast_50, 47 + {paddingHorizontal: 10, paddingVertical: 8}, 48 + ]}> 49 + <Text 50 + style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}> 51 + <Trans>Edit</Trans> 52 + </Text> 53 + </View> 54 + </View> 55 + )} 56 + </Button> 57 + </View> 58 + ) 59 + }
+462
src/screens/Messages/components/InviteLinkDialog.tsx
··· 1 + import {useState} from 'react' 2 + import {View} from 'react-native' 3 + import {Trans, useLingui} from '@lingui/react/macro' 4 + 5 + import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 6 + import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' 7 + import {shareUrl} from '#/lib/sharing' 8 + import {useCreateJoinLink} from '#/state/queries/messages/create-join-link' 9 + import {useDisableJoinLink} from '#/state/queries/messages/disable-join-link' 10 + import {useEditJoinLink} from '#/state/queries/messages/edit-join-link' 11 + import {useEnableJoinLink} from '#/state/queries/messages/enable-join-link' 12 + import {atoms as a, useTheme, web} from '#/alf' 13 + import { 14 + Button, 15 + ButtonIcon, 16 + ButtonText, 17 + StackedButton, 18 + } from '#/components/Button' 19 + import * as Dialog from '#/components/Dialog' 20 + import {type ConvoWithDetails} from '#/components/dms/util' 21 + import * as Toggle from '#/components/forms/Toggle' 22 + import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' 23 + import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight' 24 + import {ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon} from '#/components/icons/ChainLink' 25 + import {EditBig_Stroke2_Corner2_Rounded as EditIcon} from '#/components/icons/EditBig' 26 + import {Loader} from '#/components/Loader' 27 + import * as Toast from '#/components/Toast' 28 + import {Text} from '#/components/Typography' 29 + import {IS_WEB} from '#/env' 30 + import {CopyTextButton} from './CopyTextButton' 31 + import {EditTextButton} from './EditTextButton' 32 + 33 + enum Step { 34 + INFO, 35 + GENERATE, 36 + MANAGE, 37 + } 38 + 39 + const timeFormatter = new Intl.DateTimeFormat(undefined, { 40 + hour: 'numeric', 41 + minute: 'numeric', 42 + }) 43 + const dateFormatter = new Intl.DateTimeFormat(undefined, { 44 + month: 'long', 45 + day: 'numeric', 46 + year: 'numeric', 47 + }) 48 + 49 + export function InviteLinkDialog({ 50 + convo, 51 + control, 52 + isOwner, 53 + }: { 54 + convo: Extract<ConvoWithDetails, {kind: 'group'}> 55 + control: Dialog.DialogOuterProps['control'] 56 + isOwner: boolean 57 + }) { 58 + const t = useTheme() 59 + const {t: l} = useLingui() 60 + 61 + const ownerName = createSanitizedDisplayName(convo.primaryMember) 62 + 63 + const {joinLink} = convo.details 64 + const enabledStatus = joinLink?.enabledStatus 65 + 66 + const defaultStep = joinLink ? Step.MANAGE : Step.INFO 67 + const defaultWhoCanJoin = joinLink 68 + ? [ 69 + `${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`, 70 + ] 71 + : ['anyone'] 72 + 73 + const [step, setStep] = useState<Step>(defaultStep) 74 + const [whoCanJoin, setWhoCanJoin] = useState(defaultWhoCanJoin) 75 + 76 + const {openComposer} = useOpenComposer() 77 + 78 + const {mutate: createJoinLink, isPending: isCreating} = useCreateJoinLink( 79 + convo.view.id, 80 + { 81 + onSuccess: () => { 82 + setStep(Step.MANAGE) 83 + }, 84 + onError: () => { 85 + Toast.show(l`Failed to create invite link`, { 86 + type: 'error', 87 + }) 88 + }, 89 + }, 90 + ) 91 + const {mutate: editJoinLink, isPending: isEditing} = useEditJoinLink( 92 + convo.view.id, 93 + { 94 + onSuccess: () => { 95 + setStep(Step.MANAGE) 96 + }, 97 + onError: () => { 98 + Toast.show(l`Failed to edit invite link`, { 99 + type: 'error', 100 + }) 101 + }, 102 + }, 103 + ) 104 + const {mutate: disableJoinLink, isPending: isDisabling} = useDisableJoinLink( 105 + convo.view.id, 106 + { 107 + onError: () => { 108 + Toast.show(l`Failed to disable invite link`, { 109 + type: 'error', 110 + }) 111 + }, 112 + }, 113 + ) 114 + const {mutate: enableJoinLink, isPending: isEnabling} = useEnableJoinLink( 115 + convo.view.id, 116 + { 117 + onError: () => { 118 + Toast.show(l`Failed to enable invite link`, { 119 + type: 'error', 120 + }) 121 + }, 122 + }, 123 + ) 124 + const isSaving = isCreating || isEditing 125 + 126 + const whoCanJoinOptions = [ 127 + { 128 + name: 'anyone', 129 + owner: l`Anyone can join instantly`, 130 + member: l`Anyone can join instantly`, 131 + }, 132 + { 133 + name: 'anyone:requireApproval', 134 + owner: l`Anyone can request to join`, 135 + member: l`Anyone can request to join`, 136 + }, 137 + { 138 + name: 'followedByOwner', 139 + owner: l`People I follow can join instantly`, 140 + member: l`People ${ownerName} follows can join instantly`, 141 + }, 142 + { 143 + name: 'followedByOwner:requireApproval', 144 + owner: l`People I follow can request to join`, 145 + member: l`People ${ownerName} follows can request to join`, 146 + }, 147 + ] 148 + 149 + let content: React.ReactNode = null 150 + let header: string | null = null 151 + switch (step) { 152 + case Step.INFO: 153 + header = l`Invite link` 154 + content = ( 155 + <> 156 + <View> 157 + <Text style={[a.text_md, t.atoms.text]}> 158 + <Trans> 159 + An invite link lets people join this group chat without being 160 + added directly. You control who can use the link and whether 161 + they need your approval. You can disable the link at any time. 162 + </Trans> 163 + </Text> 164 + <Text style={[a.mt_lg, a.text_md, t.atoms.text]}> 165 + <Trans> 166 + Your name, avatar, and the name of the group chat will be 167 + visible to everyone. 168 + </Trans> 169 + </Text> 170 + </View> 171 + <View style={[a.mt_4xl]}> 172 + <Button 173 + label={l`Get started`} 174 + color="primary" 175 + size="large" 176 + onPress={() => { 177 + setStep(Step.GENERATE) 178 + }}> 179 + <ButtonText> 180 + <Trans>Get started</Trans> 181 + </ButtonText> 182 + <ButtonIcon icon={ArrowRightIcon} /> 183 + </Button> 184 + </View> 185 + </> 186 + ) 187 + break 188 + case Step.GENERATE: 189 + header = l`Generate invite link` 190 + content = ( 191 + <> 192 + <View> 193 + <Text style={[a.text_md, t.atoms.text]}> 194 + <Trans>Choose who can join this group chat and how.</Trans> 195 + </Text> 196 + </View> 197 + <View style={[a.mt_lg]}> 198 + <Toggle.Group 199 + label={l`Who can join this group chat and how`} 200 + type="radio" 201 + values={whoCanJoin} 202 + onChange={setWhoCanJoin}> 203 + <View style={[a.gap_sm]}> 204 + {whoCanJoinOptions.map(option => ( 205 + <Toggle.Item 206 + key={option.name} 207 + highlightRow={true} 208 + label={isOwner ? option.owner : option.member} 209 + name={option.name} 210 + style={[a.flex_1]}> 211 + {({selected}) => ( 212 + <TargetOption 213 + label={isOwner ? option.owner : option.member} 214 + selected={selected} 215 + /> 216 + )} 217 + </Toggle.Item> 218 + ))} 219 + </View> 220 + </Toggle.Group> 221 + </View> 222 + <View style={[a.mt_4xl]}> 223 + <Button 224 + label={l`Generate invite link`} 225 + color="primary" 226 + size="large" 227 + disabled={isSaving} 228 + onPress={() => { 229 + const parts = whoCanJoin[0].split(':') 230 + const joinRule = parts[0] 231 + const requireApproval = parts[1] === 'requireApproval' 232 + if (joinLink && enabledStatus === 'enabled') { 233 + editJoinLink({ 234 + joinRule, 235 + requireApproval, 236 + }) 237 + } else { 238 + createJoinLink({ 239 + joinRule, 240 + requireApproval, 241 + }) 242 + } 243 + }}> 244 + <ButtonText> 245 + {joinLink && enabledStatus === 'enabled' 246 + ? l`Update invite link` 247 + : l`Generate invite link`} 248 + </ButtonText> 249 + <ButtonIcon icon={isSaving ? Loader : ArrowRightIcon} /> 250 + </Button> 251 + </View> 252 + </> 253 + ) 254 + break 255 + case Step.MANAGE: { 256 + const hasJoinLinkCode = joinLink && joinLink.code !== '' 257 + const joinLinkURI = hasJoinLinkCode 258 + ? `https://bsky.app/chat/${joinLink.code}` 259 + : 'https://bsky.app/chat' 260 + const createdAt = joinLink ? new Date(joinLink.createdAt) : null 261 + const currentOption = whoCanJoinOptions.find( 262 + o => o.name === whoCanJoin[0], 263 + ) 264 + const ownerValue = currentOption?.owner ?? whoCanJoinOptions[0].owner 265 + const memberValue = currentOption?.member ?? whoCanJoinOptions[0].member 266 + header = 267 + enabledStatus === 'enabled' ? l`Invite link` : l`Invite link disabled` 268 + content = ( 269 + <> 270 + <View style={[a.mt_lg]}> 271 + <CopyTextButton 272 + disabled={enabledStatus === 'disabled' || !hasJoinLinkCode} 273 + label={l`Invite link`} 274 + value={joinLinkURI}> 275 + <Text 276 + numberOfLines={1} 277 + style={[ 278 + a.mr_xs, 279 + a.text_md, 280 + enabledStatus === 'disabled' 281 + ? t.atoms.text_contrast_low 282 + : t.atoms.text, 283 + ]}> 284 + {joinLinkURI} 285 + </Text> 286 + </CopyTextButton> 287 + {createdAt ? ( 288 + <Text style={[a.mt_xs, a.text_xs, t.atoms.text_contrast_medium]}> 289 + <Trans> 290 + Created {timeFormatter.format(createdAt)}{' '} 291 + {dateFormatter.format(createdAt)} 292 + </Trans> 293 + </Text> 294 + ) : null} 295 + </View> 296 + {enabledStatus === 'enabled' ? ( 297 + <View style={[a.mt_lg]}> 298 + {isOwner ? ( 299 + <EditTextButton 300 + label={l`Edit link settings`} 301 + value={ownerValue} 302 + onPress={() => setStep(Step.GENERATE)}> 303 + <Text 304 + numberOfLines={1} 305 + style={[ 306 + a.mr_xs, 307 + a.text_md, 308 + t.atoms.text, 309 + {maxWidth: '80%'}, 310 + ]}> 311 + {ownerValue} 312 + </Text> 313 + </EditTextButton> 314 + ) : ( 315 + <Text style={[a.text_sm, t.atoms.text]}>{memberValue}</Text> 316 + )} 317 + </View> 318 + ) : null} 319 + {enabledStatus === 'enabled' ? ( 320 + <View style={[a.flex_row, a.justify_between, a.gap_sm, a.mt_lg]}> 321 + {isOwner ? ( 322 + <StackedButton 323 + label={l`Disable`} 324 + icon={isDisabling ? Loader : ChainLinkBrokenIcon} 325 + color="negative_subtle" 326 + style={[a.flex_1, a.rounded_full]} 327 + disabled={isDisabling} 328 + onPress={() => { 329 + disableJoinLink() 330 + }}> 331 + <Trans>Disable</Trans> 332 + </StackedButton> 333 + ) : null} 334 + <StackedButton 335 + disabled={enabledStatus === 'disabled'} 336 + label={l`Post link`} 337 + icon={EditIcon} 338 + color="primary_subtle" 339 + style={[a.flex_1, a.rounded_full]} 340 + onPress={() => { 341 + control.close(() => { 342 + openComposer({ 343 + text: joinLinkURI, 344 + logContext: 'Other', 345 + }) 346 + }) 347 + }}> 348 + <Trans>Post link</Trans> 349 + </StackedButton> 350 + <StackedButton 351 + disabled={enabledStatus === 'disabled'} 352 + label={l`Share`} 353 + icon={ArrowShareRightIcon} 354 + color="primary_subtle" 355 + style={[a.flex_1, a.rounded_full]} 356 + onPress={() => { 357 + void shareUrl(joinLinkURI) 358 + }}> 359 + <Trans>Share</Trans> 360 + </StackedButton> 361 + </View> 362 + ) : ( 363 + <View style={[a.gap_md, a.mt_lg]}> 364 + <Button 365 + label={l`Re-enable invite link`} 366 + color="primary" 367 + size="large" 368 + disabled={isEnabling} 369 + onPress={() => { 370 + enableJoinLink() 371 + }}> 372 + <ButtonText> 373 + <Trans>Re-enable link</Trans> 374 + </ButtonText> 375 + {isEnabling && <ButtonIcon icon={Loader} />} 376 + </Button> 377 + <Button 378 + label={l`Generate new invite link`} 379 + color="secondary" 380 + size="large" 381 + onPress={() => setStep(Step.GENERATE)}> 382 + <ButtonText> 383 + <Trans>Generate new link</Trans> 384 + </ButtonText> 385 + </Button> 386 + </View> 387 + )} 388 + </> 389 + ) 390 + break 391 + } 392 + } 393 + 394 + if (!isOwner && (!joinLink || joinLink?.enabledStatus === 'disabled')) { 395 + header = l`Invite link` 396 + content = ( 397 + <> 398 + <View style={[a.mt_lg]}> 399 + <Text style={[a.text_sm, t.atoms.text]}> 400 + <Trans>There is no invite link for this group chat.</Trans> 401 + </Text> 402 + </View> 403 + <View style={[a.gap_md, a.mt_lg]}> 404 + <Button 405 + label={l`Close`} 406 + color="primary" 407 + size="large" 408 + onPress={() => control.close()}> 409 + <ButtonText> 410 + <Trans>Close</Trans> 411 + </ButtonText> 412 + </Button> 413 + </View> 414 + </> 415 + ) 416 + } 417 + 418 + return ( 419 + <Dialog.Outer 420 + control={control} 421 + onClose={() => { 422 + setStep(defaultStep) 423 + setWhoCanJoin(defaultWhoCanJoin) 424 + }}> 425 + <Dialog.Handle /> 426 + <Dialog.ScrollableInner 427 + header={ 428 + <View> 429 + <View style={[IS_WEB ? [a.px_2xl, a.pt_xl] : {paddingTop: 10}]}> 430 + <Text style={[a.font_bold, a.text_2xl, a.mb_sm, t.atoms.text]}> 431 + {header} 432 + </Text> 433 + </View> 434 + <Dialog.Close /> 435 + </View> 436 + } 437 + label={l`Group chat invite link dialog`} 438 + style={web({maxWidth: 400})}> 439 + {content} 440 + </Dialog.ScrollableInner> 441 + </Dialog.Outer> 442 + ) 443 + } 444 + 445 + function TargetOption({label, selected}: {label: string; selected: boolean}) { 446 + const t = useTheme() 447 + 448 + return ( 449 + <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 450 + <Toggle.Radio /> 451 + <Toggle.LabelText 452 + style={[ 453 + a.font_normal, 454 + a.flex_1, 455 + a.leading_tight, 456 + selected ? t.atoms.text : t.atoms.text_contrast_high, 457 + ]}> 458 + {label} 459 + </Toggle.LabelText> 460 + </View> 461 + ) 462 + }
+2 -1
src/screens/Messages/components/MessageInput.tsx
··· 139 139 scrollEnabled: isInputScrollable.get(), 140 140 })) 141 141 142 - const submitDisabled = needsEmailVerification || message.trim().length === 0 142 + const submitDisabled = 143 + needsEmailVerification || (!hasEmbed && message.trim().length === 0) 143 144 144 145 const blur = useCallback(() => { 145 146 inputRef.current?.blur()
+7 -11
src/screens/Messages/components/MessagesList.tsx
··· 254 254 ) 255 255 256 256 const onStartReached = useCallback(() => { 257 - if (hasScrolled && prevContentHeight.current > layoutHeight.get()) { 258 - void convoState.fetchMessageHistory() 259 - } 260 - }, [convoState, hasScrolled, layoutHeight]) 257 + void convoState.fetchMessageHistory() 258 + }, [convoState]) 261 259 262 260 const onScroll = useCallback( 263 261 (e: ScrollEvent) => { ··· 380 378 return ( 381 379 <MessageItem 382 380 item={item} 383 - profile={convoState.convo.members.find( 384 - member => member.did === item.message.sender.did, 385 - )} 386 - isGroupChat={convoState.isGroup()} 381 + isGroupChat={convoState.convo.kind === 'group'} 387 382 /> 388 383 ) 389 384 } else if (item.type === 'deleted-message') { ··· 452 447 ListHeaderComponent={ 453 448 <> 454 449 <MaybeLoader isLoading={convoState.isFetchingHistory} /> 455 - {convoState.isGroup() && convoState.hasAllHistory ? ( 456 - <MessagesListInfoPanel convoState={convoState} /> 450 + {convoState.convo?.kind === 'group' && 451 + convoState.hasAllHistory ? ( 452 + <MessagesListInfoPanel convo={convoState.convo} /> 457 453 ) : null} 458 454 </> 459 455 } ··· 581 577 } 582 578 } 583 579 584 - if (convoState.convo.status === 'request' && !hasAcceptOverride) { 580 + if (convoState.convo.view.status === 'request' && !hasAcceptOverride) { 585 581 return 'request' 586 582 } 587 583
+56 -29
src/screens/Messages/components/MessagesListInfoPanel.tsx
··· 1 1 import {View} from 'react-native' 2 2 import {Plural, Trans, useLingui} from '@lingui/react/macro' 3 3 4 - import {type ConvoState} from '#/state/messages/convo/types' 4 + import {logger} from '#/logger' 5 + import {useAddGroupMembers} from '#/state/queries/messages/add-group-members' 5 6 import {useSession} from '#/state/session' 6 7 import {atoms as a, useTheme} from '#/alf' 7 8 import {AvatarBubbles} from '#/components/AvatarBubbles' 8 9 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 9 10 import * as Dialog from '#/components/Dialog' 10 11 import {AddMembersFlow} from '#/components/dms/AddMembersFlow' 12 + import {type ConvoWithDetails} from '#/components/dms/util' 11 13 import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' 12 14 import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' 15 + import * as Toast from '#/components/Toast' 13 16 import {Text} from '#/components/Typography' 17 + import {InviteLinkDialog} from './InviteLinkDialog' 14 18 15 - export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) { 19 + export function MessagesListInfoPanel({ 20 + convo, 21 + }: { 22 + convo: Extract<ConvoWithDetails, {kind: 'group'}> 23 + }) { 16 24 const t = useTheme() 17 25 const {t: l} = useLingui() 18 26 19 27 const addMembersControl = Dialog.useDialogControl() 28 + const inviteLinkControl = Dialog.useDialogControl() 20 29 21 30 const {currentAccount} = useSession() 22 31 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 32 + const convoId = convo.view.id 33 + const {mutate: addGroupMembers} = useAddGroupMembers(convoId, { 34 + onSuccess: () => { 35 + addMembersControl.close() 36 + }, 37 + onError: e => { 38 + logger.error('Failed to add group chat members', {message: e}) 39 + Toast.show(l`Failed to add members`, {type: 'error'}) 40 + }, 41 + }) 42 + 43 + // TODO Enable this once the feature is working end-to-end. -dsb 44 + // const joinLink = groupConvo?.details.joinLink 45 + const isJoinLinkEnabled = false 46 + // (isOwner && groupConvo) || 47 + // (!isOwner && groupConvo && joinLink?.enabledStatus === 'enabled') 29 48 30 - const groupName = convoState.getGroupInfo?.()?.name 49 + const isOwner = convo?.primaryMember.did === currentAccount?.did 31 50 32 - const members = (convoState?.convo?.members ?? []).filter( 51 + const members = (convo?.members ?? []).filter( 33 52 profile => profile.did !== currentAccount?.did, 34 53 ) 35 54 36 - let names: React.ReactNode | null = null 55 + let names: React.ReactNode = null 37 56 if (members.length === 1) { 38 57 names = <Trans>New chat with {members[0].displayName}</Trans> 39 - } 40 - if (members.length === 2) { 58 + } else if (members.length === 2) { 41 59 names = ( 42 60 <Trans> 43 61 New chat with {members[0].displayName} and {members[1].displayName} 44 62 </Trans> 45 63 ) 46 - } 47 - if (members.length > 2) { 64 + } else if (members.length > 2) { 65 + const memberCount = convo.details.memberCount - 2 48 66 names = ( 49 67 <Trans> 50 68 New chat with {members[0].displayName}, {members[1].displayName}, and{' '} 51 69 <Plural 52 - value={members.length - 2} 53 - one={`${members.length - 2} more`} 54 - other={`${members.length - 2} more`} 70 + value={memberCount} 71 + one={`${memberCount} more`} 72 + other={`${memberCount} more`} 55 73 /> 56 74 . 57 75 </Trans> 58 76 ) 59 77 } 60 78 61 - const showButtons = isOwner || isLinkEnabled 79 + const showButtons = isOwner || isJoinLinkEnabled 62 80 63 81 return ( 64 82 <> 65 83 <View style={[a.align_center, a.justify_center]}> 66 - <AvatarBubbles animate={true} profiles={members} /> 67 - {groupName ? ( 84 + <AvatarBubbles animate={true} profiles={convo?.members} /> 85 + {convo.details.name ? ( 68 86 <Text style={[a.text_2xl, a.font_bold, a.mt_lg, t.atoms.text]}> 69 - {groupName} 87 + {convo.details.name} 70 88 </Text> 71 89 ) : null} 72 90 {names ? ( ··· 102 120 </ButtonText> 103 121 </Button> 104 122 ) : null} 105 - {isOwner || isLinkEnabled ? ( 123 + {isJoinLinkEnabled ? ( 106 124 <Button 107 125 color="secondary" 108 126 size="small" 109 - label={l`Click here to view or create an invite link for this group chat`} 110 - onPress={() => {}}> 127 + label={ 128 + isOwner 129 + ? l`Click here to create or manage an invite link for this group chat` 130 + : l`Click here to view the invite link for this group chat` 131 + } 132 + onPress={inviteLinkControl.open}> 111 133 <ButtonIcon icon={ChainLinkIcon} /> 112 134 <ButtonText> 113 135 <Trans>Invite link</Trans> ··· 117 139 </View> 118 140 ) : null} 119 141 </View> 142 + <InviteLinkDialog 143 + isOwner={isOwner} 144 + convo={convo} 145 + control={inviteLinkControl} 146 + /> 120 147 <Dialog.Outer 121 148 control={addMembersControl} 122 149 testID="addChatMembersDialog" 123 150 nativeOptions={{fullHeight: true}}> 124 151 <Dialog.Handle /> 125 152 <AddMembersFlow 153 + convo={convo} 126 154 title={l`Add people`} 127 - onAddMembers={(_dids: string[]) => { 128 - // TODO Add members here 129 - addMembersControl.close() 130 - }} 155 + onAddMembers={(members, profiles) => 156 + addGroupMembers({members, profiles}) 157 + } 131 158 /> 132 159 </Dialog.Outer> 133 160 </>
+1 -2
src/screens/Profile/Sections/Feed.tsx
··· 126 126 const t = useTheme() 127 127 128 128 return ( 129 - <View 130 - style={[a.w_full, a.py_5xl, a.border_t, t.atoms.border_contrast_medium]}> 129 + <View style={[a.w_full, a.py_5xl, a.border_t, t.atoms.border_contrast_low]}> 131 130 <Text style={[t.atoms.text_contrast_medium, a.text_center]}> 132 131 <Trans>End of feed</Trans> 133 132 </Text>
+2
src/state/cache/profile-shadow.ts
··· 11 11 import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers' 12 12 import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '#/state/queries/list-members' 13 13 import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations' 14 + import {findAllProfilesInQueryData as findAllProfilesInMessagesQueryData} from '#/state/queries/messages/list-convo-members' 14 15 import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts' 15 16 import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '#/state/queries/my-muted-accounts' 16 17 import {findAllProfilesInQueryData as findAllProfilesInNotifsQueryData} from '#/state/queries/notifications/feed' ··· 266 267 yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did) 267 268 yield* findAllProfilesInNotifsQueryData(queryClient, did) 268 269 yield* findAllProfilesInContactMatchesQueryData(queryClient, did) 270 + yield* findAllProfilesInMessagesQueryData(queryClient, did) 269 271 }
+224 -192
src/state/messages/convo/agent.ts
··· 1 1 import { 2 2 type AtpAgent, 3 - ChatBskyActorDefs, 3 + type ChatBskyActorDefs, 4 4 ChatBskyConvoDefs, 5 5 type ChatBskyConvoGetLog, 6 6 type ChatBskyConvoSendMessage, 7 + type ChatBskyGroupDefs, 7 8 } from '@atproto/api' 8 9 import {XRPCError} from '@atproto/api' 9 10 import {EventEmitter} from 'eventemitter3' ··· 36 37 } from '#/state/messages/convo/types' 37 38 import {type MessagesEventBus} from '#/state/messages/events/agent' 38 39 import {type MessagesEventBusError} from '#/state/messages/events/types' 40 + import { 41 + type ConvoWithDetails, 42 + type GroupConvoMember, 43 + parseConvoView, 44 + } from '#/components/dms/util' 39 45 import {IS_NATIVE} from '#/env' 40 - import * as bsky from '#/types/bsky' 41 46 42 47 const logger = Logger.create(Logger.Context.ConversationAgent) 43 48 ··· 102 107 {id: string; message: ChatBskyConvoSendMessage.InputSchema['message']} 103 108 > = new Map() 104 109 private deletedMessages: Set<string> = new Set() 105 - private systemMessageProfiles: Map< 106 - string, 107 - ChatBskyActorDefs.ProfileViewBasic 108 - > = new Map() 110 + private relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> = 111 + new Map() 109 112 110 113 private isProcessingPendingMessages = false 111 114 ··· 114 117 private emitter = new EventEmitter<{event: [ConvoEvent]}>() 115 118 116 119 convoId: string 117 - convo: ChatBskyConvoDefs.ConvoView | undefined 120 + convo: ConvoWithDetails | undefined 118 121 sender: ChatBskyActorDefs.ProfileViewBasic | undefined 119 122 recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined 120 123 snapshot: ConvoState | undefined ··· 130 133 this.setupPlaceholderData(params.placeholderData) 131 134 } 132 135 136 + this.setConvo = this.setConvo.bind(this) 133 137 this.subscribe = this.subscribe.bind(this) 134 138 this.getSnapshot = this.getSnapshot.bind(this) 135 139 this.sendMessage = this.sendMessage.bind(this) ··· 141 145 this.markConvoAccepted = this.markConvoAccepted.bind(this) 142 146 this.addReaction = this.addReaction.bind(this) 143 147 this.removeReaction = this.removeReaction.bind(this) 144 - this.isGroup = this.isGroup.bind(this) 145 - this.getGroupInfo = this.getGroupInfo.bind(this) 146 - this.getPrimaryMember = this.getPrimaryMember.bind(this) 147 148 this.updateGroupName = this.updateGroupName.bind(this) 149 + this.updateGroupMembers = this.updateGroupMembers.bind(this) 150 + this.updateJoinLink = this.updateJoinLink.bind(this) 151 + this.updateLockStatus = this.updateLockStatus.bind(this) 148 152 } 149 153 150 154 private commit() { ··· 172 176 } 173 177 174 178 private generateSnapshot(): ConvoState { 179 + const shared = { 180 + isFetchingHistory: this.isFetchingHistory, 181 + // Explicit null check since the value is initially undefined. 182 + hasAllHistory: this.oldestRev === null, 183 + } 184 + 185 + const methods = { 186 + deleteMessage: this.deleteMessage, 187 + sendMessage: this.sendMessage, 188 + fetchMessageHistory: this.fetchMessageHistory, 189 + markConvoAccepted: this.markConvoAccepted, 190 + addReaction: this.addReaction, 191 + removeReaction: this.removeReaction, 192 + } 193 + 194 + const emptyMethods = { 195 + deleteMessage: undefined, 196 + sendMessage: undefined, 197 + fetchMessageHistory: undefined, 198 + markConvoAccepted: undefined, 199 + addReaction: undefined, 200 + removeReaction: undefined, 201 + } 202 + 175 203 switch (this.status) { 176 204 case ConvoStatus.Initializing: { 177 205 return { ··· 179 207 items: [], 180 208 convo: this.convo, 181 209 error: undefined, 182 - sender: this.sender, 183 - recipients: this.recipients, 184 - isFetchingHistory: this.isFetchingHistory, 185 - // Explicit null check since the value is initially undefined. 186 - hasAllHistory: this.oldestRev === null, 187 - deleteMessage: undefined, 188 - sendMessage: undefined, 189 - fetchMessageHistory: undefined, 190 - markConvoAccepted: undefined, 191 - addReaction: undefined, 192 - removeReaction: undefined, 193 - isGroup: this.isGroup, 194 - getGroupInfo: this.getGroupInfo, 195 - getPrimaryMember: this.getPrimaryMember, 210 + ...shared, 211 + ...emptyMethods, 196 212 } 197 213 } 198 - case ConvoStatus.Disabled: 199 - case ConvoStatus.Suspended: 200 - case ConvoStatus.Backgrounded: 214 + case ConvoStatus.Disabled: { 215 + return { 216 + status: this.status, 217 + items: this.getItems(), 218 + convo: this.convo!, 219 + error: undefined, 220 + ...shared, 221 + ...methods, 222 + } 223 + } 224 + case ConvoStatus.Suspended: { 225 + return { 226 + status: this.status, 227 + items: this.getItems(), 228 + convo: this.convo!, 229 + error: undefined, 230 + ...shared, 231 + ...methods, 232 + } 233 + } 234 + case ConvoStatus.Backgrounded: { 235 + return { 236 + status: this.status, 237 + items: this.getItems(), 238 + convo: this.convo!, 239 + error: undefined, 240 + ...shared, 241 + ...methods, 242 + } 243 + } 201 244 case ConvoStatus.Ready: { 202 245 return { 203 246 status: this.status, 204 247 items: this.getItems(), 205 248 convo: this.convo!, 206 249 error: undefined, 207 - sender: this.sender!, 208 - recipients: this.recipients!, 209 - isFetchingHistory: this.isFetchingHistory, 210 - // Explicit null check since the value is initially undefined. 211 - hasAllHistory: this.oldestRev === null, 212 - deleteMessage: this.deleteMessage, 213 - sendMessage: this.sendMessage, 214 - fetchMessageHistory: this.fetchMessageHistory, 215 - markConvoAccepted: this.markConvoAccepted, 216 - addReaction: this.addReaction, 217 - removeReaction: this.removeReaction, 218 - isGroup: this.isGroup, 219 - getGroupInfo: this.getGroupInfo, 220 - getPrimaryMember: this.getPrimaryMember, 250 + ...shared, 251 + ...methods, 221 252 } 222 253 } 223 254 case ConvoStatus.Error: { ··· 226 257 items: [], 227 258 convo: undefined, 228 259 error: this.error!, 229 - sender: undefined, 230 - recipients: undefined, 231 260 isFetchingHistory: false, 232 261 hasAllHistory: false, 233 - deleteMessage: undefined, 234 - sendMessage: undefined, 235 - fetchMessageHistory: undefined, 236 - markConvoAccepted: undefined, 237 - addReaction: undefined, 238 - removeReaction: undefined, 239 - isGroup: undefined, 240 - getGroupInfo: undefined, 241 - getPrimaryMember: undefined, 262 + ...emptyMethods, 242 263 } 243 264 } 244 265 default: { ··· 247 268 items: [], 248 269 convo: this.convo, 249 270 error: undefined, 250 - sender: this.sender, 251 - recipients: this.recipients, 252 271 isFetchingHistory: false, 253 272 // Explicit null check since the value is initially undefined. 254 273 hasAllHistory: this.oldestRev === null, 255 - deleteMessage: undefined, 256 - sendMessage: undefined, 257 - fetchMessageHistory: undefined, 258 - markConvoAccepted: undefined, 259 - addReaction: undefined, 260 - removeReaction: undefined, 261 - isGroup: this.isGroup, 262 - getGroupInfo: this.getGroupInfo, 263 - getPrimaryMember: this.getPrimaryMember, 274 + ...emptyMethods, 264 275 } 265 276 } 266 277 } ··· 460 471 461 472 private reset() { 462 473 this.convo = undefined 463 - this.sender = undefined 464 - this.recipients = undefined 465 474 this.snapshot = undefined 466 475 467 476 this.status = ConvoStatus.Uninitialized ··· 473 482 this.newMessages = new Map() 474 483 this.pendingMessages = new Map() 475 484 this.deletedMessages = new Set() 476 - this.systemMessageProfiles = new Map() 485 + this.relatedProfiles = new Map() 477 486 478 487 this.pendingMessageFailure = null 479 488 this.fetchMessageHistoryError = undefined ··· 498 507 } 499 508 } 500 509 510 + private setConvo(convo: ChatBskyConvoDefs.ConvoView) { 511 + this.convo = parseConvoView(convo, this.senderUserDid) ?? this.convo 512 + if (this.convo) { 513 + for (const member of this.convo.members) { 514 + this.relatedProfiles.set(member.did, member) 515 + } 516 + } 517 + } 518 + 519 + private updateConvo(convo: Partial<ChatBskyConvoDefs.ConvoView>) { 520 + if (this.convo) { 521 + this.convo = 522 + parseConvoView({...this.convo.view, ...convo}, this.senderUserDid) ?? 523 + this.convo 524 + for (const member of this.convo.members) { 525 + this.relatedProfiles.set(member.did, member) 526 + } 527 + } 528 + } 529 + 501 530 /** 502 531 * Initialises the convo with placeholder data, if provided. We still refetch it before rendering the convo, 503 532 * but this allows us to render the convo header immediately. ··· 505 534 private setupPlaceholderData( 506 535 data: NonNullable<ConvoParams['placeholderData']>, 507 536 ) { 508 - this.convo = data.convo 509 - this.sender = data.convo.members.find(m => m.did === this.senderUserDid) 510 - this.recipients = data.convo.members.filter( 511 - m => m.did !== this.senderUserDid, 512 - ) 537 + this.setConvo(data.convo) 513 538 } 514 539 515 540 private async setup() { 516 541 try { 517 - const {convo, sender, recipients} = await this.fetchConvo() 542 + const {convo} = await this.fetchConvo() 518 543 519 - this.convo = convo 520 - this.sender = sender 521 - this.recipients = recipients 544 + this.setConvo(convo) 522 545 523 546 /* 524 547 * Some validation prior to `Ready` status ··· 526 549 if (!this.convo) { 527 550 throw new Error('could not find convo') 528 551 } 529 - if (!this.sender) { 530 - throw new Error('could not find sender in convo') 531 - } 532 - if (!this.recipients) { 533 - throw new Error('could not find recipients in convo') 552 + 553 + const self = this.convo.members.find(m => m.did === this.senderUserDid) 554 + 555 + if (!self) { 556 + throw new Error('could not find self in convo') 534 557 } 535 558 536 - const userIsDisabled = Boolean(this.sender.chatDisabled) 559 + const userIsDisabled = Boolean(self.chatDisabled) 537 560 538 561 if (userIsDisabled) { 539 562 this.dispatch({event: ConvoDispatchEvent.Disable}) ··· 602 625 } 603 626 604 627 private pendingFetchConvo: 605 - | Promise<{ 606 - convo: ChatBskyConvoDefs.ConvoView 607 - sender: ChatBskyActorDefs.ProfileViewBasic | undefined 608 - recipients: ChatBskyActorDefs.ProfileViewBasic[] 609 - }> 628 + | Promise<{convo: ChatBskyConvoDefs.ConvoView}> 610 629 | undefined 611 630 async fetchConvo() { 612 631 if (this.pendingFetchConvo) return this.pendingFetchConvo 613 632 633 + // non-blocking 634 + void this.fetchMemberList() 635 + 614 636 this.pendingFetchConvo = (async () => { 615 637 try { 616 638 const response = await networkRetry(2, () => { 617 - return this.agent.api.chat.bsky.convo.getConvo( 618 - { 619 - convoId: this.convoId, 620 - }, 639 + return this.agent.chat.bsky.convo.getConvo( 640 + {convoId: this.convoId}, 621 641 {headers: DM_SERVICE_HEADERS}, 622 642 ) 623 643 }) ··· 626 646 627 647 return { 628 648 convo, 629 - sender: convo.members.find(m => m.did === this.senderUserDid), 630 - recipients: convo.members.filter(m => m.did !== this.senderUserDid), 631 649 } 632 650 } finally { 633 651 this.pendingFetchConvo = undefined ··· 639 657 640 658 async refreshConvo() { 641 659 try { 642 - const {convo, sender, recipients} = await this.fetchConvo() 660 + void this.fetchMemberList() 661 + const {convo} = await this.fetchConvo() 643 662 // throw new Error('UNCOMMENT TO TEST REFRESH FAILURE') 644 - this.convo = convo || this.convo 645 - this.sender = sender || this.sender 646 - this.recipients = recipients || this.recipients 663 + this.setConvo(convo) 647 664 } catch (err) { 648 665 const e = err as Error 649 666 if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { ··· 654 671 } 655 672 } 656 673 657 - private fetchMessageHistoryError: 658 - | { 659 - retry: () => void 674 + // purely for populating `this.relatedProfiles` - we do not pipe it 675 + // into the ConvoWithDetails. If you want to drive UI based on the member list, 676 + // use `useListConvoMembersQuery` 677 + // we shouldn't also block loading off of this - the UI should be resilient 678 + async fetchMemberList() { 679 + let cursor: string | undefined 680 + do { 681 + const result = await networkRetry(2, () => { 682 + return this.agent.chat.bsky.convo.getConvoMembers( 683 + { 684 + convoId: this.convoId, 685 + limit: 50, 686 + cursor, 687 + }, 688 + {headers: DM_SERVICE_HEADERS}, 689 + ) 690 + }) 691 + cursor = result.data.cursor 692 + 693 + for (const member of result.data.members) { 694 + this.relatedProfiles.set(member.did, member) 660 695 } 661 - | undefined 696 + } while (cursor) 697 + } 698 + 699 + private fetchMessageHistoryError: {retry: () => void} | undefined 662 700 async fetchMessageHistory() { 663 701 logger.debug('fetch message history', {}) 664 702 ··· 700 738 701 739 if (relatedProfiles) { 702 740 for (const profile of relatedProfiles) { 703 - this.systemMessageProfiles.set(profile.did, profile) 741 + this.relatedProfiles.set(profile.did, profile) 704 742 } 705 743 } 706 744 ··· 820 858 */ 821 859 this.latestRev = ev.rev 822 860 861 + if ('relatedProfiles' in ev && Array.isArray(ev.relatedProfiles)) { 862 + for (const profile of ev.relatedProfiles) { 863 + this.relatedProfiles.set(profile.did, profile) 864 + } 865 + } 866 + 823 867 if ( 824 868 ChatBskyConvoDefs.isLogCreateMessage(ev) && 825 869 ChatBskyConvoDefs.isMessageView(ev.message) ··· 872 916 const systemView = toSystemMessageView(ev) 873 917 if (systemView) { 874 918 this.newMessages.set(systemView.id, systemView) 875 - if ( 876 - 'relatedProfiles' in ev && 877 - Array.isArray(ev.relatedProfiles) 878 - ) { 879 - for (const profile of ev.relatedProfiles) { 880 - this.systemMessageProfiles.set(profile.did, profile) 881 - } 882 - } 883 919 needsCommit = true 884 920 } 885 921 } ··· 907 943 id: tempId, 908 944 message, 909 945 }) 910 - if (this.convo?.status === 'request') { 911 - this.convo = { 912 - ...this.convo, 946 + if (this.convo?.view.status === 'request') { 947 + this.updateConvo({ 913 948 status: 'accepted', 914 - } 949 + }) 915 950 } 916 951 this.commit() 917 952 ··· 921 956 } 922 957 923 958 markConvoAccepted() { 924 - if (this.convo) { 925 - this.convo = { 926 - ...this.convo, 927 - status: 'accepted', 928 - } 929 - } 959 + this.updateConvo({ 960 + status: 'accepted', 961 + }) 962 + 930 963 this.commit() 931 964 } 932 965 933 966 updateMuted(muted: boolean) { 934 - if (this.convo) { 935 - this.convo = { 936 - ...this.convo, 937 - muted, 938 - } 939 - } 967 + this.updateConvo({ 968 + muted, 969 + }) 970 + 940 971 this.commit() 941 972 } 942 973 943 974 updateGroupName(name: string) { 944 - if ( 945 - this.convo && 946 - bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>( 947 - this.convo.kind, 948 - ChatBskyConvoDefs.isGroupConvo, 949 - ) 950 - ) { 951 - this.convo = { 952 - ...this.convo, 953 - kind: { 954 - ...this.convo.kind, 955 - name, 956 - }, 957 - } 975 + if (this.convo?.kind !== 'group') { 976 + throw new Error('updateGroupName can only be called on group convo') 958 977 } 978 + 979 + this.updateConvo({ 980 + kind: { 981 + ...this.convo.details, 982 + name, 983 + }, 984 + }) 985 + 986 + this.commit() 987 + } 988 + 989 + updateGroupMembers(members: GroupConvoMember[], memberCount: number) { 990 + if (this.convo?.kind !== 'group') { 991 + throw new Error('updateGroupMembers can only be called on group convo') 992 + } 993 + 994 + this.updateConvo({ 995 + members, 996 + kind: { 997 + ...this.convo.details, 998 + memberCount, 999 + }, 1000 + }) 1001 + 1002 + this.commit() 1003 + } 1004 + 1005 + updateJoinLink(joinLink: ChatBskyGroupDefs.JoinLinkView | undefined) { 1006 + if (this.convo?.kind !== 'group') { 1007 + throw new Error('updateJoinLink can only be called on group convo') 1008 + } 1009 + 1010 + this.updateConvo({ 1011 + kind: { 1012 + ...this.convo.details, 1013 + joinLink, 1014 + }, 1015 + }) 1016 + 1017 + this.commit() 1018 + } 1019 + 1020 + updateLockStatus(lockStatus: ChatBskyConvoDefs.ConvoLockStatus) { 1021 + if (this.convo?.kind !== 'group') { 1022 + throw new Error('updateLockStatus can only be called on group convo') 1023 + } 1024 + 1025 + this.updateConvo({ 1026 + kind: { 1027 + ...this.convo.details, 1028 + lockStatus, 1029 + }, 1030 + }) 1031 + 959 1032 this.commit() 960 1033 } 961 1034 ··· 980 1053 981 1054 const {id, message} = pendingMessage 982 1055 983 - const response = await this.agent.api.chat.bsky.convo.sendMessage( 1056 + const response = await this.agent.chat.bsky.convo.sendMessage( 984 1057 { 985 1058 convoId: this.convoId, 986 1059 message, ··· 1023 1096 this.emitter.emit('event', { 1024 1097 type: 'invalidate-block-state', 1025 1098 accountDids: [ 1026 - this.sender!.did, 1099 + this.senderUserDid, 1027 1100 ...this.recipients!.map(r => r.did), 1028 1101 ], 1029 1102 }) ··· 1075 1148 ) 1076 1149 1077 1150 try { 1078 - const {data} = await this.agent.api.chat.bsky.convo.sendMessageBatch( 1151 + const {data} = await this.agent.chat.bsky.convo.sendMessageBatch( 1079 1152 { 1080 1153 items: messageArray.map(({message}) => ({ 1081 1154 convoId: this.convoId, ··· 1117 1190 1118 1191 try { 1119 1192 await networkRetry(2, () => { 1120 - return this.agent.api.chat.bsky.convo.deleteMessageForSelf( 1193 + return this.agent.chat.bsky.convo.deleteMessageForSelf( 1121 1194 { 1122 1195 convoId: this.convoId, 1123 1196 messageId, ··· 1158 1231 type: 'message', 1159 1232 key: m.id, 1160 1233 message: m, 1234 + relatedProfiles: this.relatedProfiles, 1161 1235 nextMessage: null, 1162 1236 prevMessage: null, 1163 1237 }) ··· 1166 1240 type: 'deleted-message', 1167 1241 key: m.id, 1168 1242 message: m, 1243 + relatedProfiles: this.relatedProfiles, 1169 1244 nextMessage: null, 1170 1245 prevMessage: null, 1171 1246 }) ··· 1174 1249 type: 'system-message', 1175 1250 key: m.id, 1176 1251 message: m, 1177 - relatedProfiles: Array.from(this.systemMessageProfiles.values()), 1252 + relatedProfiles: this.relatedProfiles, 1178 1253 }) 1179 1254 } 1180 1255 }) ··· 1196 1271 type: 'message', 1197 1272 key: m.id, 1198 1273 message: m, 1274 + relatedProfiles: this.relatedProfiles, 1199 1275 nextMessage: null, 1200 1276 prevMessage: null, 1201 1277 }) ··· 1204 1280 type: 'deleted-message', 1205 1281 key: m.id, 1206 1282 message: m, 1283 + relatedProfiles: this.relatedProfiles, 1207 1284 nextMessage: null, 1208 1285 prevMessage: null, 1209 1286 }) ··· 1212 1289 type: 'system-message', 1213 1290 key: m.id, 1214 1291 message: m, 1215 - relatedProfiles: Array.from(this.systemMessageProfiles.values()), 1292 + relatedProfiles: this.relatedProfiles, 1216 1293 }) 1217 1294 } 1218 1295 }) ··· 1228 1305 id: nanoid(), 1229 1306 rev: '__fake__', 1230 1307 sentAt: new Date().toISOString(), 1231 - /* 1232 - * `getItems` is only run in "active" status states, where 1233 - * `this.sender` is defined 1234 - */ 1235 1308 sender: { 1236 1309 $type: 'chat.bsky.convo.defs#messageViewSender', 1237 - did: this.sender!.did, 1310 + did: this.senderUserDid, 1238 1311 }, 1239 1312 }, 1313 + relatedProfiles: this.relatedProfiles, 1240 1314 nextMessage: null, 1241 1315 prevMessage: null, 1242 1316 failed: this.pendingMessageFailure !== null, ··· 1446 1520 } catch (error) { 1447 1521 if (restore) restore() 1448 1522 throw error 1449 - } 1450 - } 1451 - 1452 - // Group utilities 1453 - 1454 - isGroup(): boolean | undefined { 1455 - if (!this.convo) return undefined 1456 - const info = this.getGroupInfo() 1457 - return !!info 1458 - } 1459 - 1460 - getGroupInfo(): ChatBskyConvoDefs.GroupConvo | undefined { 1461 - if ( 1462 - this.convo && 1463 - bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>( 1464 - this.convo.kind, 1465 - ChatBskyConvoDefs.isGroupConvo, 1466 - ) 1467 - ) { 1468 - return this.convo.kind 1469 - } 1470 - return undefined 1471 - } 1472 - 1473 - getPrimaryMember(): ChatBskyActorDefs.ProfileViewBasic | undefined { 1474 - if (this.isGroup()) { 1475 - return this.convo?.members.find(m => { 1476 - if ( 1477 - bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>( 1478 - m.kind, 1479 - ChatBskyActorDefs.isGroupConvoMember, 1480 - ) 1481 - ) { 1482 - return m.kind.role === 'owner' 1483 - } else { 1484 - throw new Error( 1485 - 'Expected a GroupConvoMember, got an unknown kind of member', 1486 - ) 1487 - } 1488 - }) 1489 - } else { 1490 - return this.recipients?.find(r => r.did !== this.senderUserDid) 1491 1523 } 1492 1524 } 1493 1525 }
+35 -7
src/state/messages/convo/index.tsx
··· 29 29 import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations' 30 30 import {RQKEY as createProfileQueryKey} from '#/state/queries/profile' 31 31 import {useAgent} from '#/state/session' 32 + import {type GroupConvoMember} from '#/components/dms/util' 32 33 33 34 export * from '#/state/messages/convo/util' 35 + 36 + function membersChanged( 37 + a: ChatBskyConvoDefs.ConvoView['members'], 38 + b: ChatBskyConvoDefs.ConvoView['members'], 39 + ) { 40 + if (a.length !== b.length) return true 41 + const aDids = new Set(a.map(m => m.did)) 42 + return b.some(m => !aDids.has(m.did)) 43 + } 34 44 35 45 const ChatContext = createContext<ConvoState | null>(null) 36 46 ChatContext.displayName = 'ChatContext' ··· 107 117 switch (event.type) { 108 118 case 'invalidate-block-state': { 109 119 for (const did of event.accountDids) { 110 - queryClient.invalidateQueries({ 120 + void queryClient.invalidateQueries({ 111 121 queryKey: createProfileQueryKey(did), 112 122 }) 113 123 } 114 - queryClient.invalidateQueries({ 124 + void queryClient.invalidateQueries({ 115 125 queryKey: [ListConvosQueryKeyRoot], 116 126 }) 117 127 } ··· 127 137 const data = event.query.state.data as 128 138 | ChatBskyConvoDefs.ConvoView 129 139 | undefined 130 - if (data && convo.convo && data.muted !== convo.convo.muted) { 140 + if (data && convo.convo && data.muted !== convo.convo.view.muted) { 131 141 convo.updateMuted(data.muted) 132 142 } 133 143 if ( 134 144 data && 135 - convo.convo && 136 145 ChatBskyConvoDefs.isGroupConvo(data.kind) && 137 - ChatBskyConvoDefs.isGroupConvo(convo.convo.kind) && 138 - data.kind.name !== convo.convo.kind.name 146 + convo.convo?.kind === 'group' 139 147 ) { 140 - convo.updateGroupName(data.kind.name) 148 + if (data.kind.name !== convo.convo.details.name) { 149 + convo.updateGroupName(data.kind.name) 150 + } 151 + if (data.kind.joinLink !== convo.convo.details.joinLink) { 152 + convo.updateJoinLink(data.kind.joinLink) 153 + } 154 + if (data.kind.lockStatus !== convo.convo.details.lockStatus) { 155 + convo.updateLockStatus(data.kind.lockStatus) 156 + } 157 + } 158 + if ( 159 + data && 160 + ChatBskyConvoDefs.isGroupConvo(data.kind) && 161 + convo.convo?.kind === 'group' && 162 + (membersChanged(data.members, convo.convo.members) || 163 + data.kind.memberCount !== convo.convo.details.memberCount) 164 + ) { 165 + convo.updateGroupMembers( 166 + data.members as GroupConvoMember[], 167 + data.kind.memberCount, 168 + ) 141 169 } 142 170 } 143 171 })
+18 -67
src/state/messages/convo/types.ts
··· 6 6 } from '@atproto/api' 7 7 8 8 import {type MessagesEventBus} from '#/state/messages/events/agent' 9 + import {type ConvoWithDetails} from '#/components/dms/util' 9 10 10 11 export type ConvoParams = { 11 12 convoId: string ··· 58 59 } 59 60 60 61 export type ConvoDispatch = 61 - | { 62 - event: ConvoDispatchEvent.Init 63 - } 64 - | { 65 - event: ConvoDispatchEvent.Ready 66 - } 67 - | { 68 - event: ConvoDispatchEvent.Resume 69 - } 70 - | { 71 - event: ConvoDispatchEvent.Background 72 - } 73 - | { 74 - event: ConvoDispatchEvent.Suspend 75 - } 76 - | { 77 - event: ConvoDispatchEvent.Error 78 - payload: ConvoError 79 - } 80 - | { 81 - event: ConvoDispatchEvent.Disable 82 - } 62 + | {event: ConvoDispatchEvent.Init} 63 + | {event: ConvoDispatchEvent.Ready} 64 + | {event: ConvoDispatchEvent.Resume} 65 + | {event: ConvoDispatchEvent.Background} 66 + | {event: ConvoDispatchEvent.Suspend} 67 + | {event: ConvoDispatchEvent.Error; payload: ConvoError} 68 + | {event: ConvoDispatchEvent.Disable} 83 69 84 70 export type ConvoItem = 85 71 | { 86 72 type: 'message' 87 73 key: string 88 74 message: ChatBskyConvoDefs.MessageView 75 + relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> 89 76 nextMessage: 90 77 | ChatBskyConvoDefs.MessageView 91 78 | ChatBskyConvoDefs.DeletedMessageView ··· 99 86 type: 'pending-message' 100 87 key: string 101 88 message: ChatBskyConvoDefs.MessageView 89 + relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> 102 90 nextMessage: 103 91 | ChatBskyConvoDefs.MessageView 104 92 | ChatBskyConvoDefs.DeletedMessageView ··· 117 105 type: 'deleted-message' 118 106 key: string 119 107 message: ChatBskyConvoDefs.DeletedMessageView 108 + relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> 120 109 nextMessage: 121 110 | ChatBskyConvoDefs.MessageView 122 111 | ChatBskyConvoDefs.DeletedMessageView ··· 130 119 type: 'system-message' 131 120 key: string 132 121 message: ChatBskyConvoDefs.SystemMessageView 133 - relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[] 122 + relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> 134 123 } 135 124 | { 136 125 type: 'error' ··· 150 139 type MarkConvoAccepted = () => void 151 140 type AddReaction = (messageId: string, reaction: string) => Promise<void> 152 141 type RemoveReaction = (messageId: string, reaction: string) => Promise<void> 153 - type IsGroup = () => boolean | undefined 154 - type GetGroupInfo = () => ChatBskyConvoDefs.GroupConvo | undefined 155 - type GetPrimaryMember = () => ChatBskyActorDefs.ProfileViewBasic | undefined 156 142 157 143 export type ConvoStateUninitialized = { 158 144 status: ConvoStatus.Uninitialized 159 145 items: [] 160 - convo: ChatBskyConvoDefs.ConvoView | undefined 146 + convo: ConvoWithDetails | undefined 161 147 error: undefined 162 - sender: ChatBskyActorDefs.ProfileViewBasic | undefined 163 - recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined 164 148 isFetchingHistory: false 165 149 hasAllHistory: boolean 166 150 deleteMessage: undefined ··· 169 153 markConvoAccepted: undefined 170 154 addReaction: undefined 171 155 removeReaction: undefined 172 - isGroup: IsGroup 173 - getGroupInfo: GetGroupInfo 174 - getPrimaryMember: GetPrimaryMember 175 156 } 176 157 export type ConvoStateInitializing = { 177 158 status: ConvoStatus.Initializing 178 159 items: [] 179 - convo: ChatBskyConvoDefs.ConvoView | undefined 160 + convo: ConvoWithDetails | undefined 180 161 error: undefined 181 - sender: ChatBskyActorDefs.ProfileViewBasic | undefined 182 - recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined 183 162 isFetchingHistory: boolean 184 163 hasAllHistory: boolean 185 164 deleteMessage: undefined ··· 188 167 markConvoAccepted: undefined 189 168 addReaction: undefined 190 169 removeReaction: undefined 191 - isGroup: IsGroup 192 - getGroupInfo: GetGroupInfo 193 - getPrimaryMember: GetPrimaryMember 194 170 } 195 171 export type ConvoStateReady = { 196 172 status: ConvoStatus.Ready 197 173 items: ConvoItem[] 198 - convo: ChatBskyConvoDefs.ConvoView 174 + convo: ConvoWithDetails 199 175 error: undefined 200 - sender: ChatBskyActorDefs.ProfileViewBasic 201 - recipients: ChatBskyActorDefs.ProfileViewBasic[] 202 176 isFetchingHistory: boolean 203 177 hasAllHistory: boolean 204 178 deleteMessage: DeleteMessage ··· 207 181 markConvoAccepted: MarkConvoAccepted 208 182 addReaction: AddReaction 209 183 removeReaction: RemoveReaction 210 - isGroup: IsGroup 211 - getGroupInfo: GetGroupInfo 212 - getPrimaryMember: GetPrimaryMember 213 184 } 214 185 export type ConvoStateBackgrounded = { 215 186 status: ConvoStatus.Backgrounded 216 187 items: ConvoItem[] 217 - convo: ChatBskyConvoDefs.ConvoView 188 + convo: ConvoWithDetails 218 189 error: undefined 219 - sender: ChatBskyActorDefs.ProfileViewBasic 220 - recipients: ChatBskyActorDefs.ProfileViewBasic[] 221 190 isFetchingHistory: boolean 222 191 hasAllHistory: boolean 223 192 deleteMessage: DeleteMessage ··· 226 195 markConvoAccepted: MarkConvoAccepted 227 196 addReaction: AddReaction 228 197 removeReaction: RemoveReaction 229 - isGroup: IsGroup 230 - getGroupInfo: GetGroupInfo 231 - getPrimaryMember: GetPrimaryMember 232 198 } 233 199 export type ConvoStateSuspended = { 234 200 status: ConvoStatus.Suspended 235 201 items: ConvoItem[] 236 - convo: ChatBskyConvoDefs.ConvoView 202 + convo: ConvoWithDetails 237 203 error: undefined 238 - sender: ChatBskyActorDefs.ProfileViewBasic 239 - recipients: ChatBskyActorDefs.ProfileViewBasic[] 240 204 isFetchingHistory: boolean 241 205 hasAllHistory: boolean 242 206 deleteMessage: DeleteMessage ··· 245 209 markConvoAccepted: MarkConvoAccepted 246 210 addReaction: AddReaction 247 211 removeReaction: RemoveReaction 248 - isGroup: IsGroup 249 - getGroupInfo: GetGroupInfo 250 - getPrimaryMember: GetPrimaryMember 251 212 } 252 213 export type ConvoStateError = { 253 214 status: ConvoStatus.Error 254 215 items: [] 255 216 convo: undefined 256 217 error: ConvoError 257 - sender: undefined 258 - recipients: undefined 259 218 isFetchingHistory: false 260 219 hasAllHistory: false 261 220 deleteMessage: undefined ··· 264 223 markConvoAccepted: undefined 265 224 addReaction: undefined 266 225 removeReaction: undefined 267 - isGroup: undefined 268 - getGroupInfo: undefined 269 - getPrimaryMember: undefined 270 226 } 271 227 export type ConvoStateDisabled = { 272 228 status: ConvoStatus.Disabled 273 229 items: ConvoItem[] 274 - convo: ChatBskyConvoDefs.ConvoView 230 + convo: ConvoWithDetails 275 231 error: undefined 276 - sender: ChatBskyActorDefs.ProfileViewBasic 277 - recipients: ChatBskyActorDefs.ProfileViewBasic[] 278 232 isFetchingHistory: boolean 279 233 hasAllHistory: boolean 280 234 deleteMessage: DeleteMessage ··· 283 237 markConvoAccepted: MarkConvoAccepted 284 238 addReaction: AddReaction 285 239 removeReaction: RemoveReaction 286 - isGroup: IsGroup 287 - getGroupInfo: GetGroupInfo 288 - getPrimaryMember: GetPrimaryMember 289 240 } 290 241 export type ConvoState = 291 242 | ConvoStateUninitialized
+176
src/state/queries/messages/add-group-members.ts
··· 1 + import { 2 + type ChatBskyActorDefs, 3 + ChatBskyConvoDefs, 4 + type ChatBskyConvoListConvos, 5 + type ChatBskyGroupAddMembers, 6 + } from '@atproto/api' 7 + import { 8 + type InfiniteData, 9 + useMutation, 10 + useQueryClient, 11 + } from '@tanstack/react-query' 12 + 13 + import {DM_SERVICE_HEADERS} from '#/lib/constants' 14 + import {logger} from '#/logger' 15 + import {useProfileQuery} from '#/state/queries/profile' 16 + import {useAgent, useSession} from '#/state/session' 17 + import type * as bsky from '#/types/bsky' 18 + import {RQKEY as CONVO_KEY} from './conversation' 19 + import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' 20 + import {listConvoMembersQueryKey} from './list-convo-members' 21 + 22 + export function useAddGroupMembers( 23 + convoId: string | undefined, 24 + { 25 + onSuccess, 26 + onError, 27 + }: { 28 + onSuccess?: (data: ChatBskyGroupAddMembers.OutputSchema) => void 29 + onError?: (error: Error) => void 30 + }, 31 + ) { 32 + const queryClient = useQueryClient() 33 + const agent = useAgent() 34 + const {currentAccount} = useSession() 35 + const {data: myProfile} = useProfileQuery({did: currentAccount?.did}) 36 + 37 + return useMutation({ 38 + mutationFn: async ({ 39 + members, 40 + }: { 41 + members: string[] 42 + profiles: bsky.profile.AnyProfileView[] 43 + }) => { 44 + if (!convoId) throw new Error('No convoId provided') 45 + const {data} = await agent.chat.bsky.group.addMembers( 46 + {convoId, members}, 47 + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 48 + ) 49 + return data 50 + }, 51 + onMutate: ({profiles}) => { 52 + if (!convoId) return 53 + 54 + const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>( 55 + CONVO_KEY(convoId), 56 + ) 57 + const prevListEntries = queryClient.getQueriesData< 58 + InfiniteData<ChatBskyConvoListConvos.OutputSchema> 59 + >({queryKey: [CONVO_LIST_KEY]}) 60 + const prevMemberList = queryClient.getQueryData< 61 + ChatBskyActorDefs.ProfileViewBasic[] 62 + >(listConvoMembersQueryKey(convoId)) 63 + 64 + const addedBy: ChatBskyActorDefs.ProfileViewBasic | undefined = myProfile 65 + ? { 66 + ...myProfile, 67 + $type: 'chat.bsky.actor.defs#profileViewBasic', 68 + } 69 + : undefined 70 + 71 + const optimisticMembers: ChatBskyActorDefs.ProfileViewBasic[] = 72 + profiles.map(profile => ({ 73 + ...profile, 74 + $type: 'chat.bsky.actor.defs#profileViewBasic', 75 + kind: { 76 + $type: 'chat.bsky.actor.defs#groupConvoMember', 77 + role: 'standard', 78 + addedBy, 79 + }, 80 + })) 81 + 82 + queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>( 83 + CONVO_KEY(convoId), 84 + prev => { 85 + if (!prev) return 86 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return prev 87 + return { 88 + ...prev, 89 + members: [...prev.members, ...optimisticMembers], 90 + kind: { 91 + ...prev.kind, 92 + memberCount: prev.kind.memberCount + optimisticMembers.length, 93 + }, 94 + } 95 + }, 96 + ) 97 + 98 + queryClient.setQueriesData< 99 + InfiniteData<ChatBskyConvoListConvos.OutputSchema> 100 + >({queryKey: [CONVO_LIST_KEY]}, prev => { 101 + if (!prev?.pages) return 102 + return { 103 + ...prev, 104 + pages: prev.pages.map(page => ({ 105 + ...page, 106 + convos: page.convos.map(convo => { 107 + if (convo.id !== convoId) return convo 108 + if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo 109 + return { 110 + ...convo, 111 + members: [...convo.members, ...optimisticMembers], 112 + kind: { 113 + ...convo.kind, 114 + memberCount: 115 + convo.kind.memberCount + optimisticMembers.length, 116 + }, 117 + } 118 + }), 119 + })), 120 + } 121 + }) 122 + 123 + queryClient.setQueryData<ChatBskyActorDefs.ProfileViewBasic[]>( 124 + listConvoMembersQueryKey(convoId), 125 + prev => { 126 + if (!prev) return 127 + return [...prev, ...optimisticMembers] 128 + }, 129 + ) 130 + 131 + return {prevConvo, prevListEntries, prevMemberList} 132 + }, 133 + onSuccess: data => { 134 + if (convoId) { 135 + queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>( 136 + CONVO_KEY(convoId), 137 + data.convo, 138 + ) 139 + 140 + queryClient.setQueriesData< 141 + InfiniteData<ChatBskyConvoListConvos.OutputSchema> 142 + >({queryKey: [CONVO_LIST_KEY]}, prev => { 143 + if (!prev?.pages) return 144 + return { 145 + ...prev, 146 + pages: prev.pages.map(page => ({ 147 + ...page, 148 + convos: page.convos.map(convo => 149 + convo.id === convoId ? data.convo : convo, 150 + ), 151 + })), 152 + } 153 + }) 154 + } 155 + onSuccess?.(data) 156 + }, 157 + onError: (e, _variables, context) => { 158 + logger.error(e) 159 + if (context?.prevConvo && convoId) { 160 + queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo) 161 + } 162 + if (context?.prevListEntries) { 163 + for (const [key, data] of context.prevListEntries) { 164 + queryClient.setQueryData(key, data) 165 + } 166 + } 167 + if (context?.prevMemberList && convoId) { 168 + queryClient.setQueryData( 169 + listConvoMembersQueryKey(convoId), 170 + context.prevMemberList, 171 + ) 172 + } 173 + onError?.(e) 174 + }, 175 + }) 176 + }
+1 -1
src/state/queries/messages/conversation.ts
··· 16 16 RQKEY_ROOT as LIST_CONVOS_KEY, 17 17 } from './list-conversations' 18 18 19 - const RQKEY_ROOT = 'convo' 19 + export const RQKEY_ROOT = 'convo' 20 20 export const RQKEY = (convoId: string) => [RQKEY_ROOT, convoId] 21 21 22 22 export function useConvoQuery({convoId}: {convoId: string}) {
+84
src/state/queries/messages/create-join-link.ts
··· 1 + import { 2 + ChatBskyConvoDefs, 3 + type ChatBskyGroupCreateJoinLink, 4 + type ChatBskyGroupDefs, 5 + } from '@atproto/api' 6 + import {useMutation, useQueryClient} from '@tanstack/react-query' 7 + 8 + import {DM_SERVICE_HEADERS} from '#/lib/constants' 9 + import {logger} from '#/logger' 10 + import {useAgent} from '#/state/session' 11 + import { 12 + rollbackConvoOptimistic, 13 + updateConvoOptimistic, 14 + } from './utils/convo-cache' 15 + 16 + export function useCreateJoinLink( 17 + convoId: string | undefined, 18 + { 19 + onSuccess, 20 + onError, 21 + }: { 22 + onSuccess?: (data: ChatBskyGroupCreateJoinLink.OutputSchema) => void 23 + onError?: (error: Error) => void 24 + }, 25 + ) { 26 + const queryClient = useQueryClient() 27 + const agent = useAgent() 28 + 29 + return useMutation({ 30 + mutationFn: async ({ 31 + joinRule, 32 + requireApproval, 33 + }: { 34 + joinRule: ChatBskyGroupDefs.JoinRule 35 + requireApproval: boolean 36 + }) => { 37 + if (!convoId) throw new Error('No convoId provided') 38 + const {data} = await agent.chat.bsky.group.createJoinLink( 39 + {convoId, joinRule, requireApproval}, 40 + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 41 + ) 42 + return data 43 + }, 44 + onMutate: ({joinRule, requireApproval}) => { 45 + if (!convoId) return 46 + return updateConvoOptimistic(queryClient, convoId, prev => { 47 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined 48 + return { 49 + ...prev, 50 + kind: { 51 + ...prev.kind, 52 + joinLink: { 53 + $type: 'chat.bsky.group.defs#joinLinkView', 54 + code: '', 55 + enabledStatus: 'enabled', 56 + joinRule, 57 + requireApproval, 58 + createdAt: new Date().toISOString(), 59 + }, 60 + }, 61 + } 62 + }) 63 + }, 64 + onSuccess: data => { 65 + if (convoId) { 66 + updateConvoOptimistic(queryClient, convoId, prev => { 67 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined 68 + return { 69 + ...prev, 70 + kind: {...prev.kind, joinLink: data.joinLink}, 71 + } 72 + }) 73 + } 74 + onSuccess?.(data) 75 + }, 76 + onError: (e, _variables, context) => { 77 + logger.error(e) 78 + if (convoId && context) { 79 + rollbackConvoOptimistic(queryClient, convoId, context) 80 + } 81 + onError?.(e) 82 + }, 83 + }) 84 + }
+72
src/state/queries/messages/disable-join-link.ts
··· 1 + import { 2 + ChatBskyConvoDefs, 3 + type ChatBskyGroupDisableJoinLink, 4 + } from '@atproto/api' 5 + import {useMutation, useQueryClient} from '@tanstack/react-query' 6 + 7 + import {DM_SERVICE_HEADERS} from '#/lib/constants' 8 + import {logger} from '#/logger' 9 + import {useAgent} from '#/state/session' 10 + import { 11 + rollbackConvoOptimistic, 12 + updateConvoOptimistic, 13 + } from './utils/convo-cache' 14 + 15 + export function useDisableJoinLink( 16 + convoId: string | undefined, 17 + { 18 + onSuccess, 19 + onError, 20 + }: { 21 + onSuccess?: (data: ChatBskyGroupDisableJoinLink.OutputSchema) => void 22 + onError?: (error: Error) => void 23 + }, 24 + ) { 25 + const queryClient = useQueryClient() 26 + const agent = useAgent() 27 + 28 + return useMutation({ 29 + mutationFn: async () => { 30 + if (!convoId) throw new Error('No convoId provided') 31 + const {data} = await agent.chat.bsky.group.disableJoinLink( 32 + {convoId}, 33 + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 34 + ) 35 + return data 36 + }, 37 + onMutate: () => { 38 + if (!convoId) return 39 + return updateConvoOptimistic(queryClient, convoId, prev => { 40 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) { 41 + return undefined 42 + } 43 + return { 44 + ...prev, 45 + kind: { 46 + ...prev.kind, 47 + joinLink: {...prev.kind.joinLink, enabledStatus: 'disabled'}, 48 + }, 49 + } 50 + }) 51 + }, 52 + onSuccess: data => { 53 + if (convoId) { 54 + updateConvoOptimistic(queryClient, convoId, prev => { 55 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined 56 + return { 57 + ...prev, 58 + kind: {...prev.kind, joinLink: data.joinLink}, 59 + } 60 + }) 61 + } 62 + onSuccess?.(data) 63 + }, 64 + onError: (e, _variables, context) => { 65 + logger.error(e) 66 + if (convoId && context) { 67 + rollbackConvoOptimistic(queryClient, convoId, context) 68 + } 69 + onError?.(e) 70 + }, 71 + }) 72 + }
+55
src/state/queries/messages/edit-group-chat-name.ts
··· 1 + import {ChatBskyConvoDefs, type ChatBskyGroupEditGroup} from '@atproto/api' 2 + import {useMutation, useQueryClient} from '@tanstack/react-query' 3 + 4 + import {DM_SERVICE_HEADERS} from '#/lib/constants' 5 + import {logger} from '#/logger' 6 + import {useAgent} from '#/state/session' 7 + import { 8 + rollbackConvoOptimistic, 9 + updateConvoOptimistic, 10 + } from './utils/convo-cache' 11 + 12 + export function useEditGroupChatName( 13 + convoId: string | undefined, 14 + { 15 + onSuccess, 16 + onError, 17 + }: { 18 + onSuccess?: (data: ChatBskyGroupEditGroup.OutputSchema) => void 19 + onError?: (error: Error) => void 20 + }, 21 + ) { 22 + const queryClient = useQueryClient() 23 + const agent = useAgent() 24 + 25 + return useMutation({ 26 + mutationFn: async ({name: groupName}: {name: string}) => { 27 + if (!convoId) throw new Error('No convoId provided') 28 + const {data} = await agent.chat.bsky.group.editGroup( 29 + {convoId, name: groupName}, 30 + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 31 + ) 32 + return data 33 + }, 34 + onMutate: ({name: groupName}) => { 35 + if (!convoId) return 36 + return updateConvoOptimistic(queryClient, convoId, prev => { 37 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined 38 + return { 39 + ...prev, 40 + kind: {...prev.kind, name: groupName}, 41 + } 42 + }) 43 + }, 44 + onSuccess: data => { 45 + onSuccess?.(data) 46 + }, 47 + onError: (e, _variables, context) => { 48 + logger.error(e) 49 + if (convoId && context) { 50 + rollbackConvoOptimistic(queryClient, convoId, context) 51 + } 52 + onError?.(e) 53 + }, 54 + }) 55 + }
+30 -21
src/state/queries/messages/edit-group-name.ts src/state/queries/messages/remove-from-group.ts
··· 1 1 import { 2 - ChatBskyConvoDefs, 2 + type ChatBskyActorDefs, 3 + type ChatBskyConvoDefs, 3 4 type ChatBskyConvoListConvos, 4 - type ChatBskyGroupEditGroup, 5 + type ChatBskyGroupRemoveMembers, 5 6 } from '@atproto/api' 6 7 import { 7 8 type InfiniteData, ··· 14 15 import {useAgent} from '#/state/session' 15 16 import {RQKEY as CONVO_KEY} from './conversation' 16 17 import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' 18 + import {listConvoMembersQueryKey} from './list-convo-members' 17 19 18 - export function useEditGroupName( 20 + export function useRemoveFromGroupChat( 19 21 convoId: string | undefined, 20 22 { 21 23 onSuccess, 22 24 onError, 23 25 }: { 24 - onSuccess?: (data: ChatBskyGroupEditGroup.OutputSchema) => void 26 + onSuccess?: (data: ChatBskyGroupRemoveMembers.OutputSchema) => void 25 27 onError?: (error: Error) => void 26 28 }, 27 29 ) { ··· 29 31 const agent = useAgent() 30 32 31 33 return useMutation({ 32 - mutationFn: async ({name: groupName}: {name: string}) => { 34 + mutationFn: async ({members}: {members: string[]}) => { 33 35 if (!convoId) throw new Error('No convoId provided') 34 - const {data} = await agent.chat.bsky.group.editGroup( 35 - {convoId, name: groupName}, 36 + const {data} = await agent.chat.bsky.group.removeMembers( 37 + {convoId, members}, 36 38 {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 37 39 ) 38 40 return data 39 41 }, 40 - onMutate: ({name: groupName}) => { 42 + onMutate: ({members}) => { 41 43 if (!convoId) return 42 44 43 45 const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>( ··· 46 48 const prevListEntries = queryClient.getQueriesData< 47 49 InfiniteData<ChatBskyConvoListConvos.OutputSchema> 48 50 >({queryKey: [CONVO_LIST_KEY]}) 51 + const prevMemberList = queryClient.getQueryData< 52 + ChatBskyActorDefs.ProfileViewBasic[] 53 + >(listConvoMembersQueryKey(convoId)) 49 54 50 - // Update for a single chat thread 51 55 queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>( 52 56 CONVO_KEY(convoId), 53 57 prev => { 54 58 if (!prev) return 55 - if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return prev 56 59 return { 57 60 ...prev, 58 - kind: { 59 - ...prev.kind, 60 - name: groupName, 61 - }, 61 + members: prev.members.filter(m => !members.includes(m.did)), 62 62 } 63 63 }, 64 64 ) 65 65 66 - // Update for the chat list 67 66 queryClient.setQueriesData< 68 67 InfiniteData<ChatBskyConvoListConvos.OutputSchema> 69 68 >({queryKey: [CONVO_LIST_KEY]}, prev => { ··· 74 73 ...page, 75 74 convos: page.convos.map(convo => { 76 75 if (convo.id !== convoId) return convo 77 - if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo 78 76 return { 79 77 ...convo, 80 - kind: { 81 - ...convo.kind, 82 - name: groupName, 83 - }, 78 + members: convo.members.filter(m => !members.includes(m.did)), 84 79 } 85 80 }), 86 81 })), 87 82 } 88 83 }) 89 84 90 - return {prevConvo, prevListEntries} 85 + queryClient.setQueryData<ChatBskyActorDefs.ProfileViewBasic[]>( 86 + listConvoMembersQueryKey(convoId), 87 + prev => { 88 + if (!prev) return 89 + return prev.filter(m => !members.includes(m.did)) 90 + }, 91 + ) 92 + 93 + return {prevConvo, prevListEntries, prevMemberList} 91 94 }, 92 95 onSuccess: data => { 93 96 onSuccess?.(data) ··· 101 104 for (const [key, data] of context.prevListEntries) { 102 105 queryClient.setQueryData(key, data) 103 106 } 107 + } 108 + if (context?.prevMemberList && convoId) { 109 + queryClient.setQueryData( 110 + listConvoMembersQueryKey(convoId), 111 + context.prevMemberList, 112 + ) 104 113 } 105 114 onError?.(e) 106 115 },
+79
src/state/queries/messages/edit-join-link.ts
··· 1 + import { 2 + ChatBskyConvoDefs, 3 + type ChatBskyGroupDefs, 4 + type ChatBskyGroupEditJoinLink, 5 + } from '@atproto/api' 6 + import {useMutation, useQueryClient} from '@tanstack/react-query' 7 + 8 + import {DM_SERVICE_HEADERS} from '#/lib/constants' 9 + import {logger} from '#/logger' 10 + import {useAgent} from '#/state/session' 11 + import { 12 + rollbackConvoOptimistic, 13 + updateConvoOptimistic, 14 + } from './utils/convo-cache' 15 + 16 + export function useEditJoinLink( 17 + convoId: string | undefined, 18 + { 19 + onSuccess, 20 + onError, 21 + }: { 22 + onSuccess?: (data: ChatBskyGroupEditJoinLink.OutputSchema) => void 23 + onError?: (error: Error) => void 24 + }, 25 + ) { 26 + const queryClient = useQueryClient() 27 + const agent = useAgent() 28 + 29 + return useMutation({ 30 + mutationFn: async ({ 31 + joinRule, 32 + requireApproval, 33 + }: { 34 + joinRule: ChatBskyGroupDefs.JoinRule 35 + requireApproval: boolean 36 + }) => { 37 + if (!convoId) throw new Error('No convoId provided') 38 + const {data} = await agent.chat.bsky.group.editJoinLink( 39 + {convoId, joinRule, requireApproval}, 40 + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 41 + ) 42 + return data 43 + }, 44 + onMutate: ({joinRule, requireApproval}) => { 45 + if (!convoId) return 46 + return updateConvoOptimistic(queryClient, convoId, prev => { 47 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) { 48 + return undefined 49 + } 50 + return { 51 + ...prev, 52 + kind: { 53 + ...prev.kind, 54 + joinLink: {...prev.kind.joinLink, joinRule, requireApproval}, 55 + }, 56 + } 57 + }) 58 + }, 59 + onSuccess: data => { 60 + if (convoId) { 61 + updateConvoOptimistic(queryClient, convoId, prev => { 62 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined 63 + return { 64 + ...prev, 65 + kind: {...prev.kind, joinLink: data.joinLink}, 66 + } 67 + }) 68 + } 69 + onSuccess?.(data) 70 + }, 71 + onError: (e, _variables, context) => { 72 + logger.error(e) 73 + if (convoId && context) { 74 + rollbackConvoOptimistic(queryClient, convoId, context) 75 + } 76 + onError?.(e) 77 + }, 78 + }) 79 + }
+69
src/state/queries/messages/enable-join-link.ts
··· 1 + import {ChatBskyConvoDefs, type ChatBskyGroupEnableJoinLink} from '@atproto/api' 2 + import {useMutation, useQueryClient} from '@tanstack/react-query' 3 + 4 + import {DM_SERVICE_HEADERS} from '#/lib/constants' 5 + import {logger} from '#/logger' 6 + import {useAgent} from '#/state/session' 7 + import { 8 + rollbackConvoOptimistic, 9 + updateConvoOptimistic, 10 + } from './utils/convo-cache' 11 + 12 + export function useEnableJoinLink( 13 + convoId: string | undefined, 14 + { 15 + onSuccess, 16 + onError, 17 + }: { 18 + onSuccess?: (data: ChatBskyGroupEnableJoinLink.OutputSchema) => void 19 + onError?: (error: Error) => void 20 + }, 21 + ) { 22 + const queryClient = useQueryClient() 23 + const agent = useAgent() 24 + 25 + return useMutation({ 26 + mutationFn: async () => { 27 + if (!convoId) throw new Error('No convoId provided') 28 + const {data} = await agent.chat.bsky.group.enableJoinLink( 29 + {convoId}, 30 + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 31 + ) 32 + return data 33 + }, 34 + onMutate: () => { 35 + if (!convoId) return 36 + return updateConvoOptimistic(queryClient, convoId, prev => { 37 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind) || !prev.kind.joinLink) { 38 + return undefined 39 + } 40 + return { 41 + ...prev, 42 + kind: { 43 + ...prev.kind, 44 + joinLink: {...prev.kind.joinLink, enabledStatus: 'enabled'}, 45 + }, 46 + } 47 + }) 48 + }, 49 + onSuccess: data => { 50 + if (convoId) { 51 + updateConvoOptimistic(queryClient, convoId, prev => { 52 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined 53 + return { 54 + ...prev, 55 + kind: {...prev.kind, joinLink: data.joinLink}, 56 + } 57 + }) 58 + } 59 + onSuccess?.(data) 60 + }, 61 + onError: (e, _variables, context) => { 62 + logger.error(e) 63 + if (convoId && context) { 64 + rollbackConvoOptimistic(queryClient, convoId, context) 65 + } 66 + onError?.(e) 67 + }, 68 + }) 69 + }
+5 -1
src/state/queries/messages/get-convo-availability.ts
··· 7 7 const RQKEY_ROOT = 'convo-availability' 8 8 export const RQKEY = (did: string) => [RQKEY_ROOT, did] 9 9 10 - export function useGetConvoAvailabilityQuery(did: string) { 10 + export function useGetConvoAvailabilityQuery( 11 + did: string, 12 + {enabled = true}: {enabled?: boolean} = {}, 13 + ) { 11 14 const agent = useAgent() 12 15 13 16 return useQuery({ ··· 21 24 return data 22 25 }, 23 26 staleTime: STALE.INFINITY, 27 + enabled, 24 28 }) 25 29 }
+2 -2
src/state/queries/messages/leave-conversation.ts
··· 71 71 return {prevPages} 72 72 }, 73 73 onSuccess: data => { 74 - queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) 74 + void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) 75 75 onSuccess?.(data) 76 76 }, 77 77 onError: (error, _, context) => { ··· 89 89 } 90 90 }, 91 91 ) 92 - queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) 92 + void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) 93 93 onError?.(error) 94 94 }, 95 95 })
+2 -2
src/state/queries/messages/list-conversations.tsx
··· 105 105 106 106 const debouncedRefetch = useMemo(() => { 107 107 const refetchAndInvalidate = () => { 108 - refetch() 109 - queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]}) 108 + void refetch() 109 + void queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]}) 110 110 } 111 111 return throttle(refetchAndInvalidate, 500, { 112 112 leading: true,
+122
src/state/queries/messages/list-convo-members.ts
··· 1 + import {useEffect} from 'react' 2 + import {type ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' 3 + import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' 4 + 5 + import {DM_SERVICE_HEADERS} from '#/lib/constants' 6 + import {useMessagesEventBus} from '#/state/messages/events' 7 + import {STALE} from '#/state/queries' 8 + import {createQueryKey} from '#/state/queries/util' 9 + import {useAgent} from '#/state/session' 10 + import * as bsky from '#/types/bsky' 11 + 12 + const RQKEY_ROOT = 'listConvoMembers' 13 + export const listConvoMembersQueryKey = (convoId: string) => 14 + createQueryKey(RQKEY_ROOT, {convoId}) 15 + 16 + // group chat size is 50, so should fetch the whole list in one go 17 + const LIMIT = 50 18 + 19 + export function useListConvoMembersQuery({ 20 + convoId, 21 + placeholderData, 22 + }: { 23 + convoId: string 24 + placeholderData?: ChatBskyActorDefs.ProfileViewBasic[] 25 + }) { 26 + const agent = useAgent() 27 + const queryClient = useQueryClient() 28 + const messagesBus = useMessagesEventBus() 29 + 30 + useEffect(() => { 31 + const unsub = messagesBus.on( 32 + ev => { 33 + if (ev.type !== 'logs') return 34 + 35 + function mutateList( 36 + fn: ( 37 + update: ChatBskyActorDefs.ProfileViewBasic[], 38 + ) => ChatBskyActorDefs.ProfileViewBasic[], 39 + ) { 40 + queryClient.setQueryData<ChatBskyActorDefs.ProfileViewBasic[]>( 41 + listConvoMembersQueryKey(convoId), 42 + old => { 43 + if (!old) return // query doesn't exist yet, skip 44 + return fn(old) 45 + }, 46 + ) 47 + } 48 + 49 + for (const log of ev.logs) { 50 + if (ChatBskyConvoDefs.isLogAddMember(log)) { 51 + const data = log.message.data 52 + if ( 53 + bsky.dangerousIsType<ChatBskyConvoDefs.SystemMessageDataAddMember>( 54 + data, 55 + ChatBskyConvoDefs.isSystemMessageDataAddMember, 56 + ) 57 + ) { 58 + const newMember = log.relatedProfiles.find( 59 + r => r.did === data.member.did, 60 + ) 61 + if (newMember) { 62 + mutateList(list => list.concat(newMember)) 63 + } 64 + } 65 + } else if (ChatBskyConvoDefs.isLogRemoveMember(log)) { 66 + const data = log.message.data 67 + if ( 68 + bsky.dangerousIsType<ChatBskyConvoDefs.SystemMessageDataRemoveMember>( 69 + data, 70 + ChatBskyConvoDefs.isSystemMessageDataRemoveMember, 71 + ) 72 + ) { 73 + mutateList(list => list.filter(m => m.did !== data.member.did)) 74 + } 75 + } 76 + } 77 + }, 78 + {convoId}, 79 + ) 80 + return () => unsub() 81 + }, [convoId, messagesBus, queryClient]) 82 + 83 + return useQuery({ 84 + queryKey: listConvoMembersQueryKey(convoId), 85 + queryFn: async () => { 86 + const members = [] 87 + let cursor 88 + 89 + do { 90 + const {data} = await agent.chat.bsky.convo.getConvoMembers( 91 + {convoId, cursor, limit: LIMIT}, 92 + {headers: DM_SERVICE_HEADERS}, 93 + ) 94 + members.push(...data.members) 95 + cursor = data.cursor 96 + } while (cursor) 97 + 98 + return members 99 + }, 100 + staleTime: STALE.MINUTES.THIRTY, 101 + placeholderData, 102 + }) 103 + } 104 + 105 + export function* findAllProfilesInQueryData( 106 + queryClient: QueryClient, 107 + did: string, 108 + ): Generator<ChatBskyActorDefs.ProfileViewBasic, void> { 109 + const queryDatas = queryClient.getQueriesData< 110 + ChatBskyActorDefs.ProfileViewBasic[] 111 + >({ 112 + queryKey: [RQKEY_ROOT], 113 + }) 114 + for (const [_queryKey, queryData] of queryDatas) { 115 + if (!queryData) continue 116 + for (const member of queryData) { 117 + if (member.did === did) { 118 + yield member 119 + } 120 + } 121 + } 122 + }
+64
src/state/queries/messages/lock-conversation.ts
··· 1 + import {ChatBskyConvoDefs, type ChatBskyConvoLockConvo} from '@atproto/api' 2 + import {useMutation, useQueryClient} from '@tanstack/react-query' 3 + 4 + import {DM_SERVICE_HEADERS} from '#/lib/constants' 5 + import {useAgent} from '#/state/session' 6 + import { 7 + rollbackConvoOptimistic, 8 + updateConvoOptimistic, 9 + } from './utils/convo-cache' 10 + 11 + export function useLockConvo( 12 + convoId: string | undefined, 13 + { 14 + onSuccess, 15 + onError, 16 + }: { 17 + onSuccess?: (data: ChatBskyConvoLockConvo.OutputSchema) => void 18 + onError?: (error: Error, variables: {lock: boolean}) => void 19 + }, 20 + ) { 21 + const queryClient = useQueryClient() 22 + const agent = useAgent() 23 + 24 + return useMutation({ 25 + mutationFn: async ({lock}: {lock: boolean}) => { 26 + if (!convoId) throw new Error('No convoId provided') 27 + if (lock) { 28 + const {data} = await agent.chat.bsky.convo.lockConvo( 29 + {convoId}, 30 + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 31 + ) 32 + return data 33 + } else { 34 + const {data} = await agent.chat.bsky.convo.unlockConvo( 35 + {convoId}, 36 + {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, 37 + ) 38 + return data 39 + } 40 + }, 41 + onMutate: ({lock}) => { 42 + if (!convoId) return 43 + return updateConvoOptimistic(queryClient, convoId, prev => { 44 + if (!ChatBskyConvoDefs.isGroupConvo(prev.kind)) return undefined 45 + return { 46 + ...prev, 47 + kind: { 48 + ...prev.kind, 49 + lockStatus: lock ? 'locked' : 'unlocked', 50 + }, 51 + } 52 + }) 53 + }, 54 + onSuccess: data => { 55 + onSuccess?.(data) 56 + }, 57 + onError: (e, variables, context) => { 58 + if (convoId && context) { 59 + rollbackConvoOptimistic(queryClient, convoId, context) 60 + } 61 + onError?.(e, variables) 62 + }, 63 + }) 64 + }
+12 -60
src/state/queries/messages/mute-conversation.ts
··· 1 - import { 2 - type ChatBskyConvoDefs, 3 - type ChatBskyConvoListConvos, 4 - type ChatBskyConvoMuteConvo, 5 - } from '@atproto/api' 6 - import { 7 - type InfiniteData, 8 - useMutation, 9 - useQueryClient, 10 - } from '@tanstack/react-query' 1 + import {type ChatBskyConvoMuteConvo} from '@atproto/api' 2 + import {useMutation, useQueryClient} from '@tanstack/react-query' 11 3 12 4 import {DM_SERVICE_HEADERS} from '#/lib/constants' 13 5 import {useAgent} from '#/state/session' 14 - import {RQKEY as CONVO_KEY} from './conversation' 15 - import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations' 6 + import { 7 + rollbackConvoOptimistic, 8 + updateConvoOptimistic, 9 + } from './utils/convo-cache' 16 10 17 11 export function useMuteConvo( 18 12 convoId: string | undefined, ··· 46 40 }, 47 41 onMutate: ({mute}) => { 48 42 if (!convoId) return 49 - 50 - const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>( 51 - CONVO_KEY(convoId), 52 - ) 53 - const prevListEntries = queryClient.getQueriesData< 54 - InfiniteData<ChatBskyConvoListConvos.OutputSchema> 55 - >({queryKey: [CONVO_LIST_KEY]}) 56 - 57 - // Update for a single chat thread 58 - queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>( 59 - CONVO_KEY(convoId), 60 - prev => { 61 - if (!prev) return 62 - return { 63 - ...prev, 64 - muted: mute, 65 - } 66 - }, 67 - ) 68 - 69 - // Update for the chat list 70 - queryClient.setQueriesData< 71 - InfiniteData<ChatBskyConvoListConvos.OutputSchema> 72 - >({queryKey: [CONVO_LIST_KEY]}, prev => { 73 - if (!prev?.pages) return 74 - return { 75 - ...prev, 76 - pages: prev.pages.map(page => ({ 77 - ...page, 78 - convos: page.convos.map(convo => { 79 - if (convo.id !== convoId) return convo 80 - return { 81 - ...convo, 82 - muted: mute, 83 - } 84 - }), 85 - })), 86 - } 87 - }) 88 - 89 - return {prevConvo, prevListEntries} 43 + return updateConvoOptimistic(queryClient, convoId, prev => ({ 44 + ...prev, 45 + muted: mute, 46 + })) 90 47 }, 91 48 onSuccess: data => { 92 49 onSuccess?.(data) 93 50 }, 94 51 onError: (e, _variables, context) => { 95 - if (context?.prevConvo && convoId) { 96 - queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo) 97 - } 98 - if (context?.prevListEntries) { 99 - for (const [key, data] of context.prevListEntries) { 100 - queryClient.setQueryData(key, data) 101 - } 52 + if (convoId && context) { 53 + rollbackConvoOptimistic(queryClient, convoId, context) 102 54 } 103 55 onError?.(e) 104 56 },
+87
src/state/queries/messages/utils/convo-cache.ts
··· 1 + import { 2 + type ChatBskyConvoDefs, 3 + type ChatBskyConvoListConvos, 4 + } from '@atproto/api' 5 + import { 6 + type InfiniteData, 7 + type QueryClient, 8 + type QueryKey, 9 + } from '@tanstack/react-query' 10 + 11 + import {RQKEY as CONVO_KEY} from '../conversation' 12 + import {RQKEY_ROOT as CONVO_LIST_KEY} from '../list-conversations' 13 + 14 + type ConvoUpdater = ( 15 + prev: ChatBskyConvoDefs.ConvoView, 16 + ) => ChatBskyConvoDefs.ConvoView | undefined 17 + 18 + export type ConvoCacheSnapshot = { 19 + prevConvo: ChatBskyConvoDefs.ConvoView | undefined 20 + prevListEntries: Array< 21 + [QueryKey, InfiniteData<ChatBskyConvoListConvos.OutputSchema> | undefined] 22 + > 23 + } 24 + 25 + /** 26 + * Writes an optimistic update to a convo across both the single-convo and 27 + * convo-list caches. The updater receives the current ConvoView and returns 28 + * the next one - return undefined to bail out (e.g. when the convo's kind 29 + * doesn't match what the mutation requires). Returns a snapshot that can be 30 + * passed to `rollbackConvoOptimistic`. 31 + */ 32 + export function updateConvoOptimistic( 33 + queryClient: QueryClient, 34 + convoId: string, 35 + updater: ConvoUpdater, 36 + ): ConvoCacheSnapshot { 37 + const prevConvo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>( 38 + CONVO_KEY(convoId), 39 + ) 40 + const prevListEntries = queryClient.getQueriesData< 41 + InfiniteData<ChatBskyConvoListConvos.OutputSchema> 42 + >({queryKey: [CONVO_LIST_KEY]}) 43 + 44 + queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>( 45 + CONVO_KEY(convoId), 46 + prev => { 47 + if (!prev) return 48 + const next = updater(prev) 49 + return next ?? prev 50 + }, 51 + ) 52 + 53 + queryClient.setQueriesData< 54 + InfiniteData<ChatBskyConvoListConvos.OutputSchema> 55 + >({queryKey: [CONVO_LIST_KEY]}, prev => { 56 + if (!prev?.pages) return 57 + return { 58 + ...prev, 59 + pages: prev.pages.map(page => ({ 60 + ...page, 61 + convos: page.convos.map(convo => { 62 + if (convo.id !== convoId) return convo 63 + const next = updater(convo) 64 + return next ?? convo 65 + }), 66 + })), 67 + } 68 + }) 69 + 70 + return {prevConvo, prevListEntries} 71 + } 72 + 73 + /** 74 + * Restores the caches to the state captured by `updateConvoOptimistic`. 75 + */ 76 + export function rollbackConvoOptimistic( 77 + queryClient: QueryClient, 78 + convoId: string, 79 + snapshot: ConvoCacheSnapshot, 80 + ) { 81 + if (snapshot.prevConvo) { 82 + queryClient.setQueryData(CONVO_KEY(convoId), snapshot.prevConvo) 83 + } 84 + for (const [key, data] of snapshot.prevListEntries) { 85 + queryClient.setQueryData(key, data) 86 + } 87 + }