diff --git a/.claude/skills/accessibility/SKILL.md b/.claude/skills/accessibility/SKILL.md index ab32176cce..d815550568 100644 --- a/.claude/skills/accessibility/SKILL.md +++ b/.claude/skills/accessibility/SKILL.md @@ -10,7 +10,7 @@ Use this skill whenever code changes can affect screen-reader users (VoiceOver o ## Non-negotiable rules 1. **Native semantics first.** Use `Pressable`, `TextInput`, `Switch`, `Image` directly. Use `accessibilityRole` only when native semantics cannot represent the widget (`menu`, `menuitem`, `progressbar`, `radio`, `checkbox`, `article`, `alert`, `tablist`, `tab`). -2. **Never hardcode English** in `accessibilityLabel`/`accessibilityHint`/announcement strings. For SDK `Button`, pass `accessibilityLabelKey='a11y/...'` (and `accessibilityLabelParams` when needed). For non-Button components, use `useA11yLabel('a11y/...', params)` or `t('a11y/...')` directly when you don't need the disabled-state short-circuit. Add the key to all 12 locale files in `package/src/i18n/`. +2. **Never hardcode English** in `accessibilityLabel`/`accessibilityHint`/announcement strings. For SDK `Button`, pass `accessibilityLabelKey='a11y/...'` (and `accessibilityLabelParams` when needed). For non-Button components, use `useA11yLabel('a11y/...', params)` or `t('a11y/...')` directly when you don't need the disabled-state short-circuit. Add the key to all 12 locale files in `package/src/i18n/`. You can omit a11y keys if a button contains a text label that describes what it does. 3. **Gate behavior on `useAccessibilityContext().enabled`.** A11y is opt-in. New listeners, subscriptions, and announcer mounts must be no-ops when `enabled` is false. New `accessibilityRole`/`accessibilityState` props are fine to render unconditionally — they cost ~zero. 4. **One focusable target per action.** Don't nest `Pressable` inside `Pressable`. Mark inner decorative views with `accessibilityElementsHidden` (iOS) + `importantForAccessibility='no-hide-descendants'` (Android) so the parent carries the label. 5. **Decorative visuals stay hidden from AT.** Icon-only buttons must carry an `accessibilityLabel` on the wrapper, and the SVG icon should be hidden. @@ -68,6 +68,7 @@ const a11yProps = useResolvedModalAccessibilityProps(); ``` This returns: + - iOS: `{ accessibilityViewIsModal: true }` - Android: `{ importantForAccessibility: 'yes' }` - Either platform when `enabled` is false: `{}` @@ -81,9 +82,7 @@ Mobile gestures (long-press, hold-to-record, pinch/pan) must have a tap-equivale ```tsx const { audioRecorderTapMode } = useAccessibilityContext(); const screenReaderOn = useScreenReaderEnabled(); -const useTapMode = - audioRecorderTapMode === 'always' || - (audioRecorderTapMode === 'auto' && screenReaderOn); +const useTapMode = audioRecorderTapMode === 'always' || (audioRecorderTapMode === 'auto' && screenReaderOn); ``` Three-state semantics: `'auto'` (swap when SR is on), `'always'` (swap for everyone), `'never'` (integrator handles). @@ -109,10 +108,12 @@ Disable spring animations and limit fade durations when this is true. ## Testing requirements per change Minimum: + - Unit tests for new keyboard/focus/semantics behavior in nearest `__tests__/`. - Use `@testing-library/react-native` semantic queries: `getByRole`, `getByLabelText`, `getByA11yState`, `getByA11yValue`. Recommended for non-trivial changes: + - Render with `` and assert the accessible variant renders. - Render with `` and assert the legacy behavior is unchanged (no extra buttons, no listeners). @@ -143,6 +144,7 @@ Recommended for non-trivial changes: ## Cross-SDK parity API shapes mirror [`stream-chat-react#3146`](https://github.com/GetStream/stream-chat-react/pull/3146): + - `useAccessibilityAnnouncer` ≈ React's `useAriaLiveAnnouncer` - `useIncomingMessageAnnouncements` — same params, same throttle/batch logic - `a11y/*` i18n namespace shared diff --git a/.claude/skills/rtl/SKILL.md b/.claude/skills/rtl/SKILL.md new file mode 100644 index 0000000000..8e593adabf --- /dev/null +++ b/.claude/skills/rtl/SKILL.md @@ -0,0 +1,269 @@ +--- +name: rtl +description: Audit and maintain RTL (right-to-left) layout compatibility in stream-chat-react-native. Use when changing styles, positioning, flex layouts, swipe gestures, animated transforms, icons, text alignment, or anything that has a horizontal/directional axis. +--- + +# RTL Compatibility Audit (stream-chat-react-native) + +Use this skill whenever code changes can affect users in RTL locales (Hebrew `he` ships today; Arabic/Persian/Urdu integrators are common). React Native flips some layout properties automatically via `I18nManager.isRTL`, but absolute positioning, hardcoded margins/paddings, transforms, swipe gestures, and SVG icons must be handled by hand. + +When the user asks for an "RTL audit" or "RTL review," walk the [Audit checklist](#audit-checklist) against the diff (or the named files), then return findings grouped by severity. When writing new code, apply the [Patterns to follow](#patterns-to-follow) rather than just the anti-patterns at the end. + +## Non-negotiable rules + +1. **Read direction at runtime.** Use `I18nManager.isRTL` from `react-native`. Never assume LTR. Never assume a value at module load time *only* — `I18nManager.isRTL` is a static snapshot per JS bundle (RN reloads the bundle on direction change), so module-scope reads are fine, but state that depends on it must not be cached across user-driven direction toggles within a single session unless the bundle is reloaded. +2. **Logical properties beat physical ones.** Prefer `start`/`end` variants (`paddingStart`, `marginEnd`, `borderStartWidth`, `insetStart`) over `left`/`right` for spacing and borders. RN auto-flips `start`/`end` based on `I18nManager.isRTL`. The exception is absolute positioning — RN does NOT auto-flip `left`/`right` on absolutely positioned elements; those need an explicit `I18nManager.isRTL` conditional. +3. **flexDirection: 'row' auto-flips.** Default `flexDirection: 'row'` reverses in RTL. Do NOT counter this by manually setting `'row-reverse'` for "alignment fixes" — that double-flips and breaks RTL. Only use `'row-reverse'` when the visual order must be opposite of reading order in both directions. +4. **Text alignment defaults to writing direction.** For `Text`, default `textAlign` is already direction-aware. Set `textAlign: 'left'`/`'right'` ONLY when you need a fixed visual side; otherwise omit it or use `textAlign: 'auto'`. When you need "align to start of reading direction" explicitly, write `textAlign: I18nManager.isRTL ? 'right' : 'left'`. +5. **`writingDirection` on Text that mixes scripts.** When user-generated text could contain RTL characters (messages, channel names, member names, poll options, inputs), set `writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr'` (iOS) so bidi resolution matches the app direction. Or wrap with `WritingDirectionAwareText` from `package/src/components/RTLComponents/`. +6. **Mirror directional icons; don't mirror neutral ones.** Arrows, chevrons, reply, send, thread, search-magnifier, message-bubble must flip in RTL. Symmetric icons (checkmark, bell, settings gear, like-heart, emoji face) must NOT flip. Use SVG `transform={I18nManager.isRTL ? 'matrix(-1 0 0 1 W 0)' : undefined}` where `W` is the SVG width. +7. **Swipe gestures need a direction multiplier.** Any gesture that moves content along the X-axis (swipe-to-reply, swipe-to-delete, paging) must multiply `translationX` by `I18nManager.isRTL ? -1 : 1`. Otherwise swipe-from-right-to-left does the wrong thing in RTL. +8. **Backward-compatible.** RTL fixes should not change LTR behavior. When in doubt, the conditional form `I18nManager.isRTL ? rtl : ltr` is safer than swapping a default. + +## Where to put what + +- **Foundation primitives & helpers** → `package/src/utils/` (e.g., `rtlMirrorSwitchStyle.ts`) and `package/src/components/RTLComponents/` (e.g., `WritingDirectionAwareText.tsx`). +- **Component-level RTL handling** → in the component itself. Read `I18nManager.isRTL` at the top of the render or in `useStyles()`. +- **Icons** → `package/src/icons/`. Existing pattern: SVG `transform="matrix(-1 0 0 1 0)"` gated on `I18nManager.isRTL`. +- **Theme** → there are no RTL-specific theme tokens. Don't add new directional values to `theme.ts` (`paddingLeft`, `marginRight`); use `start`/`end` keys instead, or compute in the consumer. +- **Locale files** → `package/src/i18n/he.json` is the only shipped RTL locale. Test RTL by setting `I18nManager.forceRTL(true)` + reload, or by switching the device to Hebrew. +- **Platform divergence (iOS vs Android)** → some platforms (iOS) require a transform mirror for native components like `Switch`. Use `useRtlMirrorSwitchStyle()` rather than inlining. + +## Patterns to follow + +### 1) Reading direction + +```tsx +import { I18nManager } from 'react-native'; + +const isRTL = I18nManager.isRTL; +``` + +Keep this at component top, or compute style objects with it inside `useStyles()`. Don't gate behavior on `Platform.OS` and assume direction — RTL works on both iOS and Android. + +### 2) Spacing: prefer logical properties + +```tsx +// GOOD — auto-flips +{ marginStart: 8, paddingEnd: 12, borderStartWidth: 1 } + +// AVOID for spacing — does not flip +{ marginLeft: 8, paddingRight: 12, borderLeftWidth: 1 } +``` + +When migrating, the rename is direct: `Left` → `Start`, `Right` → `End`. Test once in LTR + once in RTL. + +### 3) Absolute positioning: conditional + +`left` / `right` on absolutely positioned elements do **not** auto-flip. Either use `insetStart`/`insetEnd` (RN 0.71+) or branch: + +```tsx +const positionStyle = I18nManager.isRTL ? { left: 0 } : { right: 0 }; +``` + +Common offenders: scroll-to-bottom button, online-presence dot on avatars, badge counts, overlay anchors, swipe-action content underneath a row. + +### 4) Message-bubble alignment + +Own messages render on the **end** side, others on the **start**. The `alignment` value (`'left' | 'right'`) refers to *physical* sides for layout decisions, but for *overlays/menus* anchored to the bubble, flip it through: + +```tsx +const overlayItemAlignment = I18nManager.isRTL + ? alignment === 'right' ? 'left' : 'right' + : alignment; +``` + +(see `package/src/components/Message/Message.tsx:420-431`) + +### 5) Swipe-to-reply / pan gestures + +```tsx +const swipeDirectionMultiplier = I18nManager.isRTL ? -1 : 1; + +.onChange(({ translationX }) => { + const swipeDistance = translationX * swipeDirectionMultiplier; + if (swipeDistance > 0) translateX.value = swipeDistance; +}) +``` + +(see `package/src/components/Message/MessageItemView/MessageBubble.tsx:33-86` and `package/src/components/UIComponents/SwipableWrapper.tsx:67`) + +For `SwipableWrapper`, if a `side` prop is not provided, default it from direction: + +```tsx +const resolvedSide = side ?? (I18nManager.isRTL ? 'left' : 'right'); +const translationDirection = resolvedSide === 'right' ? -1 : 1; +``` + +### 6) Directional SVG icons + +For arrow/chevron/reply/send/thread/search/message-bubble icons: + +```tsx + + + +``` + +The translate component (`20` here) must equal the SVG's `width` so the mirror lands inside the viewBox. Special case for `arrow-left.tsx`: it rotates instead of matrix-mirrors — keep that style consistent with its sibling. + +When adding a new icon, ask: does this icon point in a direction (e.g., →) or carry directional meaning (e.g., "next", "reply")? If yes, mirror. If no (checkmark, bell, gear, emoji), don't. + +### 7) Text content with mixed scripts + +```tsx +{userInput} +``` + +Or: + +```tsx +import { WritingDirectionAwareText } from '../../RTLComponents/WritingDirectionAwareText'; +{userInput} +``` + +Apply to: message body, channel name, member names, poll options, search inputs, autocomplete tokens. Skip for purely numeric/symbolic content (timestamps, unread counts). + +### 8) Native `Switch` mirroring on iOS + +```tsx +import { useRtlMirrorSwitchStyle } from '../../utils/rtlMirrorSwitchStyle'; + +const mirror = useRtlMirrorSwitchStyle(); + +``` + +Returns `{ transform: [{ scaleX: -1 }] }` only when `Platform.OS === 'ios' && I18nManager.isRTL`. iOS `Switch` doesn't natively flip; Android does. + +### 9) Inverted `FlatList` and horizontal scroll + +`FlatList` `inverted` works correctly in RTL (it flips along the cross axis). Horizontal `FlatList`s auto-reverse content order in RTL — verify visually for emoji-reaction pickers and attachment-preview strips that the start of the list is at the **end** of the row in LTR and at the **start** in RTL. + +### 10) `transform: translateX` / `scaleX` + +`translateX` is in absolute pixels — positive X is *right* on screen regardless of direction. If your animation moves "toward the end" (e.g., sliding off-screen), multiply by `isRTL ? -1 : 1`. `scaleX: -1` is a mirror; only use it intentionally (the iOS Switch helper above, video direction in `AnimatedGalleryVideo`). + +## Anti-patterns to avoid + +- **Hardcoded `marginLeft` / `paddingRight` for spacing** — use `marginStart` / `paddingEnd` so RN can flip them. Acceptable only when you genuinely want a *fixed visual side* (rare). +- **Absolute `left: X` or `right: X` without a direction check** — these do NOT flip. Add a conditional. +- **`flexDirection: 'row-reverse'` to "fix" alignment** — you've broken RTL. Use `'row'`, which already flips correctly. +- **`textAlign: 'left'` on user content** — pins text to the left even in RTL. Either omit it, use `'auto'`, or conditionalize on `isRTL`. +- **Setting `writingDirection: 'ltr'` unconditionally** on user-generated text — strips bidi resolution for Arabic/Hebrew content. Branch on `I18nManager.isRTL`. +- **Mirroring symmetric icons** (checkmark, bell, gear, emoji, like-heart) — they look wrong flipped. Mirror only directional icons. +- **Forgetting the swipe-direction multiplier** on new pan gestures — the gesture activates in the wrong direction in RTL. +- **Caching `I18nManager.isRTL` at module load and assuming it never changes** is fine within a session; relying on it to update *mid-session without bundle reload* is not — RN reloads on `forceRTL` change. +- **New directional values in `theme.ts`** (`paddingLeft`, `marginRight`, hardcoded `right: -12`) — push the conditional into the consumer, or use `start`/`end`. +- **Assuming `I18nManager.forceRTL(true)` alone flips the running app** — it persists for the next bundle reload. Tests must mock `I18nManager.isRTL` (see Testing). + +## Audit checklist + +Walk this checklist against any diff that touches layout, positioning, gestures, transforms, icons, or text. Group findings by severity: + +- **HIGH**: visible breakage in RTL (text on wrong side, swipe wrong direction, icon points wrong way, overlay anchored to wrong edge). +- **MEDIUM**: misaligned spacing (margins/paddings on wrong side) — readable but off. +- **LOW**: stylistic (could use logical property but current code is technically correct). + +### Layout & positioning + +- [ ] No new `marginLeft`/`marginRight`/`paddingLeft`/`paddingRight` for *spacing* — use `marginStart`/`marginEnd`/`paddingStart`/`paddingEnd`. +- [ ] No new `borderLeftWidth`/`borderRightWidth`/`borderLeftColor`/`borderRightColor` etc. — use `borderStartWidth` / `borderEndWidth` / `borderStartColor` / `borderEndColor`. +- [ ] Any new absolute `left:`/`right:` positioning is wrapped in `I18nManager.isRTL ? ... : ...` (or uses `insetStart`/`insetEnd`). +- [ ] No new `flexDirection: 'row-reverse'` introduced as an "RTL fix" (it isn't). +- [ ] Negative offsets (e.g., `right: -12` for an overlapping badge) are conditional on direction. + +### Text + +- [ ] No new `textAlign: 'left'` or `'right'` on user-generated content; if needed, conditional on `I18nManager.isRTL`. +- [ ] `Text` components rendering user-generated/mixed-script content set `writingDirection` (or use `WritingDirectionAwareText`). +- [ ] Number-only / time / count strings are NOT given `writingDirection` (they're neutral). + +### Icons + +- [ ] New directional SVG icons (arrows, chevrons, send, reply, thread, message-bubble, search) have `transform={I18nManager.isRTL ? 'matrix(-1 0 0 1 0)' : undefined}` on the Path. +- [ ] The matrix translate value matches the SVG width. +- [ ] Symmetric/neutral icons (checkmark, bell, gear, like-heart, emoji) are NOT mirrored. + +### Gestures & animations + +- [ ] New `Gesture.Pan()` handlers that act on `translationX` multiply by `I18nManager.isRTL ? -1 : 1`. +- [ ] Reanimated `useAnimatedStyle` returning `translateX` accounts for direction when "toward the end" is meant. +- [ ] `withSpring`/`withTiming` targets toward an edge are flipped in RTL. +- [ ] New swipe-action wrappers default `side` from `I18nManager.isRTL` if not provided. + +### Lists & scroll + +- [ ] Horizontal `FlatList`/`ScrollView` content visually starts at the end of the row in LTR (start of row in RTL) — verify or accept default RN flip. +- [ ] `inverted` `FlatList` (e.g., `MessageList`) still renders newest at the bottom in both directions. + +### Native components + +- [ ] iOS `Switch` uses `useRtlMirrorSwitchStyle()`. +- [ ] `TextInput` `textAlign` is conditional or omitted (RN handles default). + +### i18n + +- [ ] No hardcoded English/LTR-only punctuation assumptions in concatenated strings — prefer interpolation via `t()` with placeholders. +- [ ] If adding strings, verify `he.json` has the same key (`yarn build-translations` keeps locales in sync). + +## Testing requirements per change + +Minimum: + +- For visible RTL changes, manually verify in the sample app by toggling Hebrew (`he`) or by calling `I18nManager.forceRTL(true)` in `index.js` and reloading. +- For unit tests, mock direction: + ```ts + import { I18nManager } from 'react-native'; + jest.spyOn(I18nManager, 'isRTL', 'get').mockReturnValue(true); + ``` + Restore between tests (`afterEach(() => jest.restoreAllMocks())`). + +Recommended for non-trivial changes: + +- Render the component twice (LTR + RTL) and snapshot the resulting style props for the directional surfaces. +- For gesture handlers, drive a fake `Gesture.Pan` with both positive and negative `translationX` under each direction and assert which one triggers the action. + +## Execution checklist (copy this when making an RTL change) + +- [ ] Identified directional axes in the change (spacing, absolute pos, gestures, icons, text) +- [ ] Spacing uses `start`/`end` logical properties +- [ ] Absolute positions are conditional on `I18nManager.isRTL` (or use `insetStart`/`insetEnd`) +- [ ] No `flexDirection: 'row-reverse'` added as a flip fix +- [ ] New gestures multiply `translationX` by direction multiplier +- [ ] New directional SVG icons carry the matrix-mirror transform; symmetric ones do not +- [ ] Text components with user-generated content set `writingDirection` +- [ ] Tested with `I18nManager.isRTL` mocked `true` AND `false` +- [ ] Visually verified in Hebrew locale (or via `forceRTL(true)` + reload) for non-trivial UI +- [ ] `yarn lint` passes +- [ ] `yarn test:typecheck` passes (run after any code change) + +## Reference files (in this repo) + +- `package/src/components/Message/Message.tsx:420-431` — alignment + overlay-alignment flip pattern. +- `package/src/components/Message/MessageItemView/MessageBubble.tsx:33-86` — swipe-direction multiplier on pan gesture. +- `package/src/components/UIComponents/SwipableWrapper.tsx:67,128` — direction-aware default `side` + translation sign. +- `package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx:169` — `right` vs `left` overlay anchor flip. +- `package/src/components/Message/MessageItemView/MessageReplies.tsx:58` — physical-alignment flip helper. +- `package/src/components/ui/Input/Input.tsx:230` and `package/src/components/AutoCompleteInput/AutoCompleteInput.tsx:207` — direction-aware `textAlign` for inputs. +- `package/src/components/RTLComponents/WritingDirectionAwareText.tsx` — drop-in `Text` with `writingDirection`. +- `package/src/utils/rtlMirrorSwitchStyle.ts` — iOS `Switch` mirror hook. +- `package/src/icons/chevron-right.tsx`, `chevron-left.tsx`, `reply.tsx`, `send.tsx`, `thread.tsx`, `search.tsx`, `message-bubble.tsx` — canonical SVG mirror pattern. +- `package/src/i18n/he.json` — only shipped RTL locale; reference for translation parity. + +## Known hazard hotspots + +Files most prone to RTL bugs when touched (audit these closely): + +- `package/src/components/MessageList/ScrollToBottomButton.tsx` — badge absolute positioning (`right: 0`). +- `package/src/components/ui/Avatar/AvatarGroup.tsx`, `AvatarStack.tsx`, `UserAvatar.tsx` — overlapping/clustered avatar offsets and presence dot. +- `package/src/components/MessageInput/MessageComposer.tsx` — overlay anchors, icon-end positioning. +- `package/src/components/MessageList/MessageList.tsx`, `MessageFlashList.tsx` — sticky headers and overlay anchors. +- `package/src/components/MessageMenu/MessageReactionPicker.tsx`, `MessageActionListItem.tsx` — horizontal reaction strip + icon padding. +- `package/src/components/Reply/Reply.tsx` — quoted-message row layout. +- `package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx` — leading-icon row. +- `package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx`, `ImageGallery.tsx` — `scaleX`/`translateX` animations. +- `package/src/components/Attachment/Audio/AudioAttachment.tsx`, `WaveProgressBar.tsx`, `ProgressControl.tsx` — progress-bar fill direction. +- `package/src/contexts/themeContext/utils/theme.ts` — any new directional defaults belong in consumers, not here. diff --git a/CLAUDE.md b/CLAUDE.md index 1f001d5ecb..0b8486625a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,9 +29,12 @@ cd package && yarn lint-fix # Auto-fix lint and formatting issues ```bash cd package && yarn test:unit # Run all unit tests (sets TZ=UTC) cd package && yarn test:coverage # Run with coverage report +cd package && yarn test:typecheck # Type-check tests against tsconfig.test.json (run after any code change) cd package && TZ=UTC npx jest path/to/test.test.tsx # Run a single test file ``` +Always run `yarn test:typecheck` after making code changes — `yarn lint` and `yarn test:unit` do not catch all type errors. + Tests use Jest with `react-native` preset and `@testing-library/react-native`. Test files live alongside source at `src/**/__tests__/*.test.ts(x)`. Mock builders are in `src/mock-builders/`. To run a single test, you can also temporarily add the file path to the `testRegex` array in `package/jest.config.js`. diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 57e513713e..edf53a0fa8 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -29,14 +29,13 @@ import { ChannelFilesScreen } from './src/screens/ChannelFilesScreen'; import { ChannelImagesScreen } from './src/screens/ChannelImagesScreen'; import { ChannelScreen } from './src/screens/ChannelScreen'; import { ChannelPinnedMessagesScreen } from './src/screens/ChannelPinnedMessagesScreen'; +import { ChannelDetailsScreen } from './src/screens/ChannelDetailsScreen'; import { ChatScreen } from './src/screens/ChatScreen'; -import { GroupChannelDetailsScreen } from './src/screens/GroupChannelDetailsScreen'; import { LoadingScreen } from './src/screens/LoadingScreen'; import { MenuDrawer } from './src/components/MenuDrawer'; import { NewDirectMessagingScreen } from './src/screens/NewDirectMessagingScreen'; import { NewGroupChannelAddMemberScreen } from './src/screens/NewGroupChannelAddMemberScreen'; import { NewGroupChannelAssignNameScreen } from './src/screens/NewGroupChannelAssignNameScreen'; -import { OneOnOneChannelDetailScreen } from './src/screens/OneOnOneChannelDetailScreen'; import { SharedGroupsScreen } from './src/screens/SharedGroupsScreen'; import { ThreadScreen } from './src/screens/ThreadScreen'; import { UserSelectorScreen } from './src/screens/UserSelectorScreen'; @@ -66,6 +65,8 @@ import { MessageListModeConfigItem, MessageListPruningConfigItem, } from './src/components/SecretMenu.tsx'; +import { ChannelAllMembersScreen } from './src/screens/ChannelAllMembersScreen.tsx'; +import { ChannelAddMembersScreen } from './src/screens/ChannelAddMembersScreen.tsx'; init({ data }); @@ -417,14 +418,19 @@ const HomeScreen = () => { options={{ headerShown: false }} /> + ; + +type ChannelAddMembersScreenNavigationProp = NativeStackNavigationProp< + StackNavigatorParamList, + 'ChannelAddMembersScreen' +>; + +type Props = { + navigation: ChannelAddMembersScreenNavigationProp; + route: ChannelAddMembersScreenRouteProp; +}; + +export const ChannelAddMembersScreen: React.FC = ({ + navigation, + route: { + params: { channel }, + }, +}) => { + const { addMembers } = useChannelActions(channel); + const [selectedUsers, setSelectedUsers] = useState([]); + + const channelDetailsContextValue = useMemo(() => ({ channel }), [channel]); + + const goBack = useCallback(() => navigation.goBack(), [navigation]); + + const onSavePress = useCallback(async () => { + await addMembers( + selectedUsers.map((user) => user.id), + { + onSuccess: () => { + setSelectedUsers([]); + goBack(); + }, + }, + ); + }, [addMembers, selectedUsers, goBack]); + + return ( + + + + + + + Save + + + + + + + ); +}; diff --git a/examples/SampleApp/src/screens/ChannelAllMembersScreen.tsx b/examples/SampleApp/src/screens/ChannelAllMembersScreen.tsx new file mode 100644 index 0000000000..f5bbdae1ef --- /dev/null +++ b/examples/SampleApp/src/screens/ChannelAllMembersScreen.tsx @@ -0,0 +1,36 @@ +import React, { useMemo } from 'react'; + +import type { RouteProp } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import type { StackNavigatorParamList } from '../types'; +import { ChannelDetailsContext, ChannelDetailsMemberList } from 'stream-chat-react-native-core'; + +type ChannelAllMembersScreenRouteProp = RouteProp< + StackNavigatorParamList, + 'ChannelAllMembersScreen' +>; + +type ChannelAllMembersScreenNavigationProp = NativeStackNavigationProp< + StackNavigatorParamList, + 'ChannelAllMembersScreen' +>; + +type Props = { + navigation: ChannelAllMembersScreenNavigationProp; + route: ChannelAllMembersScreenRouteProp; +}; + +export const ChannelAllMembersScreen: React.FC = ({ + route: { + params: { channel }, + }, +}) => { + const channelDetailsContextValue = useMemo(() => ({ channel }), [channel]); + + return ( + + + + ); +}; diff --git a/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx b/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx new file mode 100644 index 0000000000..d6098fa13e --- /dev/null +++ b/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx @@ -0,0 +1,64 @@ +import React, { useCallback } from 'react'; + +import type { RouteProp } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import { ChannelDetailsScreen as StreamChannelDetailsScreen } from 'stream-chat-react-native'; + +import type { StackNavigatorParamList } from '../types'; + +type ChannelDetailsScreenRouteProp = RouteProp; + +type ChannelDetailsScreenNavigationProp = NativeStackNavigationProp< + StackNavigatorParamList, + 'ChannelDetailsScreen' +>; + +type Props = { + navigation: ChannelDetailsScreenNavigationProp; + route: ChannelDetailsScreenRouteProp; +}; + +export const ChannelDetailsScreen: React.FC = ({ + navigation, + route: { + params: { channel }, + }, +}) => { + const onBack = useCallback(() => navigation.goBack(), [navigation]); + const popToRoot = useCallback( + () => + navigation.reset({ + index: 0, + routes: [{ name: 'MessagingScreen' }], + }), + [navigation], + ); + const onViewAllMembersPress = useCallback(() => { + navigation.navigate('ChannelAllMembersScreen', { channel }); + }, [navigation, channel]); + + const onAddMembersPress = useCallback(() => { + navigation.navigate('ChannelAddMembersScreen', { channel }); + }, [navigation, channel]); + + return ( + <> + {(channel.data?.member_count ?? 0) % 2 === 0 ? ( + + ) : ( + + )} + + ); +}; diff --git a/examples/SampleApp/src/screens/ChannelListScreen.tsx b/examples/SampleApp/src/screens/ChannelListScreen.tsx index c53b93a85b..6bfe918954 100644 --- a/examples/SampleApp/src/screens/ChannelListScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelListScreen.tsx @@ -133,21 +133,15 @@ export const ChannelListScreen: React.FC = () => { ); const getChannelActionItems = useStableCallback( - ({ context: { isDirectChat, channel }, defaultItems }) => { + ({ context: { channel }, defaultItems }) => { const viewInfo = () => { if (!channel) { return; } if (navigation) { - if (isDirectChat) { - navigation.navigate('OneOnOneChannelDetailScreen', { - channel, - }); - } else { - navigation.navigate('GroupChannelDetailsScreen', { - channel, - }); - } + navigation.navigate('ChannelDetailsScreen', { + channel, + }); } }; diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index f423215596..9adaf3238d 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -56,11 +56,6 @@ const ChannelHeader: React.FC = ({ channel }) => { const { chatClient } = useAppContext(); const navigation = useNavigation(); - const isOneOnOneConversation = - channel && - Object.values(channel.state.members).length === 2 && - channel.id?.indexOf('!members-') === 0; - const onBackPress = useCallback(() => { if (!navigation.canGoBack()) { // if no previous screen was present in history, go to the list screen @@ -75,16 +70,10 @@ const ChannelHeader: React.FC = ({ channel }) => { const onRightContentPress = useCallback(() => { closePicker(); - if (isOneOnOneConversation) { - navigation.navigate('OneOnOneChannelDetailScreen', { - channel, - }); - } else { - navigation.navigate('GroupChannelDetailsScreen', { - channel, - }); - } - }, [channel, closePicker, isOneOnOneConversation, navigation]); + navigation.navigate('ChannelDetailsScreen', { + channel, + }); + }, [channel, closePicker, navigation]); if (!channel || !chatClient) { return null; diff --git a/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx b/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx deleted file mode 100644 index 182c2ec708..0000000000 --- a/examples/SampleApp/src/screens/GroupChannelDetailsScreen.tsx +++ /dev/null @@ -1,403 +0,0 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { I18nManager, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native'; - -import { SafeAreaView } from 'react-native-safe-area-context'; - -import { RouteProp, useNavigation } from '@react-navigation/native'; - -import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import type { ChannelMemberResponse, UserResponse } from 'stream-chat'; - -import { - ChannelAvatar, - useChannelPreviewDisplayName, - useIsChannelMuted, - useOverlayContext, - useTheme, - Pin, -} from 'stream-chat-react-native'; - -import { AddMembersBottomSheet } from '../components/AddMembersBottomSheet'; -import { AllMembersBottomSheet } from '../components/AllMembersBottomSheet'; -import { ChannelDetailProfileSection } from '../components/ChannelDetailProfileSection'; -import { ConfirmationBottomSheet } from '../components/ConfirmationBottomSheet'; -import { ContactDetailBottomSheet } from '../components/ContactDetailBottomSheet'; -import { EditGroupBottomSheet } from '../components/EditGroupBottomSheet'; -import { ListItem } from '../components/ListItem'; -import { MemberListItem } from '../components/MemberListItem'; -import { ScreenHeader } from '../components/ScreenHeader'; -import { SectionCard } from '../components/SectionCard'; -import { useAppContext } from '../context/AppContext'; -import { File } from '../icons/File'; -import { GoForward } from '../icons/GoForward'; -import { LeaveGroup } from '../icons/LeaveGroup'; -import { Mute } from '../icons/Mute'; -import { Picture } from '../icons/Picture'; -import type { StackNavigatorParamList } from '../types'; -import { useRtlMirrorSwitchStyle } from '../utils/rtlMirrorSwitchStyle'; - -const MAX_VISIBLE_MEMBERS = 5; - -type GroupChannelDetailsRouteProp = RouteProp; - -type GroupChannelDetailsProps = { - route: GroupChannelDetailsRouteProp; -}; - -type GroupChannelDetailsScreenNavigationProp = NativeStackNavigationProp< - StackNavigatorParamList, - 'GroupChannelDetailsScreen' ->; - -export const GroupChannelDetailsScreen: React.FC = ({ - route: { - params: { channel }, - }, -}) => { - const { chatClient } = useAppContext(); - const navigation = useNavigation(); - const { setOverlay } = useOverlayContext(); - const { - theme: { semantics }, - } = useTheme(); - const rtlMirrorSwitchStyle = useRtlMirrorSwitchStyle(); - const { muted: channelMuted } = useIsChannelMuted(channel); - - const [muted, setMuted] = useState( - chatClient?.mutedChannels.some((mute) => mute.channel?.id === channel?.id), - ); - const [allMembersVisible, setAllMembersVisible] = useState(false); - const [addMembersVisible, setAddMembersVisible] = useState(false); - const [confirmationVisible, setConfirmationVisible] = useState(false); - const [editVisible, setEditVisible] = useState(false); - const [selectedMember, setSelectedMember] = useState(null); - - const displayName = useChannelPreviewDisplayName(channel, 30); - const allMembers = useMemo(() => Object.values(channel.state.members), [channel.state.members]); - const memberCount = channel?.data?.member_count ?? allMembers.length; - const onlineCount = channel.state.watcher_count ?? 0; - - const memberStatusText = useMemo(() => { - const parts = [`${memberCount} members`]; - if (onlineCount > 0) { - parts.push(`${onlineCount} online`); - } - return parts.join(' · '); - }, [memberCount, onlineCount]); - - const visibleMembers = useMemo(() => allMembers.slice(0, MAX_VISIBLE_MEMBERS), [allMembers]); - const hasMoreMembers = allMembers.length > MAX_VISIBLE_MEMBERS; - - const channelCreatorId = - channel.data && (channel.data.created_by_id || (channel.data.created_by as UserResponse)?.id); - - const leaveGroup = useCallback(async () => { - if (chatClient?.user?.id) { - await channel.removeMembers([chatClient.user.id]); - } - setOverlay('none'); - navigation.reset({ - index: 0, - routes: [{ name: 'MessagingScreen' }], - }); - }, [channel, chatClient?.user?.id, navigation, setOverlay]); - - const openLeaveGroupConfirmationSheet = useCallback(() => { - if (!chatClient?.user?.id) { - return; - } - setConfirmationVisible(true); - }, [chatClient?.user?.id]); - - const closeConfirmation = useCallback(() => { - setConfirmationVisible(false); - }, []); - - const openAddMembersSheet = useCallback(() => { - if (!chatClient?.user?.id) return; - setAddMembersVisible(true); - }, [chatClient?.user?.id]); - - const openAddMembersFromAllMembers = useCallback(() => { - if (!chatClient?.user?.id) return; - setAllMembersVisible(false); - setAddMembersVisible(true); - }, [chatClient?.user?.id]); - - const closeAddMembers = useCallback(() => { - setAddMembersVisible(false); - }, []); - - const handleMuteToggle = useCallback(async () => { - if (muted) { - await channel.unmute(); - } else { - await channel.mute(); - } - setMuted((prev) => !prev); - }, [channel, muted]); - - const navigateToPinnedMessages = useCallback(() => { - navigation.navigate('ChannelPinnedMessagesScreen', { channel }); - }, [channel, navigation]); - - const navigateToImages = useCallback(() => { - navigation.navigate('ChannelImagesScreen', { channel }); - }, [channel, navigation]); - - const navigateToFiles = useCallback(() => { - navigation.navigate('ChannelFilesScreen', { channel }); - }, [channel, navigation]); - - const handleMemberPress = useCallback( - (member: ChannelMemberResponse) => { - if (member.user?.id !== chatClient?.user?.id) { - setSelectedMember(member); - } - }, - [chatClient?.user?.id], - ); - - const closeContactDetail = useCallback(() => { - setSelectedMember(null); - }, []); - - const isCreator = channelCreatorId === chatClient?.user?.id; - - const openAllMembers = useCallback(() => { - setAllMembersVisible(true); - }, []); - - const closeAllMembers = useCallback(() => { - setAllMembersVisible(false); - }, []); - - const openEditSheet = useCallback(() => { - setEditVisible(true); - }, []); - - const closeEditSheet = useCallback(() => { - setEditVisible(false); - }, []); - - const rightContent = useMemo( - () => ( - - Edit - - ), - [openEditSheet, semantics.borderCoreDefault, semantics.textPrimary], - ); - - if (!channel) { - return null; - } - - const chevronRight = ; - - return ( - - rightContent} /> - - } - muted={channelMuted} - title={displayName} - subtitle={memberStatusText} - /> - - - } - label='Pinned Messages' - trailing={chevronRight} - onPress={navigateToPinnedMessages} - /> - } - label='Photos & Videos' - trailing={chevronRight} - onPress={navigateToImages} - /> - } - label='Files' - trailing={chevronRight} - onPress={navigateToFiles} - /> - - - - - - {`${memberCount} members`} - - {isCreator ? ( - - - Add - - - ) : null} - - - {visibleMembers.map((member) => { - if (!member.user?.id) { - return null; - } - return ( - handleMemberPress(member)} - /> - ); - })} - - {hasMoreMembers ? ( - - - - View all - - - - ) : null} - - - - } - label='Mute Group' - trailing={ - - } - /> - } - label='Leave Group' - destructive - onPress={openLeaveGroupConfirmationSheet} - /> - - - - - - - - - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - scrollContent: { - gap: 16, - paddingBottom: 40, - paddingHorizontal: 16, - paddingTop: 32, - }, - membersCard: { - paddingVertical: 0, - }, - sectionHeader: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingTop: 8, - }, - sectionHeaderTitle: { - flex: 1, - fontSize: 17, - fontWeight: '600', - lineHeight: 20, - writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', - }, - memberList: { - paddingBottom: 12, - }, - sectionFooter: { - alignItems: 'center', - borderTopWidth: 1, - paddingHorizontal: 16, - }, - viewAllButton: { - alignItems: 'center', - justifyContent: 'center', - minHeight: 48, - width: '100%', - }, - viewAllLabel: { - fontSize: 17, - fontWeight: '600', - lineHeight: 20, - }, - outlineButton: { - alignItems: 'center', - borderRadius: 9999, - borderWidth: 1, - justifyContent: 'center', - minHeight: 40, - paddingHorizontal: 16, - paddingVertical: 10, - }, - outlineButtonSm: { - alignItems: 'center', - borderRadius: 9999, - borderWidth: 1, - justifyContent: 'center', - minHeight: 32, - paddingHorizontal: 16, - paddingVertical: 6, - }, - outlineButtonLabel: { - fontSize: 17, - fontWeight: '600', - lineHeight: 20, - }, -}); diff --git a/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx b/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx deleted file mode 100644 index 30bb7caf70..0000000000 --- a/examples/SampleApp/src/screens/OneOnOneChannelDetailScreen.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import { ScrollView, StyleSheet, Switch } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; - -import type { RouteProp } from '@react-navigation/native'; -import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; - -import { - ChannelAvatar, - CircleBan, - Delete, - useChannelMuteActive, - Pin, - useTheme, -} from 'stream-chat-react-native'; - -import { ChannelDetailProfileSection } from '../components/ChannelDetailProfileSection'; -import { ConfirmationBottomSheet } from '../components/ConfirmationBottomSheet'; -import { ListItem } from '../components/ListItem'; -import { ScreenHeader } from '../components/ScreenHeader'; -import { SectionCard } from '../components/SectionCard'; -import { useAppContext } from '../context/AppContext'; -import { File } from '../icons/File'; -import { GoForward } from '../icons/GoForward'; -import { Mute } from '../icons/Mute'; -import { Picture } from '../icons/Picture'; -import type { StackNavigatorParamList } from '../types'; -import { getUserActivityStatus } from '../utils/getUserActivityStatus'; -import { useRtlMirrorSwitchStyle } from '../utils/rtlMirrorSwitchStyle'; - -type OneOnOneChannelDetailScreenRouteProp = RouteProp< - StackNavigatorParamList, - 'OneOnOneChannelDetailScreen' ->; - -type OneOnOneChannelDetailScreenNavigationProp = NativeStackNavigationProp< - StackNavigatorParamList, - 'OneOnOneChannelDetailScreen' ->; - -type Props = { - navigation: OneOnOneChannelDetailScreenNavigationProp; - route: OneOnOneChannelDetailScreenRouteProp; -}; - -export const OneOnOneChannelDetailScreen: React.FC = ({ - navigation, - route: { - params: { channel }, - }, -}) => { - const { - theme: { semantics }, - } = useTheme(); - const rtlMirrorSwitchStyle = useRtlMirrorSwitchStyle(); - const { chatClient } = useAppContext(); - const userMuted = useChannelMuteActive(channel); - - const [confirmationVisible, setConfirmationVisible] = useState(false); - const [blockUserConfirmationVisible, setBlockUserConfirmationVisible] = useState(false); - - const member = Object.values(channel.state.members).find( - (channelMember) => channelMember.user?.id !== chatClient?.user?.id, - ); - - const user = member?.user; - const [muted, setMuted] = useState( - chatClient?.mutedUsers && - chatClient.mutedUsers.findIndex((mutedUser) => mutedUser.target.id === user?.id) > -1, - ); - - const deleteConversation = useCallback(async () => { - try { - await channel.delete(); - navigation.reset({ - index: 0, - routes: [{ name: 'MessagingScreen' }], - }); - } catch (error) { - console.error('Error deleting conversation', error); - } - }, [channel, navigation]); - - const handleBlockUser = useCallback(async () => { - try { - if (!user?.id) { - return; - } - await chatClient?.blockUser(user.id); - navigation.reset({ - index: 0, - routes: [{ name: 'MessagingScreen' }], - }); - } catch (error) { - console.error('Error blocking user', error); - } - }, [chatClient, navigation, user?.id]); - - const openDeleteConversationConfirmationSheet = useCallback(() => { - if (!chatClient?.user?.id) { - return; - } - setConfirmationVisible(true); - }, [chatClient?.user?.id]); - - const openBlockUserConfirmationSheet = useCallback(() => { - if (!user?.id) { - return; - } - setBlockUserConfirmationVisible(true); - }, [user?.id]); - - const closeConfirmation = useCallback(() => { - setConfirmationVisible(false); - }, []); - - const closeBlockUserConfirmation = useCallback(() => { - setBlockUserConfirmationVisible(false); - }, []); - - const handleMuteToggle = useCallback(async () => { - if (muted) { - await chatClient?.unmuteUser(user!.id); - } else { - await chatClient?.muteUser(user!.id); - } - setMuted((prev) => !prev); - }, [chatClient, muted, user]); - - const navigateToPinnedMessages = useCallback(() => { - navigation.navigate('ChannelPinnedMessagesScreen', { channel }); - }, [channel, navigation]); - - const navigateToImages = useCallback(() => { - navigation.navigate('ChannelImagesScreen', { channel }); - }, [channel, navigation]); - - const navigateToFiles = useCallback(() => { - navigation.navigate('ChannelFilesScreen', { channel }); - }, [channel, navigation]); - - if (!user) { - return null; - } - - const activityStatus = getUserActivityStatus(user); - const chevronRight = ; - - return ( - - - - } - muted={userMuted} - title={user.name || user.id} - subtitle={activityStatus} - /> - - - } - label='Pinned Messages' - trailing={chevronRight} - onPress={navigateToPinnedMessages} - /> - } - label='Photos & Videos' - trailing={chevronRight} - onPress={navigateToImages} - /> - } - label='Files' - trailing={chevronRight} - onPress={navigateToFiles} - /> - - - - } - label='Mute User' - trailing={ - - } - /> - } - label='Block User' - onPress={openBlockUserConfirmationSheet} - /> - - } - label='Delete Conversation' - destructive - onPress={openDeleteConversationConfirmationSheet} - /> - - - - - - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - scrollContent: { - gap: 16, - paddingBottom: 40, - paddingHorizontal: 16, - paddingTop: 32, - }, -}); diff --git a/examples/SampleApp/src/types.ts b/examples/SampleApp/src/types.ts index 621ffeb907..079d9733de 100644 --- a/examples/SampleApp/src/types.ts +++ b/examples/SampleApp/src/types.ts @@ -24,16 +24,19 @@ export type StackNavigatorParamList = { messageId?: string; }; MapScreen: SharedLocationResponse; - GroupChannelDetailsScreen: { + ChannelDetailsScreen: { + channel: Channel; + }; + ChannelAllMembersScreen: { + channel: Channel; + }; + ChannelAddMembersScreen: { channel: Channel; }; MessagingScreen: undefined; NewDirectMessagingScreen: undefined; NewGroupChannelAddMemberScreen: undefined; NewGroupChannelAssignNameScreen: undefined; - OneOnOneChannelDetailScreen: { - channel: Channel; - }; SharedGroupsScreen: { user: UserResponse; }; diff --git a/package/src/components/ChannelDetailsScreen/ChannelDetailsScreen.tsx b/package/src/components/ChannelDetailsScreen/ChannelDetailsScreen.tsx new file mode 100644 index 0000000000..690d17f13b --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/ChannelDetailsScreen.tsx @@ -0,0 +1,119 @@ +import React, { useMemo } from 'react'; +import { ScrollView, StyleSheet, View } from 'react-native'; + +import type { Channel } from 'stream-chat'; + +import { + ChannelDetailsContextProvider, + type ChannelDetailsContextValue, +} from '../../contexts/channelDetailsContext/channelDetailsContext'; +import { useChannelDetailsContext } from '../../contexts/channelDetailsContext/channelDetailsContext'; +import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useIsDirectChat } from '../../hooks/useIsDirectChat'; +import { primitives } from '../../theme'; +import { NotificationList } from '../Notifications/NotificationList'; +import { NotificationTargetProvider } from '../Notifications/NotificationTargetContext'; + +export type ChannelDetailsScreenProps = { + channel: Channel; + /** + * Fired when the user taps the "add members" button, by default it opens the add members bottom sheet. Only visible if the current user has the `update-channel-members` capability. + */ + onAddMembersPress?: () => void; + /** + * Fired when the back button is pressed on the channel details header. + */ + onBack?: () => void; + /** Fired after the channel is no longer available to the current user (delete or leave actions). */ + onChannelDismiss?: () => void; + /** + * Fired when the user taps the "view all members" button, by default it opens the members bottom sheet. + */ + onViewAllMembersPress?: () => void; +}; + +export const ChannelDetailsScreenContent = () => { + const { channel } = useChannelDetailsContext(); + const { + theme: { + channelDetailsScreen: { container: containerOverride, scrollContent: scrollContentOverride }, + semantics, + }, + } = useTheme(); + const { + ChannelDetailsActionsSection, + ChannelDetailsMemberSection, + ChannelDetailsNavigationSection, + ChannelDetailsProfile, + ChannelDetailsScreenHeader, + } = useComponentsContext(); + const isDirect = useIsDirectChat(channel); + const styles = useStyles(); + + return ( + + + + + + {isDirect ? null : } + + + + + ); +}; + +export const ChannelDetailsScreen = ({ + channel, + onAddMembersPress, + onBack, + onChannelDismiss, + onViewAllMembersPress, +}: ChannelDetailsScreenProps) => { + const { ChannelDetailsScreenContent: ChannelDetailsScreenContentOverride } = + useComponentsContext(); + const value = useMemo( + () => ({ channel, onAddMembersPress, onBack, onChannelDismiss, onViewAllMembersPress }), + [channel, onAddMembersPress, onBack, onChannelDismiss, onViewAllMembersPress], + ); + const Content = ChannelDetailsScreenContentOverride ?? ChannelDetailsScreenContent; + const notificationHostId = channel?.cid ? `channel-details:${channel.cid}` : undefined; + + return ( + + {notificationHostId ? ( + + + + ) : ( + + )} + + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + container: { + flex: 1, + }, + scrollContent: { + gap: primitives.spacingMd, + paddingBottom: primitives.spacing3xl, + paddingHorizontal: primitives.spacingMd, + paddingTop: primitives.spacing2xl, + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelAddMembers.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelAddMembers.test.tsx new file mode 100644 index 0000000000..fbd9663e24 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelAddMembers.test.tsx @@ -0,0 +1,281 @@ +import React from 'react'; + +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import type { Channel, ChannelMemberResponse, UserResponse } from 'stream-chat'; + +import { AccessibilityProvider } from '../../../contexts/accessibilityContext/AccessibilityContext'; +import { ChannelDetailsContextProvider } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { ChatContext } from '../../../contexts/chatContext/ChatContext'; +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; +import { generateMember } from '../../../mock-builders/generator/member'; +import { generateUser } from '../../../mock-builders/generator/user'; +import { ChannelAddMembers } from '../components/ChannelAddMembers'; + +const buildChannel = (members: ChannelMemberResponse[]): Channel => + ({ + cid: 'messaging:test', + data: { member_count: members.length }, + on: () => ({ unsubscribe: () => undefined }), + state: { + members: Object.fromEntries( + members.map((m) => [m.user?.id ?? m.user_id ?? '', m]).filter(([k]) => Boolean(k)), + ), + }, + }) as unknown as Channel; + +type QueryUsersMock = jest.Mock, [unknown, unknown, unknown]>; + +const renderComponent = ({ + channel, + onSelectionChange = jest.fn(), + queryUsers, + userID = 'me', +}: { + channel: Channel; + onSelectionChange?: (users: UserResponse[]) => void; + queryUsers: QueryUsersMock; + userID?: string; +}) => + render( + + + ) => { + if (options && typeof options === 'object') { + return Object.entries(options).reduce( + (acc, [k, v]) => acc.replace(`{{${k}}}`, String(v)), + key, + ); + } + return key; + }) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + + + + + + + , + ); + +describe('ChannelAddMembers', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.useRealTimers(); + warnSpy.mockRestore(); + }); + + it('renders the search input and shows a loading indicator while the initial fetch is pending', () => { + const queryUsers: QueryUsersMock = jest.fn().mockReturnValue(new Promise(() => undefined)); + const channel = buildChannel([]); + + renderComponent({ channel, queryUsers }); + + expect(screen.getByTestId('channel-add-members-search-input')).toBeTruthy(); + expect(screen.getByTestId('channel-add-members-loading')).toBeTruthy(); + }); + + it('fires an initial queryUsers call on mount with the role filter and pagination opts', async () => { + const queryUsers: QueryUsersMock = jest.fn().mockResolvedValue({ users: [] }); + const channel = buildChannel([]); + + renderComponent({ channel, queryUsers }); + + await waitFor(() => expect(queryUsers).toHaveBeenCalledTimes(1)); + expect(queryUsers).toHaveBeenCalledWith( + { role: 'user' }, + { name: 1 }, + expect.objectContaining({ limit: 10, offset: 0, presence: true }), + ); + }); + + it('debounces search and triggers an autocomplete query with the latest value only', async () => { + jest.useFakeTimers(); + const queryUsers: QueryUsersMock = jest.fn().mockResolvedValue({ users: [] }); + const channel = buildChannel([]); + + renderComponent({ channel, queryUsers }); + + // Initial mount fetch (no autocomplete filter) + await act(async () => { + await Promise.resolve(); + }); + + const input = screen.getByTestId('channel-add-members-search-input'); + fireEvent.changeText(input, 'E'); + fireEvent.changeText(input, 'Et'); + fireEvent.changeText(input, 'Eth'); + + // Before debounce fires, only the initial mount call should exist. + expect(queryUsers).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.advanceTimersByTime(250); + await Promise.resolve(); + }); + + const autocompleteCalls = queryUsers.mock.calls.filter( + ([filter]) => (filter as { name?: unknown })?.name !== undefined, + ); + expect(autocompleteCalls).toHaveLength(1); + expect(autocompleteCalls[0][0]).toEqual({ + name: { $autocomplete: 'Eth' }, + role: 'user', + }); + }); + + it('filters out existing channel members from the rendered list', async () => { + const existingUser = generateUser({ id: 'u-1', name: 'Existing Member' }); + const newUser = generateUser({ id: 'u-2', name: 'New User' }); + const channel = buildChannel([generateMember({ user: existingUser })]); + const queryUsers: QueryUsersMock = jest + .fn() + .mockResolvedValue({ users: [existingUser, newUser] }); + + renderComponent({ channel, queryUsers }); + + await waitFor(() => expect(screen.queryByTestId('channel-add-members-row-u-2')).toBeTruthy()); + expect(screen.queryByTestId('channel-add-members-row-u-1')).toBeNull(); + }); + + it('filters out the current user from the rendered list', async () => { + const me = generateUser({ id: 'me', name: 'Me' }); + const newUser = generateUser({ id: 'u-2', name: 'New User' }); + const channel = buildChannel([]); + const queryUsers: QueryUsersMock = jest.fn().mockResolvedValue({ users: [me, newUser] }); + + renderComponent({ channel, queryUsers, userID: 'me' }); + + await waitFor(() => expect(screen.queryByTestId('channel-add-members-row-u-2')).toBeTruthy()); + expect(screen.queryByTestId('channel-add-members-row-me')).toBeNull(); + }); + + it('toggles selection on the row and reflects it via accessibilityState', async () => { + const newUser = generateUser({ id: 'u-2', name: 'New User' }); + const channel = buildChannel([]); + const queryUsers: QueryUsersMock = jest.fn().mockResolvedValue({ users: [newUser] }); + + renderComponent({ channel, queryUsers }); + + const row = await waitFor(() => screen.getByTestId('channel-add-members-row-u-2')); + expect(row.props.accessibilityState).toMatchObject({ selected: false }); + + fireEvent.press(row); + expect( + screen.getByTestId('channel-add-members-row-u-2').props.accessibilityState, + ).toMatchObject({ selected: true }); + + fireEvent.press(screen.getByTestId('channel-add-members-row-u-2')); + expect( + screen.getByTestId('channel-add-members-row-u-2').props.accessibilityState, + ).toMatchObject({ selected: false }); + }); + + it('emits onSelectionChange with the latest selection on toggle', async () => { + const newUser = generateUser({ id: 'u-2', name: 'New User' }); + const channel = buildChannel([]); + const queryUsers: QueryUsersMock = jest.fn().mockResolvedValue({ users: [newUser] }); + const onSelectionChange = jest.fn(); + + renderComponent({ channel, onSelectionChange, queryUsers }); + + const row = await waitFor(() => screen.getByTestId('channel-add-members-row-u-2')); + fireEvent.press(row); + + await waitFor(() => + expect(onSelectionChange).toHaveBeenLastCalledWith([expect.objectContaining({ id: 'u-2' })]), + ); + + fireEvent.press(row); + await waitFor(() => expect(onSelectionChange).toHaveBeenLastCalledWith([])); + }); + + it('renders the empty state when queryUsers returns no users', async () => { + const queryUsers: QueryUsersMock = jest.fn().mockResolvedValue({ users: [] }); + const channel = buildChannel([]); + + renderComponent({ channel, queryUsers }); + + await waitFor(() => expect(screen.getByTestId('channel-add-members-empty')).toBeTruthy()); + expect(screen.getByText('No user found')).toBeTruthy(); + }); + + it('renders the clear-search button only when search text is non-empty', async () => { + const queryUsers: QueryUsersMock = jest.fn().mockResolvedValue({ users: [] }); + const channel = buildChannel([]); + + renderComponent({ channel, queryUsers }); + + expect(screen.queryByTestId('channel-add-members-clear-search')).toBeNull(); + + fireEvent.changeText(screen.getByTestId('channel-add-members-search-input'), 'X'); + expect(screen.getByTestId('channel-add-members-clear-search')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('channel-add-members-clear-search')); + await waitFor(() => + expect(screen.queryByTestId('channel-add-members-clear-search')).toBeNull(), + ); + }); + + it('keeps the selection when the search text changes', async () => { + jest.useFakeTimers(); + const userA = generateUser({ id: 'u-2', name: 'New User' }); + const userB = generateUser({ id: 'u-3', name: 'Other User' }); + const channel = buildChannel([]); + const queryUsers: QueryUsersMock = jest + .fn() + .mockResolvedValueOnce({ users: [userA, userB] }) + .mockResolvedValueOnce({ users: [userA] }); + + const onSelectionChange = jest.fn(); + renderComponent({ channel, onSelectionChange, queryUsers }); + + await act(async () => { + await Promise.resolve(); + }); + + const row = screen.getByTestId('channel-add-members-row-u-2'); + fireEvent.press(row); + expect(onSelectionChange).toHaveBeenLastCalledWith([expect.objectContaining({ id: 'u-2' })]); + + fireEvent.changeText(screen.getByTestId('channel-add-members-search-input'), 'New'); + await act(async () => { + jest.advanceTimersByTime(250); + await Promise.resolve(); + }); + + expect(onSelectionChange).toHaveBeenLastCalledWith([expect.objectContaining({ id: 'u-2' })]); + expect( + screen.getByTestId('channel-add-members-row-u-2').props.accessibilityState, + ).toMatchObject({ selected: true }); + }); + + it('wires onEndReached on the list so it can request additional pages', async () => { + const firstPage = Array.from({ length: 10 }, (_, i) => + generateUser({ id: `p1-${i}`, name: `User P1 ${i}` }), + ); + const channel = buildChannel([]); + const queryUsers: QueryUsersMock = jest.fn().mockResolvedValue({ users: firstPage }); + + renderComponent({ channel, queryUsers }); + + await waitFor(() => expect(screen.getByTestId('channel-add-members-row-p1-0')).toBeTruthy()); + + const list = screen.getByTestId('channel-add-members-list'); + expect(typeof list.props.onEndReached).toBe('function'); + expect(list.props.onEndReachedThreshold).toBe(0.2); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsActionsSection.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsActionsSection.test.tsx new file mode 100644 index 0000000000..c0112856f3 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsActionsSection.test.tsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +import { AccessibilityProvider } from '../../../contexts/accessibilityContext/AccessibilityContext'; +import { ChannelDetailsContextProvider } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; +import type { ChannelActionItem } from '../../../hooks/useChannelActionItems'; +import * as useIsDirectChatModule from '../../../hooks/useIsDirectChat'; +import { ChannelDetailsActionsSection } from '../components/ChannelDetailsActionsSection'; +import type { ChannelDetailsListItemProps } from '../components/ChannelDetailsListItem'; +import * as useChannelDetailsActionItemsModule from '../hooks/useChannelDetailsActionItems'; + +const NoopIcon = () => null; + +const buildItem = (overrides: Partial = {}): ChannelActionItem => ({ + action: jest.fn(), + Icon: NoopIcon, + id: 'mute', + label: 'Mute', + placement: 'sheet', + type: 'standard', + ...overrides, +}); + +const channel = { + cid: 'messaging:test', + on: () => ({ unsubscribe: () => undefined }), +} as unknown as Channel; + +type Probe = ChannelDetailsListItemProps & { testID?: string }; + +const probeCalls: Probe[] = []; +const ListItemProbe = (props: Probe) => { + probeCalls.push(props); + return ( + + {props.label} + + ); +}; + +const renderSection = ({ a11yEnabled = false }: { a11yEnabled?: boolean } = {}) => + render( + + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + + + + + + + , + ); + +describe('ChannelDetailsActionsSection', () => { + let useIsDirectChatSpy: jest.SpyInstance; + let useActionItemsSpy: jest.SpyInstance; + + beforeEach(() => { + probeCalls.length = 0; + useIsDirectChatSpy = jest + .spyOn(useIsDirectChatModule, 'useIsDirectChat') + .mockReturnValue(false); + useActionItemsSpy = jest + .spyOn(useChannelDetailsActionItemsModule, 'useChannelDetailsActionItems') + .mockReturnValue([]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('when there are no items', () => { + it('renders nothing', () => { + const { toJSON } = renderSection(); + expect(toJSON()).toBeNull(); + }); + }); + + describe('when there are items', () => { + const muteItem = buildItem({ id: 'mute', label: 'Mute Group' }); + const leaveItem = buildItem({ + id: 'leave', + label: 'Leave Group', + type: 'destructive', + }); + const deleteItem = buildItem({ + id: 'deleteChannel', + label: 'Delete Group', + type: 'destructive', + }); + + it('renders one list item per action item', () => { + useActionItemsSpy.mockReturnValue([muteItem, leaveItem, deleteItem]); + renderSection(); + expect(probeCalls).toHaveLength(3); + expect(probeCalls.map((p) => p.label)).toEqual(['Mute Group', 'Leave Group', 'Delete Group']); + }); + + it('builds testIDs from the item id', () => { + useActionItemsSpy.mockReturnValue([muteItem, leaveItem, deleteItem]); + renderSection(); + expect(screen.getByTestId('channel-details-action-mute')).toBeTruthy(); + expect(screen.getByTestId('channel-details-action-leave')).toBeTruthy(); + expect(screen.getByTestId('channel-details-action-deleteChannel')).toBeTruthy(); + }); + + it('forwards the icon, label, and onPress to ChannelDetailsListItem', () => { + useActionItemsSpy.mockReturnValue([muteItem]); + renderSection(); + const [item] = probeCalls; + expect(item.Icon).toBe(muteItem.Icon); + expect(item.label).toBe('Mute Group'); + expect(typeof item.onPress).toBe('function'); + }); + + it('passes destructive=true only for items with type="destructive"', () => { + useActionItemsSpy.mockReturnValue([muteItem, leaveItem, deleteItem]); + renderSection(); + const byId = Object.fromEntries(probeCalls.map((p) => [p.testID, p.destructive])); + expect(byId['channel-details-action-mute']).toBe(false); + expect(byId['channel-details-action-leave']).toBe(true); + expect(byId['channel-details-action-deleteChannel']).toBe(true); + }); + + it('invokes the original action when the list item is pressed', () => { + const action = jest.fn(); + useActionItemsSpy.mockReturnValue([buildItem({ action, id: 'mute', label: 'Mute Group' })]); + renderSection(); + fireEvent.press(screen.getByTestId('channel-details-action-mute')); + expect(action).toHaveBeenCalledTimes(1); + }); + }); + + describe('accessibility hints', () => { + const leaveItem = buildItem({ id: 'leave', label: 'Leave Group', type: 'destructive' }); + const deleteItem = buildItem({ + id: 'deleteChannel', + label: 'Delete Group', + type: 'destructive', + }); + const muteItem = buildItem({ id: 'mute', label: 'Mute Group' }); + + it('omits hints when AccessibilityContext is disabled (default)', () => { + useActionItemsSpy.mockReturnValue([muteItem, leaveItem, deleteItem]); + renderSection({ a11yEnabled: false }); + for (const item of probeCalls) { + expect(item.accessibilityHint).toBeUndefined(); + } + }); + + it('applies the group-specific leave/delete hints when accessibility is enabled and chat is a group', () => { + useIsDirectChatSpy.mockReturnValue(false); + useActionItemsSpy.mockReturnValue([muteItem, leaveItem, deleteItem]); + renderSection({ a11yEnabled: true }); + const byId = Object.fromEntries(probeCalls.map((p) => [p.testID, p.accessibilityHint])); + expect(byId['channel-details-action-mute']).toBeUndefined(); + expect(byId['channel-details-action-leave']).toBe('a11y/Removes you from this group'); + expect(byId['channel-details-action-deleteChannel']).toBe( + 'a11y/Deletes this group permanently', + ); + }); + + it('applies the direct-chat-specific hints when accessibility is enabled and chat is direct', () => { + useIsDirectChatSpy.mockReturnValue(true); + useActionItemsSpy.mockReturnValue([leaveItem, deleteItem]); + renderSection({ a11yEnabled: true }); + const byId = Object.fromEntries(probeCalls.map((p) => [p.testID, p.accessibilityHint])); + expect(byId['channel-details-action-leave']).toBe('a11y/Removes you from this chat'); + expect(byId['channel-details-action-deleteChannel']).toBe( + 'a11y/Deletes this chat permanently', + ); + }); + }); + + describe('ChannelDetailsListItem override', () => { + it('uses the override passed via WithComponents instead of the default', () => { + useActionItemsSpy.mockReturnValue([buildItem({ id: 'mute', label: 'Mute Group' })]); + renderSection(); + // Probe is our injected override — its presence proves the override path is used. + expect(probeCalls).toHaveLength(1); + }); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsListItem.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsListItem.test.tsx new file mode 100644 index 0000000000..281a5d1ed0 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsListItem.test.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { ThemeProvider } from '../../../contexts'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import type { IconProps } from '../../../icons/utils/base'; +import { ChannelDetailsListItem } from '../components/ChannelDetailsListItem'; + +const TestIcon = jest.fn(() => null); + +const renderItem = (props: Partial> = {}) => + render( + + + , + ); + +describe('ChannelDetailsListItem', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders the provided label', () => { + renderItem({ label: 'Mute Group' }); + expect(screen.getByText('Mute Group')).toBeTruthy(); + }); + + it('renders the icon', () => { + renderItem(); + expect(TestIcon).toHaveBeenCalled(); + }); + + it('renders the trailing slot when provided', () => { + renderItem({ trailing: 5 }); + expect(screen.getByTestId('trailing', { includeHiddenElements: true })).toBeTruthy(); + }); + + it('omits the trailing slot when not provided', () => { + renderItem({ testID: 'item' }); + expect(screen.queryByTestId('trailing', { includeHiddenElements: true })).toBeNull(); + }); + }); + + describe('interaction surface', () => { + it('renders as a non-interactive row when onPress is not provided', () => { + renderItem({ testID: 'item' }); + const row = screen.getByTestId('item'); + expect(row.props.accessibilityRole).toBeUndefined(); + expect(row.props.accessibilityLabel).toBeUndefined(); + }); + + it('renders as a button with the label as accessibilityLabel when onPress is provided', () => { + renderItem({ onPress: jest.fn(), testID: 'item' }); + const row = screen.getByTestId('item'); + expect(row.props.accessibilityRole).toBe('button'); + expect(row.props.accessibilityLabel).toBe('Pinned Messages'); + }); + + it('forwards accessibilityHint when provided', () => { + renderItem({ + accessibilityHint: 'Removes you from this group', + onPress: jest.fn(), + testID: 'item', + }); + expect(screen.getByTestId('item').props.accessibilityHint).toBe( + 'Removes you from this group', + ); + }); + + it('invokes onPress when the row is pressed', () => { + const onPress = jest.fn(); + renderItem({ onPress, testID: 'item' }); + fireEvent.press(screen.getByTestId('item')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('does not throw when pressed without an onPress (read-only row)', () => { + renderItem({ testID: 'item' }); + expect(() => fireEvent.press(screen.getByTestId('item'))).not.toThrow(); + }); + }); + + describe('destructive variant', () => { + const lastIconProps = () => TestIcon.mock.calls[TestIcon.mock.calls.length - 1][0]; + const labelColor = () => { + const styles = screen.getByText('Pinned Messages').props.style as Array< + { color?: string } | undefined + >; + return styles.find((s) => s?.color)?.color; + }; + + it('uses the same color for fill and stroke', () => { + renderItem(); + const icon = lastIconProps(); + expect(icon.fill).toBe(icon.stroke); + }); + + it('paints the icon and label differently when destructive vs standard', () => { + const { rerender } = renderItem({ destructive: false }); + const standardIcon = lastIconProps().fill; + const standardLabelColor = labelColor(); + + TestIcon.mockClear(); + rerender( + + + , + ); + const destructiveIcon = lastIconProps().fill; + const destructiveLabelColor = labelColor(); + + expect(destructiveIcon).not.toBe(standardIcon); + expect(destructiveLabelColor).not.toBe(standardLabelColor); + expect(destructiveIcon).toBe(destructiveLabelColor); + }); + }); + + describe('accessibility', () => { + it('hides the trailing slot from assistive tech (label carries the announcement)', () => { + renderItem({ testID: 'item', trailing: 5 }); + // Hidden by default (a11y queries skip it)… + expect(screen.queryByTestId('trailing')).toBeNull(); + // …but still present in the tree. + expect(screen.queryByTestId('trailing', { includeHiddenElements: true })).toBeTruthy(); + }); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberList.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberList.test.tsx new file mode 100644 index 0000000000..8e99ccffd4 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberList.test.tsx @@ -0,0 +1,147 @@ +import React, { type ComponentProps } from 'react'; +import { Text } from 'react-native'; + +import { render } from '@testing-library/react-native'; +import type { Channel, ChannelMemberResponse } from 'stream-chat'; + +import { ChannelDetailsContextProvider } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { ChatContext } from '../../../contexts/chatContext/ChatContext'; +import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; +import { generateMember } from '../../../mock-builders/generator/member'; +import { generateUser } from '../../../mock-builders/generator/user'; +import { StreamBottomSheetModalFlatList } from '../../UIComponents/StreamBottomSheetModalFlatList'; +import { ChannelDetailsMemberList } from '../components/ChannelDetailsMemberList'; +import type { ChannelDetailsMemberListItemProps } from '../components/ChannelDetailsMemberListItem'; + +type FlatListProps = ComponentProps>; + +const mockStreamBottomSheetModalFlatList = jest.fn( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_props: FlatListProps) => null, +); + +jest.mock('../../UIComponents/StreamBottomSheetModalFlatList', () => ({ + StreamBottomSheetModalFlatList: (...args: [FlatListProps]) => + mockStreamBottomSheetModalFlatList(...args), +})); + +const buildChannel = (members: ChannelMemberResponse[]): Channel => + ({ + cid: 'messaging:test', + data: {}, + on: () => ({ unsubscribe: () => undefined }), + state: { + members: Object.fromEntries( + members.map((m) => [m.user?.id ?? m.user_id ?? '', m]).filter(([k]) => Boolean(k)), + ), + }, + }) as unknown as Channel; + +type Probe = ChannelDetailsMemberListItemProps; + +const probeCalls: Probe[] = []; +const MemberListItemProbe = (props: Probe) => { + probeCalls.push(props); + return {props.member.user?.name}; +}; + +const renderList = ({ channel, currentUserId }: { channel: Channel; currentUserId?: string }) => + render( + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + + + + + + + + , + ); + +const renderItemsFromMock = (members: ChannelMemberResponse[]) => { + mockStreamBottomSheetModalFlatList.mockClear(); + probeCalls.length = 0; + + return members; +}; + +describe('ChannelDetailsMemberList', () => { + beforeEach(() => { + mockStreamBottomSheetModalFlatList.mockClear(); + probeCalls.length = 0; + }); + + it('forwards every channel member into the bottom sheet flat list', () => { + const alice = generateMember({ user: generateUser({ id: 'alice', name: 'Alice' }) }); + const bob = generateMember({ user: generateUser({ id: 'bob', name: 'Bob' }) }); + const channel = buildChannel([alice, bob]); + + renderList({ channel }); + + expect(mockStreamBottomSheetModalFlatList).toHaveBeenCalled(); + const props = mockStreamBottomSheetModalFlatList.mock.calls[0]?.[0]; + const data = props.data as ChannelMemberResponse[]; + expect(data).toHaveLength(2); + expect(data.map((m) => m.user?.id)).toEqual(['alice', 'bob']); + expect(typeof props.renderItem).toBe('function'); + expect(typeof props.keyExtractor).toBe('function'); + }); + + it('uses a stable keyExtractor based on user.id', () => { + const alice = generateMember({ user: generateUser({ id: 'alice' }) }); + const channel = buildChannel([alice]); + + renderList({ channel }); + + const { keyExtractor } = mockStreamBottomSheetModalFlatList.mock.calls[0]?.[0] ?? {}; + expect(keyExtractor?.(alice, 0)).toBe('alice'); + }); + + it('renders the resolved item component with the isCurrentUser flag', () => { + const alice = generateMember({ user: generateUser({ id: 'alice', name: 'Alice' }) }); + const bob = generateMember({ user: generateUser({ id: 'bob', name: 'Bob' }) }); + renderItemsFromMock([alice, bob]); + const channel = buildChannel([alice, bob]); + + renderList({ channel, currentUserId: 'alice' }); + + const { data: dataArray, renderItem } = + mockStreamBottomSheetModalFlatList.mock.calls[0]?.[0] ?? {}; + const data = dataArray as ChannelMemberResponse[]; + + expect(data).toHaveLength(2); + + data?.forEach((member, index) => { + render( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (renderItem as any)({ index, item: member, separators: {} as never }), + ); + }); + + expect(probeCalls).toHaveLength(2); + const byId = Object.fromEntries(probeCalls.map((p) => [p.member.user?.id, p])); + expect(byId.alice.isCurrentUser).toBe(true); + expect(byId.bob.isCurrentUser).toBe(false); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberListItem.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberListItem.test.tsx new file mode 100644 index 0000000000..4116ee623b --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberListItem.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react-native'; +import Dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import type { ChannelMemberResponse } from 'stream-chat'; + +import { ThemeProvider } from '../../../contexts'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; +import { ChannelDetailsMemberListItem } from '../components/ChannelDetailsMemberListItem'; + +Dayjs.extend(relativeTime); + +const memberFor = (overrides: Partial> = {}) => + ({ + user: { + id: 'alice', + name: 'Alice', + online: false, + ...overrides, + }, + }) as unknown as ChannelMemberResponse; + +const renderRow = (props: React.ComponentProps) => + render( + + ) => { + if (options && 'relativeTime' in options) { + return key.replace('{{relativeTime}}', String(options.relativeTime)); + } + return key; + }) as never, + tDateTimeParser: (input) => Dayjs(input), + userLanguage: 'en', + }} + > + + + , + ); + +describe('ChannelDetailsMemberListItem accessibility', () => { + it('composes name and offline status into the accessible label', () => { + renderRow({ member: memberFor() }); + expect(screen.getByLabelText('Alice, Offline')).toBeTruthy(); + }); + + it('includes the online status in the accessible label when the member is online', () => { + renderRow({ member: memberFor({ online: true }) }); + expect(screen.getByLabelText('Alice, Online')).toBeTruthy(); + }); + + it('uses "You" when the row represents the current user', () => { + renderRow({ isCurrentUser: true, member: memberFor() }); + expect(screen.getByLabelText('You, Offline')).toBeTruthy(); + }); +}); + +describe('ChannelDetailsMemberListItem activity status', () => { + it('shows "Online" for an online member', () => { + renderRow({ member: memberFor({ online: true }) }); + expect(screen.getByText('Online')).toBeTruthy(); + }); + + it('shows "Offline" for an offline member with no last_active', () => { + renderRow({ member: memberFor({ online: false }) }); + expect(screen.getByText('Offline')).toBeTruthy(); + }); + + it('shows a "Last seen ..." string for an offline member with last_active', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-05-13T12:00:00Z')); + const tenMinutesAgo = new Date('2026-05-13T11:50:00Z').toISOString(); + + renderRow({ member: memberFor({ last_active: tenMinutesAgo, online: false }) }); + + expect(screen.getByText(/^Last seen /)).toBeTruthy(); + jest.useRealTimers(); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberSection.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberSection.test.tsx new file mode 100644 index 0000000000..9efa5543d0 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberSection.test.tsx @@ -0,0 +1,399 @@ +import React from 'react'; +import { Pressable, Text } from 'react-native'; + +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import { NotificationManager } from 'stream-chat'; +import type { Channel, ChannelMemberResponse, UserResponse } from 'stream-chat'; + +import { AccessibilityProvider } from '../../../contexts/accessibilityContext/AccessibilityContext'; +import { ChannelDetailsContextProvider } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { ChatContext } from '../../../contexts/chatContext/ChatContext'; +import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; +import { + allOwnCapabilities, + OwnCapabilitiesContextValue, + OwnCapability, +} from '../../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; +import { useChannelActions } from '../../../hooks/useChannelActions'; +import { generateMember } from '../../../mock-builders/generator/member'; +import { generateUser } from '../../../mock-builders/generator/user'; +import type { ChannelAddMembersProps } from '../components/ChannelAddMembers'; +import { ChannelDetailsMemberSection } from '../components/ChannelDetailsMemberSection'; +import * as useChannelDetailsMembersPreviewModule from '../hooks/useChannelDetailsMembersPreview'; + +jest.mock('../../../hooks/useChannelActions'); +const mockedUseChannelActions = jest.mocked(useChannelActions); + +const MemberListProbe = () => full-member-list; + +const AddMembersProbe = ({ onSelectionChange }: ChannelAddMembersProps) => ( + <> + add-members + + onSelectionChange([generateUser({ id: 'picked-1', name: 'Picked One' })] as UserResponse[]) + } + testID='probe-select-one' + > + select one + + onSelectionChange([])} testID='probe-clear-selection'> + clear + + +); + +const buildChannel = ( + members: ChannelMemberResponse[], + memberCount?: number, + overrides?: Partial, +): Channel => + ({ + addMembers: jest.fn().mockResolvedValue(undefined), + cid: 'messaging:test', + data: { member_count: memberCount ?? members.length }, + on: () => ({ unsubscribe: () => undefined }), + state: { + members: Object.fromEntries( + members.map((m) => [m.user?.id ?? m.user_id ?? '', m]).filter(([k]) => Boolean(k)), + ), + }, + ...overrides, + }) as unknown as Channel; + +const applyCapabilities = ( + channel: Channel, + overrides?: Partial, +): Channel => { + if (!overrides) return channel; + const ownCapabilities = Object.entries(overrides) + .filter(([, enabled]) => enabled) + .map(([key]) => allOwnCapabilities[key as OwnCapability]); + (channel as { data?: Record }).data = { + ...((channel as { data?: Record }).data ?? {}), + own_capabilities: ownCapabilities, + }; + return channel; +}; + +const renderSection = ({ + capabilities, + channel, + onAddMembersPress, + onViewAllMembersPress, +}: { + channel: Channel; + capabilities?: Partial; + onAddMembersPress?: () => void; + onViewAllMembersPress?: () => void; +}) => + render( + + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + + + + + + + + + , + ); + +const makeMembers = (count: number) => + Array.from({ length: count }, (_, idx) => + generateMember({ user: generateUser({ id: `u-${idx}`, name: `User ${idx}` }) }), + ); + +describe('ChannelDetailsMemberSection', () => { + let previewSpy: jest.SpyInstance; + let addMembersSpy: jest.Mock; + + beforeEach(() => { + previewSpy = jest.spyOn( + useChannelDetailsMembersPreviewModule, + 'useChannelDetailsMembersPreview', + ); + addMembersSpy = jest.fn(async (_ids: string[], options?: { onSuccess?: () => unknown }) => { + await options?.onSuccess?.(); + }); + mockedUseChannelActions.mockReturnValue({ + addMembers: addMembersSpy, + } as unknown as ReturnType); + }); + + afterEach(() => { + jest.restoreAllMocks(); + mockedUseChannelActions.mockReset(); + }); + + it('hides the "View all" affordance when there are no extra members', () => { + previewSpy.mockReturnValue({ hasMore: false, total: 3, visible: makeMembers(3) }); + const channel = buildChannel(makeMembers(3), 3); + + renderSection({ channel }); + + expect(screen.queryByLabelText('View all')).toBeNull(); + }); + + it('shows the "View all" affordance when there are more members than the preview shows', () => { + previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); + const channel = buildChannel(makeMembers(12), 12); + + renderSection({ channel }); + + expect(screen.getByLabelText('View all')).toBeTruthy(); + }); + + it('opens the bottom-sheet member list when "View all" is pressed and no override is provided', () => { + previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); + const channel = buildChannel(makeMembers(12), 12); + + renderSection({ channel }); + + expect(screen.queryByTestId('member-list-probe')).toBeNull(); + fireEvent.press(screen.getByLabelText('View all')); + expect(screen.getByTestId('member-list-probe')).toBeTruthy(); + }); + + it('calls onViewAllMembersPress instead of opening the modal when provided', () => { + previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); + const channel = buildChannel(makeMembers(12), 12); + const onViewAllMembersPress = jest.fn(); + + renderSection({ channel, onViewAllMembersPress }); + + fireEvent.press(screen.getByLabelText('View all')); + + expect(onViewAllMembersPress).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('member-list-probe')).toBeNull(); + }); + + it('closes the bottom-sheet member list when the close button is pressed', () => { + previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); + const channel = buildChannel(makeMembers(12), 12); + + renderSection({ channel }); + + fireEvent.press(screen.getByLabelText('View all')); + expect(screen.getByTestId('member-list-probe')).toBeTruthy(); + + fireEvent.press(screen.getByLabelText('a11y/Close')); + + expect(screen.queryByTestId('member-list-probe')).toBeNull(); + }); + + it('does not render any add-members affordance when the user lacks update-channel-members capability', () => { + previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); + const channel = buildChannel(makeMembers(12), 12); + + renderSection({ channel }); + + expect(screen.queryByTestId('channel-details-member-section-add-button')).toBeNull(); + + fireEvent.press(screen.getByLabelText('View all')); + + expect(screen.queryByTestId('channel-details-member-list-add-button')).toBeNull(); + }); + + it('renders the preview add button and invokes onAddMembersPress when the user has the capability', () => { + previewSpy.mockReturnValue({ hasMore: false, total: 3, visible: makeMembers(3) }); + const channel = buildChannel(makeMembers(3), 3); + const onAddMembersPress = jest.fn(); + + renderSection({ + capabilities: { updateChannelMembers: true }, + channel, + onAddMembersPress, + }); + + const previewAddButton = screen.getByTestId('channel-details-member-section-add-button'); + fireEvent.press(previewAddButton); + + expect(onAddMembersPress).toHaveBeenCalledTimes(1); + }); + + it('renders the modal add button and invokes onAddMembersPress when the user has the capability', () => { + previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); + const channel = buildChannel(makeMembers(12), 12); + const onAddMembersPress = jest.fn(); + + renderSection({ + capabilities: { updateChannelMembers: true }, + channel, + onAddMembersPress, + }); + + fireEvent.press(screen.getByLabelText('View all')); + fireEvent.press(screen.getByTestId('channel-details-member-list-add-button')); + + // Preview-Add (first press inside renderSection: not pressed) is not counted; only the + // modal-Add press fires the callback. + expect(onAddMembersPress).toHaveBeenCalledTimes(1); + }); + + it('opens the Add-members sheet when the modal Add button is pressed and no onAddMembersPress override is provided', () => { + previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); + const channel = buildChannel(makeMembers(12), 12); + + renderSection({ + capabilities: { updateChannelMembers: true }, + channel, + }); + + fireEvent.press(screen.getByLabelText('View all')); + fireEvent.press(screen.getByTestId('channel-details-member-list-add-button')); + + expect(screen.getByTestId('add-members-probe')).toBeTruthy(); + // View-all sheet is dismissed when Add-members opens (swap, not stack). + expect(screen.queryByTestId('member-list-probe')).toBeNull(); + }); + + it('opens the Add-members sheet when the preview Add is pressed and no onAddMembersPress override is provided', () => { + previewSpy.mockReturnValue({ hasMore: false, total: 3, visible: makeMembers(3) }); + const channel = buildChannel(makeMembers(3), 3); + + renderSection({ + capabilities: { updateChannelMembers: true }, + channel, + }); + + expect(screen.queryByTestId('add-members-probe')).toBeNull(); + fireEvent.press(screen.getByTestId('channel-details-member-section-add-button')); + expect(screen.getByTestId('add-members-probe')).toBeTruthy(); + }); + + it('keeps the Add-members confirm button disabled until ChannelAddMembers reports a selection', () => { + previewSpy.mockReturnValue({ hasMore: false, total: 3, visible: makeMembers(3) }); + const channel = buildChannel(makeMembers(3), 3); + + renderSection({ + capabilities: { updateChannelMembers: true }, + channel, + }); + + fireEvent.press(screen.getByTestId('channel-details-member-section-add-button')); + const confirm = screen.getByTestId('channel-details-add-members-confirm-button'); + expect(confirm.props.accessibilityState).toMatchObject({ disabled: true }); + + fireEvent.press(screen.getByTestId('probe-select-one')); + expect( + screen.getByTestId('channel-details-add-members-confirm-button').props.accessibilityState, + ).toMatchObject({ disabled: false }); + + fireEvent.press(screen.getByTestId('probe-clear-selection')); + expect( + screen.getByTestId('channel-details-add-members-confirm-button').props.accessibilityState, + ).toMatchObject({ disabled: true }); + }); + + it('calls addMembers from useChannelActions with the selected user ids and closes the sheet on confirm', async () => { + previewSpy.mockReturnValue({ hasMore: false, total: 3, visible: makeMembers(3) }); + const channel = buildChannel(makeMembers(3), 3); + + renderSection({ + capabilities: { updateChannelMembers: true }, + channel, + }); + + fireEvent.press(screen.getByTestId('channel-details-member-section-add-button')); + fireEvent.press(screen.getByTestId('probe-select-one')); + + await act(async () => { + fireEvent.press(screen.getByTestId('channel-details-add-members-confirm-button')); + await Promise.resolve(); + }); + + expect(addMembersSpy).toHaveBeenCalledWith( + ['picked-1'], + expect.objectContaining({ onSuccess: expect.any(Function) }), + ); + expect(channel.addMembers).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByTestId('add-members-probe')).toBeNull()); + }); + + it('resets the selection when the Add-members sheet is closed via the X', () => { + previewSpy.mockReturnValue({ hasMore: false, total: 3, visible: makeMembers(3) }); + const channel = buildChannel(makeMembers(3), 3); + + renderSection({ + capabilities: { updateChannelMembers: true }, + channel, + }); + + fireEvent.press(screen.getByTestId('channel-details-member-section-add-button')); + fireEvent.press(screen.getByTestId('probe-select-one')); + expect( + screen.getByTestId('channel-details-add-members-confirm-button').props.accessibilityState, + ).toMatchObject({ disabled: false }); + + // Two 'a11y/Close' labels render: one in the View-all modal header, one in the + // Add-members modal header. Both are mounted (the modal contents stay in the tree + // until their close animation finishes); grab the last one — the Add-members close. + const closes = screen.getAllByLabelText('a11y/Close'); + fireEvent.press(closes[closes.length - 1]); + + fireEvent.press(screen.getByTestId('channel-details-member-section-add-button')); + expect( + screen.getByTestId('channel-details-add-members-confirm-button').props.accessibilityState, + ).toMatchObject({ disabled: true }); + }); + + it('keeps the Add-members sheet open and re-enables confirm when addMembers does not invoke onSuccess', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + previewSpy.mockReturnValue({ hasMore: false, total: 3, visible: makeMembers(3) }); + // Simulate the hook's internal error catch: addMembers resolves without invoking onSuccess. + addMembersSpy.mockResolvedValueOnce(undefined); + const channel = buildChannel(makeMembers(3), 3); + + renderSection({ + capabilities: { updateChannelMembers: true }, + channel, + }); + + fireEvent.press(screen.getByTestId('channel-details-member-section-add-button')); + fireEvent.press(screen.getByTestId('probe-select-one')); + + await act(async () => { + fireEvent.press(screen.getByTestId('channel-details-add-members-confirm-button')); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(addMembersSpy).toHaveBeenCalledWith( + ['picked-1'], + expect.objectContaining({ onSuccess: expect.any(Function) }), + ); + expect(screen.getByTestId('add-members-probe')).toBeTruthy(); + expect( + screen.getByTestId('channel-details-add-members-confirm-button').props.accessibilityState, + ).toMatchObject({ disabled: false }); + + warnSpy.mockRestore(); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsProfile.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsProfile.test.tsx new file mode 100644 index 0000000000..7b88832fac --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsProfile.test.tsx @@ -0,0 +1,187 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react-native'; +import type { Channel, ChannelMemberResponse } from 'stream-chat'; + +import { ChannelDetailsContextProvider } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { ChatProvider } from '../../../contexts/chatContext/ChatContext'; +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; +import * as useChannelMuteActiveModule from '../../../hooks/useChannelMuteActive'; +import * as useIsDirectChatModule from '../../../hooks/useIsDirectChat'; +import * as useChannelMembersStateModule from '../../ChannelList/hooks/useChannelMembersState'; +import * as useChannelPreviewDisplayNameModule from '../../ChannelPreview/hooks/useChannelPreviewDisplayName'; +import { ChannelDetailsProfile } from '../components/ChannelDetailsProfile'; +import * as useChannelDetailsMemberStatusTextModule from '../hooks/useChannelDetailsMemberStatusText'; + +const channelAvatarCalls: Array<{ size?: string; showBorder?: boolean }> = []; +jest.mock('../../ui/Avatar/ChannelAvatar', () => { + const RN = jest.requireActual('react-native'); + const ReactActual = jest.requireActual('react'); + return { + ChannelAvatar: (props: { size?: string; showBorder?: boolean }) => { + channelAvatarCalls.push({ showBorder: props.showBorder, size: props.size }); + return ReactActual.createElement(RN.View, { testID: 'channel-avatar' }); + }, + }; +}); + +const OWN_USER_ID = 'own-user'; +const OTHER_USER_ID = 'other-user'; + +const buildMember = (id: string, online = false): ChannelMemberResponse => + ({ + user: { id, online }, + user_id: id, + }) as unknown as ChannelMemberResponse; + +const buildChannel = () => + ({ + cid: 'messaging:test', + data: {}, + getClient: () => ({ userID: OWN_USER_ID }), + on: () => ({ unsubscribe: () => undefined }), + }) as unknown as Channel; + +const renderProfile = ({ channel = buildChannel() }: { channel?: Channel } = {}) => + render( + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + + + + + + , + ); + +describe('ChannelDetailsProfile', () => { + let useIsDirectChatSpy: jest.SpyInstance; + let useChannelMembersStateSpy: jest.SpyInstance; + let useChannelPreviewDisplayNameSpy: jest.SpyInstance; + let useChannelDetailsMemberStatusTextSpy: jest.SpyInstance; + let useChannelMuteActiveSpy: jest.SpyInstance; + + beforeEach(() => { + channelAvatarCalls.length = 0; + useIsDirectChatSpy = jest + .spyOn(useIsDirectChatModule, 'useIsDirectChat') + .mockReturnValue(false); + useChannelMembersStateSpy = jest + .spyOn(useChannelMembersStateModule, 'useChannelMembersState') + .mockReturnValue({}); + useChannelPreviewDisplayNameSpy = jest + .spyOn(useChannelPreviewDisplayNameModule, 'useChannelPreviewDisplayName') + .mockReturnValue('Display Name'); + useChannelDetailsMemberStatusTextSpy = jest + .spyOn(useChannelDetailsMemberStatusTextModule, 'useChannelDetailsMemberStatusText') + .mockReturnValue('12 members, 3 online'); + useChannelMuteActiveSpy = jest + .spyOn(useChannelMuteActiveModule, 'useChannelMuteActive') + .mockReturnValue(false); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('default rendering', () => { + it('renders the channel avatar with size="2xl" and no border', () => { + renderProfile(); + expect(screen.getByTestId('channel-avatar')).toBeTruthy(); + const last = channelAvatarCalls[channelAvatarCalls.length - 1]; + expect(last.size).toBe('2xl'); + expect(last.showBorder).toBe(false); + }); + + it('renders the display name as the title', () => { + renderProfile(); + expect(screen.getByText('Display Name')).toBeTruthy(); + }); + + it('marks the title with accessibilityRole="header"', () => { + renderProfile(); + const title = screen.getByText('Display Name'); + expect(title.props.accessibilityRole).toBe('header'); + }); + + it('renders an empty title when the display name is missing', () => { + useChannelPreviewDisplayNameSpy.mockReturnValue(undefined); + const { toJSON } = renderProfile(); + // No crash, and a Text node renders (empty string) + expect(toJSON()).toBeTruthy(); + }); + }); + + describe('group chats', () => { + beforeEach(() => { + useIsDirectChatSpy.mockReturnValue(false); + }); + + it('renders the group status text as the subtitle', () => { + renderProfile(); + expect(screen.getByText('12 members, 3 online')).toBeTruthy(); + }); + + it('does not render a subtitle when the group status text is empty', () => { + useChannelDetailsMemberStatusTextSpy.mockReturnValue(''); + renderProfile(); + expect(screen.queryByText('12 members, 3 online')).toBeNull(); + }); + }); + + describe('direct chats', () => { + beforeEach(() => { + useIsDirectChatSpy.mockReturnValue(true); + }); + + it('renders "Online" when the other member is online', () => { + useChannelMembersStateSpy.mockReturnValue({ + [OWN_USER_ID]: buildMember(OWN_USER_ID, true), + [OTHER_USER_ID]: buildMember(OTHER_USER_ID, true), + }); + renderProfile(); + expect(screen.getByText('Online')).toBeTruthy(); + }); + + it('does not render a subtitle when the other member is offline', () => { + useChannelMembersStateSpy.mockReturnValue({ + [OWN_USER_ID]: buildMember(OWN_USER_ID, true), + [OTHER_USER_ID]: buildMember(OTHER_USER_ID, false), + }); + renderProfile(); + expect(screen.queryByText('Online')).toBeNull(); + }); + + it('ignores the group status text in direct chats', () => { + useChannelMembersStateSpy.mockReturnValue({ + [OWN_USER_ID]: buildMember(OWN_USER_ID, true), + [OTHER_USER_ID]: buildMember(OTHER_USER_ID, false), + }); + renderProfile(); + expect(screen.queryByText('12 members, 3 online')).toBeNull(); + }); + }); + + describe('muted indicator', () => { + it('renders the muted indicator when useChannelMuteActive returns true', () => { + useChannelMuteActiveSpy.mockReturnValue(true); + renderProfile(); + expect(screen.getByTestId('channel-details-profile-muted-indicator')).toBeTruthy(); + }); + + it('does not render the muted indicator when useChannelMuteActive returns false', () => { + useChannelMuteActiveSpy.mockReturnValue(false); + renderProfile(); + expect(screen.queryByTestId('channel-details-profile-muted-indicator')).toBeNull(); + }); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsScreen.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsScreen.test.tsx new file mode 100644 index 0000000000..d6aad0c818 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsScreen.test.tsx @@ -0,0 +1,218 @@ +import React, { PropsWithChildren } from 'react'; +import { Text } from 'react-native'; + +import { render, screen } from '@testing-library/react-native'; +import { NotificationManager } from 'stream-chat'; +import type { Channel } from 'stream-chat'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { ChatContext } from '../../../contexts/chatContext/ChatContext'; +import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; +import type { OwnCapabilitiesContextValue } from '../../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; +import { useOwnCapabilitiesContext } from '../../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; +import * as useIsDirectChatModule from '../../../hooks/useIsDirectChat'; +import { ChannelDetailsScreen } from '../ChannelDetailsScreen'; + +const Providers = ({ children }: PropsWithChildren) => ( + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + {children} + + + +); + +const HeaderProbe = () => HEADER; +const ProfileProbe = () => PROFILE; +const NavigationProbe = () => NAVIGATION; +const MemberProbe = () => MEMBER; +const ActionsProbe = () => ACTIONS; + +const SECTION_OVERRIDES = { + ChannelDetailsActionsSection: ActionsProbe, + ChannelDetailsMemberSection: MemberProbe, + ChannelDetailsNavigationSection: NavigationProbe, + ChannelDetailsProfile: ProfileProbe, + ChannelDetailsScreenHeader: HeaderProbe, +}; + +const channel = { + cid: 'messaging:test', + id: 'test', + on: jest.fn(() => ({ unsubscribe: jest.fn() })), +} as unknown as Channel; + +const renderContent = () => + render( + + + + + , + ); + +describe('ChannelDetailsScreenContent', () => { + let useIsDirectChatSpy: jest.SpyInstance; + + beforeEach(() => { + useIsDirectChatSpy = jest + .spyOn(useIsDirectChatModule, 'useIsDirectChat') + .mockReturnValue(false); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('section composition', () => { + it('renders header, profile, navigation, and actions sections', () => { + renderContent(); + expect(screen.getByTestId('probe-header')).toBeTruthy(); + expect(screen.getByTestId('probe-profile')).toBeTruthy(); + expect(screen.getByTestId('probe-navigation')).toBeTruthy(); + expect(screen.getByTestId('probe-actions')).toBeTruthy(); + }); + + it('renders the member section for group chats', () => { + useIsDirectChatSpy.mockReturnValue(false); + renderContent(); + expect(screen.getByTestId('probe-member')).toBeTruthy(); + }); + + it('hides the member section for direct chats', () => { + useIsDirectChatSpy.mockReturnValue(true); + renderContent(); + expect(screen.queryByTestId('probe-member')).toBeNull(); + }); + }); +}); + +describe('ChannelDetailsScreen', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('context provisioning', () => { + it('exposes channel and callbacks via ChannelDetailsContext', () => { + const onChannelDismiss = jest.fn(); + const onBack = jest.fn(); + let captured: ReturnType | undefined; + const ContextProbe = () => { + captured = useChannelDetailsContext(); + return null; + }; + + render( + + + + + , + ); + + expect(captured).toBeDefined(); + expect(captured?.channel).toBe(channel); + expect(captured?.onChannelDismiss).toBe(onChannelDismiss); + expect(captured?.onBack).toBe(onBack); + }); + + it('exposes own capabilities derived from the channel via OwnCapabilitiesContext', () => { + const unsubscribe = jest.fn(); + const channelWithCapabilities = { + cid: 'messaging:test', + id: 'test', + data: { own_capabilities: ['send-message', 'delete-own-message'] }, + on: jest.fn(() => ({ unsubscribe })), + } as unknown as Channel; + + let captured: OwnCapabilitiesContextValue | undefined; + const CapabilitiesProbe = () => { + captured = useOwnCapabilitiesContext(); + return null; + }; + + render( + + + + + , + ); + + expect(captured).toBeDefined(); + expect(captured?.sendMessage).toBe(true); + expect(captured?.deleteOwnMessage).toBe(true); + expect(captured?.banChannelMembers).toBe(false); + expect(channelWithCapabilities.on).toHaveBeenCalledWith( + 'capabilities.changed', + expect.any(Function), + ); + }); + }); + + describe('ChannelDetailsScreenContent override', () => { + it('renders the override instead of the default content', () => { + const Override = () => CUSTOM; + render( + + + + + , + ); + + expect(screen.getByTestId('custom-content')).toBeTruthy(); + // The default content's section probes should not render. + expect(screen.queryByTestId('probe-header')).toBeNull(); + expect(screen.queryByTestId('probe-profile')).toBeNull(); + }); + }); + + describe('default content path', () => { + it('falls back to ChannelDetailsScreenContent when no override is supplied', () => { + jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(false); + // Note: re-export the default Content via the override map so we can prove it + // wasn't swapped out — the section probes from SECTION_OVERRIDES should appear. + render( + + + + + , + ); + expect(screen.getByTestId('probe-header')).toBeTruthy(); + expect(screen.getByTestId('probe-actions')).toBeTruthy(); + }); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/useChannelDetailsActionItems.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/useChannelDetailsActionItems.test.tsx new file mode 100644 index 0000000000..290e2d667e --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/useChannelDetailsActionItems.test.tsx @@ -0,0 +1,222 @@ +import { renderHook } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +import * as channelDetailsContextModule from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import type { ChannelActionItem } from '../../../hooks/useChannelActionItems'; +import * as useChannelActionItemsModule from '../../../hooks/useChannelActionItems'; +import { useChannelDetailsActionItems } from '../hooks/useChannelDetailsActionItems'; + +type Customizer = NonNullable< + Parameters[0]['getChannelActionItems'] +>; + +const NoopIcon = () => null; + +const buildItem = (overrides: Partial): ChannelActionItem => ({ + action: jest.fn(), + Icon: NoopIcon, + id: 'mute', + label: 'Mute', + placement: 'sheet', + type: 'standard', + ...overrides, +}); + +const channel = { id: 'channel-id' } as unknown as Channel; + +const mockContext = ( + overrides: Partial = {}, +) => { + const value: channelDetailsContextModule.ChannelDetailsContextValue = { + channel, + onChannelDismiss: jest.fn(), + ...overrides, + }; + jest.spyOn(channelDetailsContextModule, 'useChannelDetailsContext').mockReturnValue(value); + return value; +}; + +describe('useChannelDetailsActionItems', () => { + let capturedCustomizer: Customizer | undefined; + let useChannelActionItemsSpy: jest.SpyInstance; + const returnedItems: ChannelActionItem[] = [buildItem({ id: 'mute' })]; + + beforeEach(() => { + capturedCustomizer = undefined; + useChannelActionItemsSpy = jest + .spyOn(useChannelActionItemsModule, 'useChannelActionItems') + .mockImplementation((params) => { + capturedCustomizer = params.getChannelActionItems; + return returnedItems; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('calls useChannelActionItems with the channel from context and a customizer', () => { + mockContext(); + renderHook(() => useChannelDetailsActionItems()); + + expect(useChannelActionItemsSpy).toHaveBeenCalledTimes(1); + expect(useChannelActionItemsSpy).toHaveBeenCalledWith({ + channel, + getChannelActionItems: expect.any(Function), + }); + }); + + it('returns whatever useChannelActionItems returns', () => { + mockContext(); + const { result } = renderHook(() => useChannelDetailsActionItems()); + + expect(result.current).toBe(returnedItems); + }); + + it('passes unrelated items through unchanged', () => { + mockContext(); + renderHook(() => useChannelDetailsActionItems()); + + const muteItem = buildItem({ id: 'mute' }); + const archiveItem = buildItem({ id: 'archive' }); + const result = capturedCustomizer!({ + context: { channel } as never, + defaultItems: [muteItem, archiveItem], + }); + + expect(result).toHaveLength(2); + expect(result[0]).toBe(muteItem); + expect(result[1]).toBe(archiveItem); + }); + + it('wraps leave action to call onChannelDismiss after the original action resolves', async () => { + const { onChannelDismiss } = mockContext(); + renderHook(() => useChannelDetailsActionItems()); + + const callOrder: string[] = []; + let resolveLeave: (() => void) | undefined; + const originalLeave = jest.fn( + (options?: { onSuccess?: () => unknown }) => + new Promise((resolve) => { + callOrder.push('leave-start'); + resolveLeave = async () => { + callOrder.push('leave-resolved'); + await options?.onSuccess?.(); + resolve(); + }; + }), + ); + (onChannelDismiss as jest.Mock).mockImplementation(() => { + callOrder.push('onChannelDismiss'); + }); + + const leaveItem = buildItem({ + action: originalLeave, + id: 'leave', + label: 'Leave Group', + placement: 'sheet', + type: 'destructive', + }); + + const [wrapped] = capturedCustomizer!({ + context: { channel } as never, + defaultItems: [leaveItem], + }); + + expect(wrapped).not.toBe(leaveItem); + expect(wrapped.id).toBe('leave'); + expect(wrapped.label).toBe('Leave Group'); + expect(wrapped.type).toBe('destructive'); + + const pending = wrapped.action(); + expect(originalLeave).toHaveBeenCalledTimes(1); + expect(onChannelDismiss).not.toHaveBeenCalled(); + + resolveLeave!(); + await pending; + + expect(onChannelDismiss).toHaveBeenCalledTimes(1); + expect(onChannelDismiss).toHaveBeenCalledWith(); + expect(callOrder).toEqual(['leave-start', 'leave-resolved', 'onChannelDismiss']); + }); + + it('wraps deleteChannel action to call onChannelDismiss after the original action resolves', async () => { + const { onChannelDismiss } = mockContext(); + renderHook(() => useChannelDetailsActionItems()); + + const callOrder: string[] = []; + let resolveDelete: (() => void) | undefined; + const originalDelete = jest.fn( + (options?: { onSuccess?: () => unknown }) => + new Promise((resolve) => { + callOrder.push('delete-start'); + resolveDelete = async () => { + callOrder.push('delete-resolved'); + await options?.onSuccess?.(); + resolve(); + }; + }), + ); + (onChannelDismiss as jest.Mock).mockImplementation(() => { + callOrder.push('onChannelDismiss'); + }); + + const deleteItem = buildItem({ + action: originalDelete, + id: 'deleteChannel', + label: 'Delete Group', + placement: 'sheet', + type: 'destructive', + }); + + const [wrapped] = capturedCustomizer!({ + context: { channel } as never, + defaultItems: [deleteItem], + }); + + expect(wrapped).not.toBe(deleteItem); + expect(wrapped.id).toBe('deleteChannel'); + expect(wrapped.label).toBe('Delete Group'); + expect(wrapped.type).toBe('destructive'); + + const pending = wrapped.action(); + expect(originalDelete).toHaveBeenCalledTimes(1); + expect(onChannelDismiss).not.toHaveBeenCalled(); + + resolveDelete!(); + await pending; + + expect(onChannelDismiss).toHaveBeenCalledTimes(1); + expect(onChannelDismiss).toHaveBeenCalledWith(); + expect(callOrder).toEqual(['delete-start', 'delete-resolved', 'onChannelDismiss']); + }); + + it('does not throw when onChannelDismiss is undefined on the leave path', async () => { + mockContext({ onChannelDismiss: undefined }); + renderHook(() => useChannelDetailsActionItems()); + + const originalLeave = jest.fn().mockResolvedValue(undefined); + const [wrapped] = capturedCustomizer!({ + context: { channel } as never, + defaultItems: [buildItem({ action: originalLeave, id: 'leave' })], + }); + + await expect(wrapped.action()).resolves.toBeUndefined(); + expect(originalLeave).toHaveBeenCalledTimes(1); + }); + + it('does not throw when onChannelDismiss is undefined on the deleteChannel path', async () => { + mockContext({ onChannelDismiss: undefined }); + renderHook(() => useChannelDetailsActionItems()); + + const originalDelete = jest.fn().mockResolvedValue(undefined); + const [wrapped] = capturedCustomizer!({ + context: { channel } as never, + defaultItems: [buildItem({ action: originalDelete, id: 'deleteChannel' })], + }); + + await expect(wrapped.action()).resolves.toBeUndefined(); + expect(originalDelete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/useUserActivityStatus.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/useUserActivityStatus.test.tsx new file mode 100644 index 0000000000..d3f28021b7 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/useUserActivityStatus.test.tsx @@ -0,0 +1,75 @@ +import React, { type PropsWithChildren } from 'react'; + +import { renderHook } from '@testing-library/react-native'; +import Dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import type { UserResponse } from 'stream-chat'; + +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; +import { useUserActivityStatus } from '../hooks/useUserActivityStatus'; + +Dayjs.extend(relativeTime); + +const wrapper = ({ children }: PropsWithChildren) => ( + ) => { + if (options && 'relativeTime' in options) { + return `${key.replace('{{relativeTime}}', String(options.relativeTime))}`; + } + return key; + }) as never, + tDateTimeParser: (input) => Dayjs(input), + userLanguage: 'en', + }} + > + {children} + +); + +const userFor = (overrides: Partial = {}): UserResponse => + ({ id: 'u-1', ...overrides }) as UserResponse; + +describe('useUserActivityStatus', () => { + it('returns "Online" when the user is online', () => { + const { result } = renderHook(() => useUserActivityStatus(userFor({ online: true })), { + wrapper, + }); + expect(result.current).toBe('Online'); + }); + + it('returns "Offline" when the user is offline and has no last_active', () => { + const { result } = renderHook(() => useUserActivityStatus(userFor({ online: false })), { + wrapper, + }); + expect(result.current).toBe('Offline'); + }); + + it('returns "Offline" when no user is provided', () => { + const { result } = renderHook(() => useUserActivityStatus(undefined), { wrapper }); + expect(result.current).toBe('Offline'); + }); + + it('returns a relative "Last seen ..." string when offline with a valid last_active', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-05-13T12:00:00Z')); + const tenMinutesAgo = new Date('2026-05-13T11:50:00Z').toISOString(); + + const { result } = renderHook( + () => useUserActivityStatus(userFor({ last_active: tenMinutesAgo, online: false })), + { wrapper }, + ); + + expect(result.current).toMatch(/^Last seen /); + expect(result.current).toContain('minutes ago'); + + jest.useRealTimers(); + }); + + it('falls back to "Offline" when last_active is unparseable', () => { + const { result } = renderHook( + () => useUserActivityStatus(userFor({ last_active: 'not-a-date' as never, online: false })), + { wrapper }, + ); + expect(result.current).toBe('Offline'); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/components/ChannelAddMembers.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelAddMembers.tsx new file mode 100644 index 0000000000..563dfec165 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelAddMembers.tsx @@ -0,0 +1,428 @@ +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + ColorValue, + FlatList, + I18nManager, + Pressable, + StyleProp, + StyleSheet, + Text, + TextInput, + View, + ViewStyle, +} from 'react-native'; + +import type { UserResponse } from 'stream-chat'; + +import { BottomSheetContext } from '../../../contexts/bottomSheetContext/BottomSheetContext'; +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { DEFAULT_BASE_CONTEXT_VALUE } from '../../../contexts/utils/defaultBaseContextValue'; +import { useStableCallback } from '../../../hooks/useStableCallback'; +import { Checkmark } from '../../../icons/checkmark-1'; +import { Search } from '../../../icons/search'; +import { NewClose } from '../../../icons/xmark'; +import { primitives } from '../../../theme'; +import { UserAvatar } from '../../ui/Avatar/UserAvatar'; +import { StreamBottomSheetModalFlatList } from '../../UIComponents/StreamBottomSheetModalFlatList'; +import { useChannelAddMembers } from '../hooks/useChannelAddMembers'; + +export type ChannelAddMembersProps = { + /** + * Fires whenever the internal selection changes. Parent components use this to + * drive a confirm button (enable when the selection is non-empty, read the + * selected user ids when committing the add). + */ + onSelectionChange: (selectedUsers: UserResponse[]) => void; +}; + +const keyExtractor = (user: UserResponse) => user.id; + +type SelectionCircleProps = { + selected: boolean; + selectedStyle?: StyleProp; + unselectedStyle?: StyleProp; +}; + +const SelectionCircle = React.memo( + ({ selected, selectedStyle, unselectedStyle }: SelectionCircleProps) => { + const { + theme: { semantics }, + } = useTheme(); + const styles = useStyles(); + + if (selected) { + return ( + + + + ); + } + + return ( + + ); + }, +); + +SelectionCircle.displayName = 'SelectionCircle'; + +type RowProps = { + accessibilityLabel: string; + onPress: () => void; + rowStyle?: StyleProp; + selected: boolean; + selectedCircleStyle?: StyleProp; + unselectedCircleStyle?: StyleProp; + user: UserResponse; + userNameColor: ColorValue; + userNameStyle?: StyleProp; +}; + +const ChannelAddMembersRow = React.memo( + ({ + accessibilityLabel, + onPress, + rowStyle, + selected, + selectedCircleStyle, + unselectedCircleStyle, + user, + userNameColor, + userNameStyle, + }: RowProps) => { + const styles = useStyles(); + const displayName = user.name ?? user.id; + return ( + [styles.userRow, rowStyle, pressed && { opacity: 0.7 }]} + testID={`channel-add-members-row-${user.id}`} + > + + + {displayName} + + + + ); + }, +); + +ChannelAddMembersRow.displayName = 'ChannelAddMembersRow'; + +type EmptyStateProps = { + containerStyle?: StyleProp; + iconColor: ColorValue; + label: string; + loading: boolean; + textColor: ColorValue; + textStyle?: StyleProp; +}; + +const ChannelAddMembersEmptyState = ({ + containerStyle, + iconColor, + label, + loading, + textColor, + textStyle, +}: EmptyStateProps) => { + const styles = useStyles(); + + if (loading) { + return ( + + + + ); + } + return ( + + + + {label} + + + ); +}; + +export const ChannelAddMembers = ({ onSelectionChange }: ChannelAddMembersProps) => { + const { channel } = useChannelDetailsContext(); + const { t } = useTranslationContext(); + const { + theme: { + channelDetailsScreen: { + addMembers: { + emptyState: emptyStateOverride, + emptyStateText: emptyStateTextOverride, + searchContainer: searchContainerOverride, + searchInput: searchInputOverride, + searchInputFocused: searchInputFocusedOverride, + searchTextInput: searchTextInputOverride, + selectionCircle: selectionCircleOverride, + selectionCircleSelected: selectionCircleSelectedOverride, + userName: userNameOverride, + userRow: userRowOverride, + }, + }, + semantics, + }, + } = useTheme(); + const styles = useStyles(); + + const { + clearSearch, + isSelected, + loading, + loadMore, + onChangeSearchText, + results, + searchText, + selectedUsers, + toggleUser, + } = useChannelAddMembers({ channel }); + + const [searchFocused, setSearchFocused] = useState(false); + + const bottomSheetContext = useContext(BottomSheetContext); + const List = + bottomSheetContext === DEFAULT_BASE_CONTEXT_VALUE ? FlatList : StreamBottomSheetModalFlatList; + + const stableOnSelectionChange = useStableCallback(onSelectionChange); + + const lastSelectionRef = useRef(selectedUsers); + useEffect(() => { + if (lastSelectionRef.current === selectedUsers) return; + lastSelectionRef.current = selectedUsers; + stableOnSelectionChange(selectedUsers); + }, [selectedUsers, stableOnSelectionChange]); + + const renderItem = useCallback( + ({ item }: { item: UserResponse }) => { + const selected = isSelected(item.id); + const displayName = item.name ?? item.id; + return ( + toggleUser(item)} + rowStyle={userRowOverride} + selected={selected} + selectedCircleStyle={selectionCircleSelectedOverride} + unselectedCircleStyle={selectionCircleOverride} + user={item} + userNameColor={semantics.textPrimary} + userNameStyle={userNameOverride} + /> + ); + }, + [ + isSelected, + selectionCircleOverride, + selectionCircleSelectedOverride, + semantics.textPrimary, + t, + toggleUser, + userNameOverride, + userRowOverride, + ], + ); + + const emptyStateElement = ( + + ); + + return ( + + + + + setSearchFocused(false)} + onChangeText={onChangeSearchText} + onFocus={() => setSearchFocused(true)} + placeholder={t('Search')} + placeholderTextColor={semantics.textSecondary} + style={[ + styles.searchTextInput, + { color: semantics.textPrimary }, + searchTextInputOverride, + ]} + testID='channel-add-members-search-input' + value={searchText} + /> + {searchText.length > 0 ? ( + + + + ) : null} + + + + + + ); +}; + +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + + return useMemo( + () => + StyleSheet.create({ + container: { + flex: 1, + }, + emptyState: { + alignItems: 'center', + gap: primitives.spacingSm, + justifyContent: 'center', + paddingVertical: primitives.spacingXl, + height: '100%', + width: '100%', + }, + emptyStateText: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + textAlign: 'center', + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + list: { + flex: 1, + }, + listContent: { + flexGrow: 1, + paddingBottom: primitives.spacingXl, + }, + searchContainer: { + paddingBottom: primitives.spacingSm, + paddingHorizontal: primitives.spacingMd, + paddingTop: primitives.spacingXs, + }, + searchInput: { + alignItems: 'center', + borderRadius: primitives.radiusMax, + borderWidth: 1, + flexDirection: 'row', + gap: primitives.spacingSm, + height: 48, + paddingHorizontal: primitives.spacingMd, + }, + searchTextInput: { + flex: 1, + fontSize: primitives.typographyFontSizeMd, + lineHeight: primitives.typographyLineHeightNormal, + padding: 0, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + clearSearch: { + justifyContent: 'center', + alignItems: 'center', + borderRadius: primitives.radiusMax, + borderWidth: 1.5, + borderColor: semantics.inputTextIcon, + height: 15, + width: 15, + }, + selectionCircle: { + alignItems: 'center', + borderRadius: primitives.radiusMax, + borderWidth: 1, + height: 24, + justifyContent: 'center', + width: 24, + }, + userName: { + flex: 1, + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + userRow: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingSm, + minHeight: 52, + paddingHorizontal: primitives.spacingMd, + paddingVertical: primitives.spacingXs, + }, + }), + [semantics], + ); +}; diff --git a/package/src/components/ChannelDetailsScreen/components/ChannelDetailsActionsSection.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsActionsSection.tsx new file mode 100644 index 0000000000..0fd630f90a --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsActionsSection.tsx @@ -0,0 +1,75 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useA11yLabel } from '../../../a11y/hooks/useA11yLabel'; +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useIsDirectChat } from '../../../hooks/useIsDirectChat'; +import { primitives } from '../../../theme'; +import { useChannelDetailsActionItems } from '../hooks'; + +export const ChannelDetailsActionsSection = () => { + const { channel } = useChannelDetailsContext(); + const { + theme: { + channelDetailsScreen: { sectionCard: sectionCardOverride }, + semantics, + }, + } = useTheme(); + const { ChannelDetailsListItem } = useComponentsContext(); + const isDirect = useIsDirectChat(channel); + const leaveHint = useA11yLabel( + isDirect ? 'a11y/Removes you from this chat' : 'a11y/Removes you from this group', + ); + const deleteHint = useA11yLabel( + isDirect ? 'a11y/Deletes this chat permanently' : 'a11y/Deletes this group permanently', + ); + const styles = useStyles(); + + const items = useChannelDetailsActionItems(); + + if (items.length === 0) return null; + + return ( + + {items.map((item) => { + const testID = `channel-details-action-${item.id}`; + const accessibilityHint = + item.id === 'leave' ? leaveHint : item.id === 'deleteChannel' ? deleteHint : undefined; + + return ( + item.action()} + testID={testID} + /> + ); + })} + + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + sectionCard: { + borderRadius: primitives.radiusLg, + overflow: 'hidden', + paddingVertical: primitives.spacingXs, + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetailsScreen/components/ChannelDetailsListItem.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsListItem.tsx new file mode 100644 index 0000000000..2e9404747e --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsListItem.tsx @@ -0,0 +1,141 @@ +import React, { useMemo } from 'react'; +import { I18nManager, Pressable, StyleSheet, Text, View } from 'react-native'; + +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import type { IconProps } from '../../../icons/utils/base'; +import { primitives } from '../../../theme'; + +export type ChannelDetailsListItemProps = { + Icon: React.ComponentType; + label: string; + accessibilityHint?: string; + destructive?: boolean; + onPress?: () => void; + testID?: string; + trailing?: React.ReactNode; +}; + +export const ChannelDetailsListItem = ({ + accessibilityHint, + Icon, + destructive = false, + label, + onPress, + testID, + trailing, +}: ChannelDetailsListItemProps) => { + const { + theme: { + channelDetailsScreen: { + listItem: { + container: containerOverride, + destructiveLabel: destructiveLabelOverride, + iconWrapper: iconWrapperOverride, + label: labelOverride, + }, + }, + semantics, + }, + } = useTheme(); + const styles = useStyles(); + const labelColor = destructive ? semantics.accentError : semantics.textPrimary; + const iconColor = destructive ? semantics.accentError : semantics.textPrimary; + + const content = ( + + + + + + {label} + + {trailing ? ( + + {trailing} + + ) : null} + + ); + + if (!onPress) { + return ( + + {content} + + ); + } + + return ( + [ + styles.row, + pressed ? { backgroundColor: semantics.backgroundUtilityPressed } : null, + ]} + testID={testID} + > + {content} + + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + contentContainer: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + gap: primitives.spacingSm, + minHeight: 48, + paddingHorizontal: primitives.spacingSm, + paddingVertical: primitives.spacingXs, + }, + iconWrapper: { + alignItems: 'center', + height: 20, + justifyContent: 'center', + width: 20, + }, + label: { + flex: 1, + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + row: { + alignItems: 'center', + flexDirection: 'row', + minHeight: 48, + paddingHorizontal: primitives.spacingXxs, + }, + trailing: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'flex-end', + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberList.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberList.tsx new file mode 100644 index 0000000000..a16f852bc4 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberList.tsx @@ -0,0 +1,45 @@ +import React, { useCallback, useContext, useMemo } from 'react'; +import { FlatList } from 'react-native'; + +import type { ChannelMemberResponse } from 'stream-chat'; + +import { BottomSheetContext } from '../../../contexts/bottomSheetContext/BottomSheetContext'; +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; +import { DEFAULT_BASE_CONTEXT_VALUE } from '../../../contexts/utils/defaultBaseContextValue'; +import { useChannelMembersState } from '../../ChannelList/hooks/useChannelMembersState'; +import { StreamBottomSheetModalFlatList } from '../../UIComponents/StreamBottomSheetModalFlatList'; + +const keyExtractor = (member: ChannelMemberResponse) => member.user?.id ?? member.user_id ?? ''; + +/** + * Renders the full list of channel members. + * + * Auto-detects whether a `BottomSheetProvider` is mounted above it: when rendered + * inside a bottom sheet (the default `ChannelDetailsMemberSection` path), uses + * `StreamBottomSheetModalFlatList` so scroll is correctly handed off between the + * list and the surrounding sheet. When rendered standalone (e.g. inside a + * full-screen route reached via `onViewAllMembersPress`), falls back to a regular + * `FlatList`. + */ +export const ChannelDetailsMemberList = () => { + const { channel } = useChannelDetailsContext(); + const { client } = useChatContext(); + const { ChannelDetailsMemberListItem } = useComponentsContext(); + const members = useChannelMembersState(channel); + const bottomSheetContext = useContext(BottomSheetContext); + const List = + bottomSheetContext === DEFAULT_BASE_CONTEXT_VALUE ? FlatList : StreamBottomSheetModalFlatList; + + const data = useMemo(() => Object.values(members), [members]); + + const renderItem = useCallback( + ({ item }: { item: ChannelMemberResponse }) => ( + + ), + [ChannelDetailsMemberListItem, client.userID], + ); + + return ; +}; diff --git a/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberListItem.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberListItem.tsx new file mode 100644 index 0000000000..e47a84cabf --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberListItem.tsx @@ -0,0 +1,121 @@ +import React, { useMemo } from 'react'; +import { I18nManager, StyleSheet, Text, View } from 'react-native'; + +import type { ChannelMemberResponse } from 'stream-chat'; + +import { composeAccessibilityLabel } from '../../../a11y/a11yUtils'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { primitives } from '../../../theme'; +import { UserAvatar } from '../../ui/Avatar/UserAvatar'; +import { useUserActivityStatus } from '../hooks/useUserActivityStatus'; + +export type ChannelDetailsMemberListItemProps = { + member: ChannelMemberResponse; + isCurrentUser?: boolean; +}; + +const ChannelDetailsMemberListItemInner = ({ + isCurrentUser, + member, +}: ChannelDetailsMemberListItemProps) => { + const { t } = useTranslationContext(); + const { + theme: { + channelDetailsScreen: { + memberItem: { container: containerOverride, name: nameOverride, status: statusOverride }, + }, + semantics, + }, + } = useTheme(); + const styles = useStyles(); + const statusLine = useUserActivityStatus(member.user); + + const user = member.user; + if (!user) return null; + + const displayName = isCurrentUser ? t('You') : (user.name ?? user.id); + const accessibilityLabel = composeAccessibilityLabel(displayName, statusLine); + + return ( + + + + + {displayName} + + {statusLine ? ( + + {statusLine} + + ) : null} + + + ); +}; + +const areEqual = ( + prev: ChannelDetailsMemberListItemProps, + next: ChannelDetailsMemberListItemProps, +) => { + if (prev.isCurrentUser !== next.isCurrentUser) return false; + if (prev.member === next.member) return true; + const prevUser = prev.member.user; + const nextUser = next.member.user; + if (prevUser === nextUser) return prev.member.channel_role === next.member.channel_role; + if (!prevUser || !nextUser) return false; + return ( + prevUser.id === nextUser.id && + prevUser.name === nextUser.name && + prevUser.online === nextUser.online && + prevUser.image === nextUser.image && + prevUser.last_active === nextUser.last_active && + prev.member.channel_role === next.member.channel_role + ); +}; + +export const ChannelDetailsMemberListItem = React.memo(ChannelDetailsMemberListItemInner, areEqual); + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + body: { + flex: 1, + gap: 0, + minWidth: 0, + }, + container: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingSm, + minHeight: 48, + paddingHorizontal: primitives.spacingSm, + paddingVertical: primitives.spacingXs, + }, + name: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + status: { + fontSize: primitives.typographyFontSizeXs, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightTight, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberSection.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberSection.tsx new file mode 100644 index 0000000000..19780ca1cf --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberSection.tsx @@ -0,0 +1,416 @@ +import React, { useMemo, useState } from 'react'; +import { I18nManager, Pressable, StyleSheet, Text, useWindowDimensions, View } from 'react-native'; + +import type { UserResponse } from 'stream-chat'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; +import { useOwnCapabilitiesContext } from '../../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useChannelActions } from '../../../hooks/useChannelActions'; +import { useStableCallback } from '../../../hooks/useStableCallback'; +import { Checkmark } from '../../../icons/checkmark-1'; +import { UserAdd } from '../../../icons/user-add'; +import { NewClose } from '../../../icons/xmark'; +import { primitives } from '../../../theme'; +import { NotificationList } from '../../Notifications/NotificationList'; +import { NotificationTargetProvider } from '../../Notifications/NotificationTargetContext'; +import { Button } from '../../ui/Button/Button'; +import { BottomSheetModal } from '../../UIComponents/BottomSheetModal'; +import { useChannelDetailsMembersPreview } from '../hooks/useChannelDetailsMembersPreview'; + +type ModalHeaderProps = { + onClose: () => void; + title: string; + rightAction?: React.ReactNode; +}; + +const ModalHeader = ({ onClose, rightAction, title }: ModalHeaderProps) => { + const { + theme: { + channelDetailsScreen: { + memberSection: { + modalHeader: modalHeaderOverride, + modalHeaderTitle: modalHeaderTitleOverride, + }, + }, + semantics, + }, + } = useTheme(); + const styles = useStyles(); + + return ( + + +