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 965b73d3be..fbcc43d4e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,10 +42,13 @@ yarn lint-fix # Auto-fix lint and formatting issues ```bash yarn test:unit # All unit tests (sets TZ=UTC) yarn test:coverage # With coverage report +yarn test:typecheck # Type-check tests against tsconfig.test.json (run after any code change) yarn workspace stream-chat-react-native-core test:unit # Same as `yarn test:unit` cd package && TZ=UTC npx jest path/to/test.test.tsx # 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 4d0dd860ee..00fc913a3a 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -36,25 +36,22 @@ import { MessageListPruningConfigItem, } from './src/components/SecretMenu.tsx'; import { AppContext } from './src/context/AppContext'; -import { AppOverlayProvider } from './src/context/AppOverlayProvider'; import { StreamChatProvider } from './src/context/StreamChatContext'; import { UserSearchProvider } from './src/context/UserSearchContext'; import { useChatClient } from './src/hooks/useChatClient'; import { useStreamChatTheme } from './src/hooks/useStreamChatTheme'; import { AdvancedUserSelectorScreen } from './src/screens/AdvancedUserSelectorScreen'; +import { ChannelDetailsScreen } from './src/screens/ChannelDetailsScreen.tsx'; import { ChannelFilesScreen } from './src/screens/ChannelFilesScreen'; import { ChannelImagesScreen } from './src/screens/ChannelImagesScreen'; import { ChannelPinnedMessagesScreen } from './src/screens/ChannelPinnedMessagesScreen'; import { ChannelScreen } from './src/screens/ChannelScreen'; import { ChatScreen } from './src/screens/ChatScreen'; -import { GroupChannelDetailsScreen } from './src/screens/GroupChannelDetailsScreen'; import { LoadingScreen } from './src/screens/LoadingScreen'; import { MapScreen } from './src/screens/MapScreen'; 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'; @@ -346,11 +343,9 @@ const DrawerNavigatorWrapper: React.FC<{ useNativeMultipartUpload > - - - - - + + + ); @@ -420,13 +415,8 @@ const HomeScreen = () => { options={{ headerShown: false }} /> - { name='ChannelPinnedMessagesScreen' options={{ headerShown: false }} /> - void; - visible: boolean; -}; - -const keyExtractor = (item: UserResponse) => item.id; - -const SelectionCircle = React.memo(({ selected }: { selected: boolean }) => { - const { - theme: { semantics }, - } = useTheme(); - - if (selected) { - return ( - - - - ); - } - - return ; -}); - -SelectionCircle.displayName = 'SelectionCircle'; - -const selectionStyles = StyleSheet.create({ - circle: { - alignItems: 'center', - borderRadius: 9999, - borderWidth: 1, - height: 24, - justifyContent: 'center', - width: 24, - }, -}); - -export const AddMembersBottomSheet = React.memo( - ({ channel, onClose, visible }: AddMembersBottomSheetProps) => { - const { - theme: { semantics }, - } = useTheme(); - const styles = useStyles(); - - const { - clearText, - initialResults, - loading, - loadMore, - onChangeSearchText, - onFocusInput, - reset, - results, - searchText, - selectedUserIds, - toggleUser, - } = usePaginatedUsers(); - - const [adding, setAdding] = useState(false); - const [searchFocused, setSearchFocused] = useState(false); - - const stableOnClose = useStableCallback(onClose); - const hasSelection = selectedUserIds.length > 0; - - const existingMemberIds = useMemo( - () => new Set(Object.keys(channel.state.members)), - [channel.state.members], - ); - - const filteredResults = useMemo( - () => results.filter((user) => !existingMemberIds.has(user.id)), - [results, existingMemberIds], - ); - - const handleClose = useCallback(() => { - reset(); - setSearchFocused(false); - stableOnClose(); - }, [reset, stableOnClose]); - - const handleConfirm = useCallback(async () => { - if (!hasSelection) return; - - setAdding(true); - try { - await channel.addMembers(selectedUserIds); - reset(); - setSearchFocused(false); - stableOnClose(); - } catch (error) { - if (error instanceof Error) { - Alert.alert('Error', error.message); - } - } - setAdding(false); - }, [channel, hasSelection, reset, selectedUserIds, stableOnClose]); - - const handleSearchFocus = useCallback(() => { - setSearchFocused(true); - onFocusInput(); - }, [onFocusInput]); - - const handleSearchBlur = useCallback(() => { - setSearchFocused(false); - }, []); - - const renderItem = useCallback( - ({ item }: { item: UserResponse }) => { - const isSelected = selectedUserIds.includes(item.id); - return ( - toggleUser(item)} - style={({ pressed }) => [styles.userRow, pressed && { opacity: 0.7 }]} - > - - - - {item.name || item.id} - - - - - ); - }, - [selectedUserIds, semantics.textPrimary, styles, toggleUser], - ); - - const initialLoadComplete = initialResults !== null; - - const EmptyComponent = useCallback(() => { - if (loading && !initialLoadComplete) { - return ( - - - - ); - } - return ( - - - No user found - - ); - }, [loading, initialLoadComplete, semantics.textSecondary, styles]); - - return ( - - - - - - - - Add Members - - - {adding ? ( - - ) : ( - - )} - - - - - - - - {searchText.length > 0 ? ( - - - - ) : null} - - - - - - - ); - }, -); - -AddMembersBottomSheet.displayName = 'AddMembersBottomSheet'; - -const useStyles = () => { - return useMemo( - () => - StyleSheet.create({ - safeArea: { - flex: 1, - }, - header: { - alignItems: 'center', - flexDirection: 'row', - gap: 12, - justifyContent: 'space-between', - paddingHorizontal: 12, - paddingVertical: 12, - }, - iconButton: { - alignItems: 'center', - borderRadius: 9999, - borderWidth: 1, - height: 40, - justifyContent: 'center', - width: 40, - }, - confirmButton: { - alignItems: 'center', - borderRadius: 9999, - height: 40, - justifyContent: 'center', - width: 40, - }, - title: { - flex: 1, - fontSize: 17, - fontWeight: '600', - lineHeight: 20, - textAlign: 'center', - }, - searchContainer: { - paddingHorizontal: 16, - paddingBottom: 8, - }, - searchInput: { - alignItems: 'center', - borderRadius: 9999, - borderWidth: 1, - flexDirection: 'row', - gap: 8, - height: 48, - paddingHorizontal: 16, - }, - searchTextInput: { - flex: 1, - fontSize: 17, - lineHeight: 20, - padding: 0, - }, - userRow: { - alignItems: 'center', - flexDirection: 'row', - gap: 12, - minHeight: 52, - paddingHorizontal: 16, - paddingVertical: 8, - }, - userRowLeading: { - alignItems: 'center', - flex: 1, - flexDirection: 'row', - gap: 12, - }, - userName: { - flex: 1, - fontSize: 17, - fontWeight: '400', - lineHeight: 20, - }, - emptyState: { - alignItems: 'center', - gap: 12, - justifyContent: 'center', - paddingVertical: 40, - }, - emptyText: { - fontSize: 17, - lineHeight: 20, - textAlign: 'center', - }, - listContent: { - flexGrow: 1, - paddingBottom: 40, - }, - }), - [], - ); -}; diff --git a/examples/SampleApp/src/components/AllMembersBottomSheet.tsx b/examples/SampleApp/src/components/AllMembersBottomSheet.tsx deleted file mode 100644 index 24c63836f8..0000000000 --- a/examples/SampleApp/src/components/AllMembersBottomSheet.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; - -import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import type { Channel, ChannelMemberResponse } from 'stream-chat'; -import { - BottomSheetModal, - StreamBottomSheetModalFlatList, - UserAdd, - useStableCallback, - useTheme, -} from 'stream-chat-react-native'; - -import { ContactDetailBottomSheet } from './ContactDetailBottomSheet'; -import { MemberListItem } from './MemberListItem'; - -import { Close } from '../icons/Close'; - -import type { StackNavigatorParamList } from '../types'; - -type AllMembersBottomSheetProps = { - channel: Channel; - channelCreatorId: string | undefined; - currentUserId: string | undefined; - navigation: NativeStackNavigationProp; - onClose: () => void; - visible: boolean; - onAddMember?: () => void; -}; - -const keyExtractor = (item: ChannelMemberResponse) => item.user_id ?? item.user?.id ?? ''; - -export const AllMembersBottomSheet = React.memo( - ({ - channel, - channelCreatorId, - currentUserId, - navigation, - onAddMember, - onClose, - visible, - }: AllMembersBottomSheetProps) => { - const { - theme: { semantics }, - } = useTheme(); - const styles = useStyles(); - - const [selectedMember, setSelectedMember] = useState(null); - - const members = useMemo(() => Object.values(channel.state.members), [channel.state.members]); - - const memberCount = channel?.data?.member_count ?? members.length; - - const stableOnClose = useStableCallback(onClose); - - const handleMemberPress = useCallback( - (member: ChannelMemberResponse) => { - if (member.user?.id !== currentUserId) { - setSelectedMember(member); - } - }, - [currentUserId], - ); - - const closeContactDetail = useCallback(() => { - setSelectedMember(null); - stableOnClose(); - }, [stableOnClose]); - - const renderItem = useCallback( - ({ item }: { item: ChannelMemberResponse }) => ( - handleMemberPress(item)} - /> - ), - [channelCreatorId, currentUserId, handleMemberPress], - ); - - return ( - - - - - - - - - {`${memberCount} Members`} - - - {onAddMember ? ( - - - - ) : ( - - )} - - - - - - - ); - }, -); - -AllMembersBottomSheet.displayName = 'AllMembersBottomSheet'; - -const useStyles = () => { - return useMemo( - () => - StyleSheet.create({ - safeArea: { - flex: 1, - }, - header: { - alignItems: 'center', - flexDirection: 'row', - gap: 12, - justifyContent: 'space-between', - paddingHorizontal: 12, - paddingVertical: 12, - }, - iconButton: { - alignItems: 'center', - borderRadius: 9999, - borderWidth: 1, - height: 40, - justifyContent: 'center', - width: 40, - }, - iconButtonPlaceholder: { - height: 40, - width: 40, - }, - title: { - flex: 1, - fontSize: 17, - fontWeight: '600', - lineHeight: 20, - textAlign: 'center', - }, - listContent: { - paddingBottom: 40, - }, - }), - [], - ); -}; diff --git a/examples/SampleApp/src/components/ChannelDetailProfileSection.tsx b/examples/SampleApp/src/components/ChannelDetailProfileSection.tsx deleted file mode 100644 index f98d6ed8c5..0000000000 --- a/examples/SampleApp/src/components/ChannelDetailProfileSection.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, { useMemo } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; - -import { ChannelPreviewMutedStatus, useTheme } from 'stream-chat-react-native'; - -type ChannelDetailProfileSectionProps = { - avatar: React.ReactNode; - muted?: boolean; - subtitle: string; - title: string; -}; - -export const ChannelDetailProfileSection = React.memo( - ({ avatar, muted, subtitle, title }: ChannelDetailProfileSectionProps) => { - const { - theme: { semantics }, - } = useTheme(); - const styles = useStyles(); - - return ( - - {avatar} - - - - {title} - - {muted ? : null} - - {subtitle ? ( - - {subtitle} - - ) : null} - - - ); - }, -); - -ChannelDetailProfileSection.displayName = 'ChannelDetailProfileSection'; - -const useStyles = () => - useMemo( - () => - StyleSheet.create({ - container: { - alignItems: 'center', - gap: 16, - paddingHorizontal: 0, - }, - heading: { - alignItems: 'center', - gap: 8, - width: '100%', - }, - titleRow: { - alignItems: 'center', - flexDirection: 'row', - gap: 4, - justifyContent: 'center', - maxWidth: '100%', - }, - title: { - fontSize: 22, - flexShrink: 1, - fontWeight: '600', - lineHeight: 24, - textAlign: 'center', - }, - subtitle: { - fontSize: 15, - fontWeight: '400', - lineHeight: 20, - textAlign: 'center', - }, - }), - [], - ); diff --git a/examples/SampleApp/src/components/ChannelDetailsNavigationSection.tsx b/examples/SampleApp/src/components/ChannelDetailsNavigationSection.tsx new file mode 100644 index 0000000000..8519691690 --- /dev/null +++ b/examples/SampleApp/src/components/ChannelDetailsNavigationSection.tsx @@ -0,0 +1,90 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useNavigation } from '@react-navigation/native'; +import type { NavigationProp } from '@react-navigation/native'; + +import { + ChannelDetailsActionItem, + ChevronRight, + FilePickerIcon, + ImageGrid, + Pin, + useChannelDetailsContext, + useTheme, + useTranslationContext, +} from 'stream-chat-react-native'; + +import type { StackNavigatorParamList } from '../types'; + +/** + * SampleApp implementation of the (now default-less) `ChannelDetailsNavigationSection` + * slot. It wires the Pinned Messages / Photos & Videos / Files rows to the app's own + * navigation screens. Registered via `useSampleAppComponentOverrides`. + */ +export const ChannelDetailsNavigationSection = () => { + const { t } = useTranslationContext(); + const { channel } = useChannelDetailsContext(); + const navigation = useNavigation>(); + const { + theme: { + channelDetailsScreen: { sectionCard: sectionCardOverride }, + semantics, + }, + } = useTheme(); + const styles = useStyles(); + + const chevron = useMemo( + () => ( + + + + ), + [semantics.textTertiary], + ); + + return ( + + navigation.navigate('ChannelPinnedMessagesScreen', { channel })} + testID='channel-details-pinned-messages' + trailing={chevron} + /> + navigation.navigate('ChannelImagesScreen', { channel })} + testID='channel-details-photos-and-videos' + trailing={chevron} + /> + navigation.navigate('ChannelFilesScreen', { channel })} + testID='channel-details-files' + trailing={chevron} + /> + + ); +}; + +const useStyles = () => + useMemo( + () => + StyleSheet.create({ + sectionCard: { + borderRadius: 16, + overflow: 'hidden', + paddingVertical: 4, + }, + }), + [], + ); diff --git a/examples/SampleApp/src/components/ChannelInfoOverlay.tsx b/examples/SampleApp/src/components/ChannelInfoOverlay.tsx deleted file mode 100644 index 9c5b374e4f..0000000000 --- a/examples/SampleApp/src/components/ChannelInfoOverlay.tsx +++ /dev/null @@ -1,321 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import { FlatList, StyleSheet, Text, View } from 'react-native'; - -import { Pressable } from 'react-native-gesture-handler'; - -import Animated from 'react-native-reanimated'; - -import { SafeAreaView } from 'react-native-safe-area-context'; - -import dayjs from 'dayjs'; -import relativeTime from 'dayjs/plugin/relativeTime'; - -import { ChannelMemberResponse } from 'stream-chat'; -import { - CircleClose, - Delete, - UserMinus, - useTheme, - useViewport, - UserAvatar, - BottomSheetModal, - useStableCallback, -} from 'stream-chat-react-native'; - -import { ConfirmationBottomSheet } from './ConfirmationBottomSheet'; - -import type { ConfirmationData } from './ConfirmationBottomSheet'; - -import { useAppOverlayContext } from '../context/AppOverlayContext'; -import { useChannelInfoOverlayContext } from '../context/ChannelInfoOverlayContext'; -import { useChannelInfoOverlayActions } from '../hooks/useChannelInfoOverlayActions'; -import { Archive } from '../icons/Archive'; - -import { Pin } from '../icons/Pin.tsx'; -import { User } from '../icons/User'; -import { useLegacyColors } from '../theme/useLegacyColors'; - -dayjs.extend(relativeTime); - -const styles = StyleSheet.create({ - avatarPresenceIndicator: { - right: 5, - top: 1, - }, - channelName: { - fontSize: 16, - fontWeight: 'bold', - paddingBottom: 4, - paddingHorizontal: 30, - }, - channelStatus: { - fontSize: 12, - }, - container: { - flex: 1, - justifyContent: 'flex-end', - }, - containerInner: { - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - width: '100%', - }, - detailsContainer: { - alignItems: 'center', - paddingTop: 24, - }, - flatList: { - paddingBottom: 24, - paddingTop: 16, - }, - flatListContent: { - paddingHorizontal: 8, - }, - lastRow: { - alignItems: 'center', - borderBottomWidth: 1, - borderTopWidth: 1, - flexDirection: 'row', - paddingVertical: 16, - }, - row: { alignItems: 'center', borderTopWidth: 1, flexDirection: 'row', paddingVertical: 16 }, - rowInner: { paddingLeft: 16, paddingRight: 10 }, - rowText: { - fontSize: 14, - fontWeight: '700', - }, - userItemContainer: { marginHorizontal: 8, alignItems: 'center' }, - userName: { - fontSize: 12, - fontWeight: '600', - paddingTop: 4, - textAlign: 'center', - }, -}); - -export type ChannelInfoOverlayProps = { - overlayOpacity: Animated.SharedValue; - visible?: boolean; -}; - -export const ChannelInfoOverlay = (props: ChannelInfoOverlayProps) => { - const { visible } = props; - - const { setOverlay } = useAppOverlayContext(); - const { data } = useChannelInfoOverlayContext(); - const { vw } = useViewport(); - - const width = vw(100) - 60; - - const { channel, clientId, membership, navigation } = data || {}; - - const { - theme: { semantics }, - } = useTheme(); - const { accent_red, black, grey } = useLegacyColors(); - - // magic number 8 used as fontSize is 16 so assuming average character width of half - const maxWidth = channel - ? Math.floor(width / 8 - Object.keys(channel.state.members || {}).length.toString().length) - : 0; - const channelName = channel - ? channel.data?.name || - Object.values(channel.state.members) - .slice(0) - .reduce((returnString, currentMember, index, originalArray) => { - const returnStringLength = returnString.length; - const currentMemberName = - currentMember.user?.name || currentMember.user?.id || 'Unknown User'; - // a rough approximation of when the +Number shows up - if (returnStringLength + (currentMemberName.length + 2) < maxWidth) { - if (returnStringLength) { - returnString += `, ${currentMemberName}`; - } else { - returnString = currentMemberName; - } - } else { - const remainingMembers = originalArray.length - index; - returnString += `, +${remainingMembers}`; - originalArray.splice(1); // exit early - } - return returnString; - }, '') - : ''; - const otherMembers = channel - ? Object.values(channel.state.members).filter( - (member) => member.user?.id !== clientId, - ) - : []; - - const [confirmationData, setConfirmationData] = useState(null); - - const showConfirmation = useCallback((_data: ConfirmationData) => { - setConfirmationData(_data); - }, []); - - const closeConfirmation = useCallback(() => { - setConfirmationData(null); - }, []); - - const { viewInfo, pinUnpin, archiveUnarchive, leaveGroup, deleteConversation, cancel } = - useChannelInfoOverlayActions({ channel, navigation, otherMembers, showConfirmation }); - - const onClose = useStableCallback(() => { - setOverlay('none'); - }); - - return ( - - - {channel && ( - <> - - - {channelName} - - - {otherMembers.length === 1 - ? otherMembers[0].user?.online - ? 'Online' - : `Last Seen ${dayjs(otherMembers[0].user?.last_active).fromNow()}` - : `${Object.keys(channel.state.members).length} Members, ${ - Object.values(channel.state.members).filter( - (member) => !!member.user?.online, - ).length - } Online`} - - (channel.state.members) - .map((member) => member.user) - .sort((a, b) => - !!a?.online && !b?.online - ? -1 - : a?.id === clientId && b?.id !== clientId - ? -1 - : !!a?.online && !!b?.online - ? 0 - : 1, - )} - horizontal - keyExtractor={(item, index) => `${item?.id}_${index}`} - renderItem={({ item }) => - item ? ( - - - - - {item.name || item.id || ''} - - - ) : null - } - style={styles.flatList} - /> - - - - - - - View info - - - - - - - - - {membership?.pinned_at ? 'Unpin' : 'Pin'} - - - - - - - - - - {membership?.archived_at ? 'Unarchive' : 'Archive'} - - - - - {otherMembers.length > 1 && ( - - - - - - Leave Group - - - )} - - - - - - Delete conversation - - - - - - - - Cancel - - - - )} - - - - ); -}; diff --git a/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx b/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx deleted file mode 100644 index eeb09d897f..0000000000 --- a/examples/SampleApp/src/components/ConfirmationBottomSheet.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; - -import { BottomSheetModal, Delete, useStableCallback, useTheme } from 'stream-chat-react-native'; - -import { UserMinus } from '../icons/UserMinus'; - -const SHEET_HEIGHT = 224; - -export type ConfirmationData = { - onConfirm: () => void; - title: string; - cancelText?: string; - confirmText?: string; - subtext?: string; -}; - -type ConfirmationBottomSheetProps = { - onClose: () => void; - visible: boolean; - cancelText?: string; - confirmText?: string; - onConfirm?: () => void; - subtext?: string; - title?: string; -}; - -export const ConfirmationBottomSheet = React.memo( - ({ - cancelText = 'CANCEL', - confirmText = 'CONFIRM', - onClose, - onConfirm, - subtext, - title, - visible, - }: ConfirmationBottomSheetProps) => { - const { - theme: { semantics }, - } = useTheme(); - const styles = useStyles(); - const stableOnClose = useStableCallback(onClose); - - const handleCancel = useCallback(() => { - stableOnClose(); - }, [stableOnClose]); - - const handleConfirm = useCallback(() => { - onConfirm?.(); - stableOnClose(); - }, [onConfirm, stableOnClose]); - - const isLeave = confirmText === 'LEAVE'; - - return ( - - - - {isLeave ? ( - - ) : ( - - )} - {title} - {subtext ? ( - {subtext} - ) : null} - - - - - {cancelText} - - - - - {confirmText} - - - - - - ); - }, -); - -const useStyles = () => { - return useMemo( - () => - StyleSheet.create({ - actionButton: { - padding: 20, - }, - actionText: { - fontSize: 14, - fontWeight: '600', - }, - actions: { - borderTopWidth: 1, - flexDirection: 'row', - justifyContent: 'space-between', - }, - description: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - }, - safeArea: { - flex: 1, - }, - subtext: { - fontSize: 14, - fontWeight: '500', - marginTop: 8, - paddingHorizontal: 16, - textAlign: 'center', - }, - title: { - fontSize: 16, - fontWeight: '700', - marginTop: 18, - paddingHorizontal: 16, - }, - }), - [], - ); -}; diff --git a/examples/SampleApp/src/components/ContactDetailBottomSheet.tsx b/examples/SampleApp/src/components/ContactDetailBottomSheet.tsx deleted file mode 100644 index 7ff3320fa6..0000000000 --- a/examples/SampleApp/src/components/ContactDetailBottomSheet.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import { Alert, StyleSheet, Text, View } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; - -import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import type { Channel, ChannelMemberResponse } from 'stream-chat'; -import { - BottomSheetModal, - CircleBan, - useChatContext, - useStableCallback, - useTheme, - UserAvatar, -} from 'stream-chat-react-native'; - -import { ListItem } from './ListItem'; - -import { Message } from '../icons/Message'; -import { Mute } from '../icons/Mute'; -import type { StackNavigatorParamList } from '../types'; -import { getUserActivityStatus } from '../utils/getUserActivityStatus'; - -const SHEET_HEIGHT = 260; - -type ContactDetailBottomSheetProps = { - channel: Channel; - member: ChannelMemberResponse | null; - navigation: NativeStackNavigationProp; - onClose: () => void; - visible: boolean; -}; - -export const ContactDetailBottomSheet = React.memo( - ({ member, navigation, onClose, visible }: ContactDetailBottomSheetProps) => { - const { - theme: { semantics }, - } = useTheme(); - const { client } = useChatContext(); - const styles = useStyles(); - - const stableOnClose = useStableCallback(onClose); - - const user = member?.user; - const activityStatus = user ? getUserActivityStatus(user) : ''; - const isMuted = client.mutedUsers?.some((m) => m.target.id === user?.id) ?? false; - - const sendDirectMessage = useCallback(async () => { - if (!client.user?.id || !user?.id) return; - - const members = [client.user.id, user.id]; - - try { - const channels = await client.queryChannels({ members }); - - const dmChannel = - channels.length === 1 ? channels[0] : client.channel('messaging', { members }); - - await dmChannel.watch(); - - stableOnClose(); - navigation.navigate('ChannelScreen', { - channel: dmChannel, - channelId: dmChannel.id, - }); - } catch (error) { - if (error instanceof Error) { - Alert.alert('Error', error.message); - } - } - }, [client, navigation, stableOnClose, user?.id]); - - const muteUser = useCallback(async () => { - if (!user?.id) return; - - try { - const isMuted = client.mutedUsers?.some((m) => m.target.id === user.id); - if (isMuted) { - await client.unmuteUser(user.id); - } else { - await client.muteUser(user.id); - } - stableOnClose(); - } catch (error) { - if (error instanceof Error) { - Alert.alert('Error', error.message); - } - } - }, [client, stableOnClose, user?.id]); - - const blockUser = useCallback(async () => { - if (!user?.id) return; - - try { - await client.blockUser(user.id); - stableOnClose(); - } catch (error) { - if (error instanceof Error) { - Alert.alert('Error', error.message); - } - } - }, [client, stableOnClose, user?.id]); - - if (!user) return null; - - return ( - - - - - - - {user.name || user.id} - - {activityStatus ? ( - - {activityStatus} - - ) : null} - - - - } - label='Send Direct Message' - onPress={sendDirectMessage} - /> - } - label={isMuted ? 'Unmute User' : 'Mute User'} - onPress={muteUser} - /> - } - label='Block User' - onPress={blockUser} - /> - - - ); - }, -); - -ContactDetailBottomSheet.displayName = 'ContactDetailBottomSheet'; - -const useStyles = () => { - return useMemo( - () => - StyleSheet.create({ - safeArea: { - flex: 1, - }, - header: { - alignItems: 'center', - flexDirection: 'row', - gap: 12, - paddingHorizontal: 12, - paddingVertical: 12, - }, - headerText: { - flex: 1, - gap: 4, - }, - name: { - fontSize: 17, - fontWeight: '600', - lineHeight: 20, - }, - status: { - fontSize: 15, - fontWeight: '400', - lineHeight: 20, - }, - }), - [], - ); -}; diff --git a/examples/SampleApp/src/components/ListItem.tsx b/examples/SampleApp/src/components/ListItem.tsx deleted file mode 100644 index 07e0da839b..0000000000 --- a/examples/SampleApp/src/components/ListItem.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React, { useMemo } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; - -import { useTheme } from 'stream-chat-react-native'; - -type ListItemProps = { - icon: React.ReactNode; - label: string; - destructive?: boolean; - onPress?: () => void; - trailing?: React.ReactNode; -}; - -export const ListItem = React.memo( - ({ icon, label, destructive = false, onPress, trailing }: ListItemProps) => { - const { - theme: { semantics }, - } = useTheme(); - const styles = useStyles(); - - const labelColor = destructive ? semantics.accentError : semantics.textPrimary; - - return ( - [styles.outerContainer, pressed && { opacity: 0.7 }]} - > - - {icon} - - {label} - - {trailing ? {trailing} : null} - - - ); - }, -); - -ListItem.displayName = 'ListItem'; - -const useStyles = () => - useMemo( - () => - StyleSheet.create({ - outerContainer: { - minHeight: 40, - paddingHorizontal: 4, - }, - contentContainer: { - alignItems: 'center', - borderRadius: 12, - flexDirection: 'row', - gap: 12, - padding: 12, - }, - label: { - flex: 1, - fontSize: 17, - fontWeight: '400', - lineHeight: 20, - }, - trailing: { - flexShrink: 0, - }, - }), - [], - ); diff --git a/examples/SampleApp/src/components/MemberListItem.tsx b/examples/SampleApp/src/components/MemberListItem.tsx deleted file mode 100644 index 5895124a6d..0000000000 --- a/examples/SampleApp/src/components/MemberListItem.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React, { useMemo } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; - -import type { ChannelMemberResponse } from 'stream-chat'; -import { useChatContext, useTheme, UserAvatar } from 'stream-chat-react-native'; - -import { Mute } from '../icons/Mute'; -import { getUserActivityStatus } from '../utils/getUserActivityStatus'; - -type MemberListItemProps = { - member: ChannelMemberResponse; - isCurrentUser?: boolean; - isOwner?: boolean; - onPress?: () => void; -}; - -export const MemberListItem = React.memo( - ({ member, isCurrentUser = false, isOwner = false, onPress }: MemberListItemProps) => { - const { - theme: { semantics }, - } = useTheme(); - const { client } = useChatContext(); - const styles = useStyles(); - - const user = member.user; - if (!user) { - return null; - } - - const displayName = isCurrentUser ? 'You' : user.name || user.id; - const activityStatus = getUserActivityStatus(user); - const isMuted = client.mutedUsers?.some((m) => m.target.id === user.id) ?? false; - - return ( - [styles.outerContainer, pressed && { opacity: 0.7 }]} - > - - - - - - {displayName} - - {activityStatus ? ( - - {activityStatus} - - ) : null} - - - {isMuted ? : null} - {isOwner ? ( - Admin - ) : null} - - - ); - }, -); - -MemberListItem.displayName = 'MemberListItem'; - -const useStyles = () => - useMemo( - () => - StyleSheet.create({ - outerContainer: { - minHeight: 40, - paddingHorizontal: 4, - }, - contentContainer: { - alignItems: 'center', - borderRadius: 12, - flexDirection: 'row', - gap: 12, - paddingHorizontal: 12, - paddingVertical: 8, - }, - leading: { - alignItems: 'center', - flex: 1, - flexDirection: 'row', - gap: 12, - }, - textContainer: { - flex: 1, - }, - name: { - fontSize: 17, - fontWeight: '400', - lineHeight: 20, - }, - status: { - fontSize: 13, - fontWeight: '400', - lineHeight: 16, - }, - roleLabel: { - fontSize: 17, - fontWeight: '400', - lineHeight: 20, - textAlign: 'right', - width: 120, - }, - }), - [], - ); diff --git a/examples/SampleApp/src/components/OverlayBackdrop.tsx b/examples/SampleApp/src/components/OverlayBackdrop.tsx deleted file mode 100644 index fb8674259a..0000000000 --- a/examples/SampleApp/src/components/OverlayBackdrop.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import { StyleProp, View, ViewStyle } from 'react-native'; - -import { useTheme } from 'stream-chat-react-native'; - -import { useLegacyColors } from '../theme/useLegacyColors'; - -type OverlayBackdropProps = { - style?: StyleProp; -}; - -export const OverlayBackdrop = (props: OverlayBackdropProps): React.ReactNode => { - const { style = {} } = props; - useTheme(); - const { overlay } = useLegacyColors(); - return ; -}; diff --git a/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx b/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx index eb03b185fa..f719e823de 100644 --- a/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx +++ b/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx @@ -6,6 +6,7 @@ import type { ComponentOverrides } from 'stream-chat-react-native'; import { useTheme } from 'stream-chat-react-native'; import { CustomAttachmentPickerContent } from './AttachmentPickerContent'; +import { ChannelDetailsNavigationSection } from './ChannelDetailsNavigationSection'; import { CustomChannelPreviewStatus } from './ChannelPreview'; import { FastImageAdapter } from './FastImageAdapter'; import { MessageLocation } from './LocationSharing/MessageLocation'; @@ -45,6 +46,7 @@ export const useSampleAppComponentOverrides = ( useMemo( () => ({ AttachmentPickerContent: CustomAttachmentPickerContent, + ChannelDetailsNavigationSection, ChannelListHeaderNetworkDownIndicator: RenderNull, ImageComponent: FastImageAdapter, MessageLocation, diff --git a/examples/SampleApp/src/components/UserInfoOverlay.tsx b/examples/SampleApp/src/components/UserInfoOverlay.tsx deleted file mode 100644 index 7d3f857a22..0000000000 --- a/examples/SampleApp/src/components/UserInfoOverlay.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { Keyboard, StyleSheet, Text, View, ViewStyle } from 'react-native'; - -import { Gesture, GestureDetector, Pressable } from 'react-native-gesture-handler'; - -import Animated, { - cancelAnimation, - Easing, - Extrapolation, - interpolate, - runOnJS, - useAnimatedStyle, - useSharedValue, - withDecay, - withTiming, -} from 'react-native-reanimated'; - -import { SafeAreaView } from 'react-native-safe-area-context'; - -import dayjs from 'dayjs'; -import relativeTime from 'dayjs/plugin/relativeTime'; - -import { UserResponse } from 'stream-chat'; -import { useChatContext, useTheme, useViewport, UserAvatar } from 'stream-chat-react-native'; - -import { ConfirmationBottomSheet } from './ConfirmationBottomSheet'; - -import type { ConfirmationData } from './ConfirmationBottomSheet'; - -import { useAppContext } from '../context/AppContext'; -import { useAppOverlayContext } from '../context/AppOverlayContext'; -import { useUserInfoOverlayContext } from '../context/UserInfoOverlayContext'; - -import { useUserInfoOverlayActions } from '../hooks/useUserInfoOverlayActions'; - -import { CircleClose } from '../icons/CircleClose'; -import { Message } from '../icons/Message'; -import { User } from '../icons/User'; -import { UserMinus } from '../icons/UserMinus'; -import { useLegacyColors } from '../theme/useLegacyColors'; - -dayjs.extend(relativeTime); - -const styles = StyleSheet.create({ - avatarPresenceIndicator: { - right: 5, - top: 1, - }, - channelName: { - fontSize: 16, - fontWeight: 'bold', - paddingBottom: 4, - }, - channelStatus: { - fontSize: 12, - fontWeight: 'bold', - }, - container: { - flex: 1, - justifyContent: 'flex-end', - }, - containerInner: { - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - width: '100%', - }, - detailsContainer: { - alignItems: 'center', - paddingTop: 24, - }, - lastRow: { - alignItems: 'center', - borderBottomWidth: 1, - borderTopWidth: 1, - flexDirection: 'row', - }, - row: { alignItems: 'center', borderTopWidth: 1, flexDirection: 'row' }, - rowInner: { padding: 16 }, - rowText: { - fontSize: 14, - fontWeight: '700', - }, - userItemContainer: { - paddingVertical: 16, - }, - userName: { - fontSize: 12, - fontWeight: 'bold', - paddingTop: 4, - textAlign: 'center', - }, -}); - -export type UserInfoOverlayProps = { - overlayOpacity: Animated.SharedValue; - visible?: boolean; -}; - -export const UserInfoOverlay = (props: UserInfoOverlayProps) => { - const { overlayOpacity, visible } = props; - const { chatClient } = useAppContext(); - const { overlay, setOverlay } = useAppOverlayContext(); - const { client } = useChatContext(); - const { data, reset } = useUserInfoOverlayContext(); - const { vh } = useViewport(); - - const screenHeight = vh(100); - const halfScreenHeight = vh(50); - - const { channel, member } = data || {}; - - const { - theme: { semantics }, - } = useTheme(); - const { accent_red, black, grey, white } = useLegacyColors(); - - const offsetY = useSharedValue(0); - const translateY = useSharedValue(0); - const viewHeight = useSharedValue(0); - - const showScreen = useSharedValue(0); - const fadeScreen = (show: boolean) => { - 'worklet'; - if (show) { - offsetY.value = 0; - translateY.value = 0; - } - showScreen.value = show - ? withTiming(1, { - duration: 150, - easing: Easing.in(Easing.ease), - }) - : withTiming( - 0, - { - duration: 150, - easing: Easing.out(Easing.ease), - }, - () => { - runOnJS(reset)(); - }, - ); - }; - - useEffect(() => { - if (visible) { - Keyboard.dismiss(); - } - fadeScreen(!!visible); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [visible]); - - const pan = Gesture.Pan() - .enabled(overlay === 'channelInfo') - .maxPointers(1) - .minDistance(10) - .onBegin(() => { - cancelAnimation(translateY); - offsetY.value = translateY.value; - }) - .onUpdate((event) => { - translateY.value = offsetY.value + event.translationY; - overlayOpacity.value = interpolate( - translateY.value, - [0, halfScreenHeight], - [1, 0.75], - Extrapolation.CLAMP, - ); - }) - .onEnd((evt) => { - const finalYPosition = evt.translationY + evt.velocityY * 0.1; - - if (finalYPosition > halfScreenHeight && translateY.value > 0) { - cancelAnimation(translateY); - overlayOpacity.value = withTiming( - 0, - { - duration: 200, - easing: Easing.out(Easing.ease), - }, - () => { - runOnJS(setOverlay)('none'); - }, - ); - translateY.value = - evt.velocityY > 1000 - ? withDecay({ - velocity: evt.velocityY, - }) - : withTiming(screenHeight, { - duration: 200, - easing: Easing.out(Easing.ease), - }); - } else { - translateY.value = withTiming(0); - overlayOpacity.value = withTiming(1); - } - }); - - const tap = Gesture.Tap() - .maxDistance(32) - .onEnd(() => { - runOnJS(setOverlay)('none'); - }); - - const panStyle = useAnimatedStyle(() => ({ - transform: [ - { - translateY: translateY.value > 0 ? translateY.value : 0, - }, - ], - })); - - const showScreenStyle = useAnimatedStyle(() => ({ - transform: [ - { - translateY: interpolate(showScreen.value, [0, 1], [viewHeight.value / 2, 0]), - }, - ], - })); - - const self = channel - ? Object.values(channel.state.members).find( - (channelMember) => channelMember.user?.id === client.user?.id, - ) - : undefined; - - const [confirmationData, setConfirmationData] = useState(null); - - const showConfirmation = useCallback((_data: ConfirmationData) => { - setConfirmationData(_data); - }, []); - - const closeConfirmation = useCallback(() => { - setConfirmationData(null); - }, []); - - const { viewInfo, messageUser, removeFromGroup, cancel } = useUserInfoOverlayActions({ - showConfirmation, - }); - - if (!self || !member) { - return null; - } - - if (!channel) { - return null; - } - - const channelCreatorId = - channel.data && (channel.data.created_by_id || (channel.data.created_by as UserResponse)?.id); - - return ( - - - - - { - viewHeight.value = height; - }} - style={[styles.container, panStyle]} - > - - - {channel && ( - <> - - - {member.user?.name || member.user?.id || ''} - - - {member.user?.online - ? 'Online' - : `Last Seen ${dayjs(member.user?.last_active).fromNow()}`} - - - - - - - - - - - View info - - - - - - - - Message - - - {channelCreatorId === chatClient?.user?.id ? ( - - - - - - - Remove From Group - - - - ) : null} - - - - - - Cancel - - - - )} - - - - - - - - - ); -}; diff --git a/examples/SampleApp/src/context/AppOverlayContext.tsx b/examples/SampleApp/src/context/AppOverlayContext.tsx deleted file mode 100644 index badb915810..0000000000 --- a/examples/SampleApp/src/context/AppOverlayContext.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React, { useContext } from 'react'; - -export type BlurType = 'light' | 'dark' | undefined; - -export type Overlay = 'channelInfo' | 'none' | 'userInfo'; - -export type AppOverlayContextValue = { - overlay: Overlay; - setOverlay: React.Dispatch>; -}; -export const AppOverlayContext = React.createContext( - {} as AppOverlayContextValue, -); - -export type AppOverlayProviderProps = { - value?: Partial; -}; - -export const useAppOverlayContext = () => useContext(AppOverlayContext); diff --git a/examples/SampleApp/src/context/AppOverlayProvider.tsx b/examples/SampleApp/src/context/AppOverlayProvider.tsx deleted file mode 100644 index 6780b9546d..0000000000 --- a/examples/SampleApp/src/context/AppOverlayProvider.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { BackHandler, StyleSheet, useWindowDimensions } from 'react-native'; -import Animated, { - cancelAnimation, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; - -import { AppOverlayContext, AppOverlayContextValue } from './AppOverlayContext'; - -import { ChannelInfoOverlayProvider } from './ChannelInfoOverlayContext'; -import { UserInfoOverlayProvider } from './UserInfoOverlayContext'; - -import { ChannelInfoOverlay } from '../components/ChannelInfoOverlay'; -import { OverlayBackdrop } from '../components/OverlayBackdrop'; -import { UserInfoOverlay } from '../components/UserInfoOverlay'; - -export const AppOverlayProvider = ( - props: React.PropsWithChildren<{ - value?: Partial; - }>, -) => { - const { children, value } = props; - - const [overlay, setOverlay] = useState(value?.overlay || 'none'); - - const overlayOpacity = useSharedValue(0); - const { height, width } = useWindowDimensions(); - - useEffect(() => { - const backAction = () => { - if (overlay !== 'none') { - setOverlay('none'); - return true; - } - - return false; - }; - - const backHandler = BackHandler.addEventListener('hardwareBackPress', backAction); - - return () => backHandler.remove(); - }, [overlay]); - - useEffect(() => { - cancelAnimation(overlayOpacity); - if (overlay !== 'none') { - overlayOpacity.value = withTiming(1); - } else { - overlayOpacity.value = withTiming(0); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [overlay]); - - const overlayStyle = useAnimatedStyle( - () => ({ - opacity: overlayOpacity.value, - }), - [], - ); - - const overlayContext = { - overlay, - setOverlay, - }; - - return ( - - - - {children} - - - - - - - - - ); -}; diff --git a/examples/SampleApp/src/context/ChannelInfoOverlayContext.tsx b/examples/SampleApp/src/context/ChannelInfoOverlayContext.tsx deleted file mode 100644 index 18bcf94696..0000000000 --- a/examples/SampleApp/src/context/ChannelInfoOverlayContext.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React, { useContext, useState } from 'react'; - -import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { ChannelState } from 'stream-chat'; -import type { ChannelContextValue } from 'stream-chat-react-native'; - -import type { StackNavigatorParamList } from '../types'; - -export type ChannelListScreenNavigationProp = NativeStackNavigationProp< - StackNavigatorParamList, - 'ChannelListScreen' ->; - -export type ChannelInfoOverlayData = Partial> & { - clientId?: string; - membership?: ChannelState['membership']; - navigation?: ChannelListScreenNavigationProp; -}; - -export type ChannelInfoOverlayContextValue = { - reset: () => void; - setData: React.Dispatch>; - data?: ChannelInfoOverlayData; -}; - -export const ChannelInfoOverlayContext = React.createContext({} as ChannelInfoOverlayContextValue); - -type Props = React.PropsWithChildren<{ - value?: ChannelInfoOverlayContextValue; -}>; - -export const ChannelInfoOverlayProvider = ({ children, value }: Props) => { - const [data, setData] = useState(value?.data); - - const reset = () => { - setData(value?.data); - }; - - const channelInfoOverlayContext = { - data, - reset, - setData, - }; - return ( - - {children} - - ); -}; - -export const useChannelInfoOverlayContext = () => - useContext(ChannelInfoOverlayContext) as unknown as ChannelInfoOverlayContextValue; diff --git a/examples/SampleApp/src/context/UserInfoOverlayContext.tsx b/examples/SampleApp/src/context/UserInfoOverlayContext.tsx deleted file mode 100644 index 6c9edb3eb2..0000000000 --- a/examples/SampleApp/src/context/UserInfoOverlayContext.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React, { useContext, useState } from 'react'; - -import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import type { ChannelState } from 'stream-chat'; -import type { ChannelContextValue } from 'stream-chat-react-native'; - -import type { StackNavigatorParamList } from '../types'; - -type GroupChannelDetailsScreenNavigationProp = NativeStackNavigationProp< - StackNavigatorParamList, - 'GroupChannelDetailsScreen' ->; - -export type UserInfoOverlayData = Partial> & { - member?: ChannelState['members'][0]; - navigation?: GroupChannelDetailsScreenNavigationProp; -}; - -export type UserInfoOverlayContextValue = { - reset: () => void; - setData: React.Dispatch>; - data?: UserInfoOverlayData; -}; - -export const UserInfoOverlayContext = React.createContext({} as UserInfoOverlayContextValue); - -type Props = React.PropsWithChildren<{ - value?: UserInfoOverlayContextValue; -}>; - -export const UserInfoOverlayProvider = ({ children, value }: Props) => { - const [data, setData] = useState(value?.data); - - const reset = () => { - setData(value?.data); - }; - - const userInfoOverlayContext = { - data, - reset, - setData, - }; - return ( - - {children} - - ); -}; - -export const useUserInfoOverlayContext = () => - useContext(UserInfoOverlayContext) as unknown as UserInfoOverlayContextValue; diff --git a/examples/SampleApp/src/hooks/useChannelInfoOverlayActions.tsx b/examples/SampleApp/src/hooks/useChannelInfoOverlayActions.tsx deleted file mode 100644 index d2f93312c7..0000000000 --- a/examples/SampleApp/src/hooks/useChannelInfoOverlayActions.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { Channel, ChannelMemberResponse } from 'stream-chat'; - -import type { ConfirmationData } from '../components/ConfirmationBottomSheet'; -import { useAppOverlayContext } from '../context/AppOverlayContext'; - -import { - ChannelListScreenNavigationProp, - useChannelInfoOverlayContext, -} from '../context/ChannelInfoOverlayContext'; - -export type UseChannelInfoOverlayGesturesParams = { - showConfirmation: (data: ConfirmationData) => void; - channel?: Channel; - navigation?: ChannelListScreenNavigationProp; - otherMembers?: ChannelMemberResponse[]; -}; - -export const useChannelInfoOverlayActions = (params: UseChannelInfoOverlayGesturesParams) => { - const { navigation, channel, otherMembers, showConfirmation } = params; - const { data } = useChannelInfoOverlayContext(); - const { setOverlay } = useAppOverlayContext(); - - const { clientId, membership } = data || {}; - - const viewInfo = () => { - if (!channel) { - return; - } - setOverlay('none'); - if (navigation) { - if (otherMembers?.length === 1) { - navigation.navigate('OneOnOneChannelDetailScreen', { - channel, - }); - } else { - navigation.navigate('GroupChannelDetailsScreen', { - channel, - }); - } - } - }; - - const pinUnpin = async () => { - try { - if (!channel) { - return; - } - if (membership?.pinned_at) { - await channel.unpin(); - } else { - await channel.pin(); - } - } catch (error) { - console.log('Error pinning/unpinning channel', error); - } - - setOverlay('none'); - }; - - const archiveUnarchive = async () => { - try { - if (!channel) { - return; - } - if (membership?.archived_at) { - await channel.unarchive(); - } else { - await channel.archive(); - } - } catch (error) { - console.log('Error archiving/unarchiving channel', error); - } - - setOverlay('none'); - }; - - const leaveGroup = () => { - if (!channel) { - return; - } - if (clientId) { - channel.removeMembers([clientId]); - } - setOverlay('none'); - }; - - const deleteConversation = () => { - if (!channel) { - return; - } - showConfirmation({ - confirmText: 'DELETE', - onConfirm: () => { - channel.delete(); - setOverlay('none'); - }, - subtext: `Are you sure you want to delete this ${ - otherMembers?.length === 1 ? 'conversation' : 'group' - }?`, - title: `Delete ${otherMembers?.length === 1 ? 'Conversation' : 'Group'}`, - }); - }; - - const cancel = () => { - setOverlay('none'); - }; - - return { viewInfo, pinUnpin, archiveUnarchive, leaveGroup, deleteConversation, cancel }; -}; diff --git a/examples/SampleApp/src/hooks/useUserInfoOverlayActions.tsx b/examples/SampleApp/src/hooks/useUserInfoOverlayActions.tsx deleted file mode 100644 index 9a47841202..0000000000 --- a/examples/SampleApp/src/hooks/useUserInfoOverlayActions.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { Alert } from 'react-native'; - -import { useChatContext } from 'stream-chat-react-native'; - -import type { ConfirmationData } from '../components/ConfirmationBottomSheet'; -import { useAppOverlayContext } from '../context/AppOverlayContext'; -import { useUserInfoOverlayContext } from '../context/UserInfoOverlayContext'; - -type UseUserInfoOverlayActionsParams = { - showConfirmation: (data: ConfirmationData) => void; -}; - -export const useUserInfoOverlayActions = ({ - showConfirmation, -}: UseUserInfoOverlayActionsParams) => { - const { client } = useChatContext(); - const { setOverlay } = useAppOverlayContext(); - const { data } = useUserInfoOverlayContext(); - const { channel, member, navigation } = data ?? {}; - - const viewInfo = async () => { - if (!client.user?.id || !member) { - return; - } - - const members = [client.user.id, member.user?.id || '']; - - const channels = await client.queryChannels({ - members, - }); - - let newChannel; - if (channels.length === 1) { - newChannel = channels[0]; - } else { - try { - newChannel = client.channel('messaging', { members }); - await newChannel.watch(); - } catch (error) { - newChannel = undefined; - if (error instanceof Error) { - Alert.alert('Error creating channel', error.message); - } - } - } - - setOverlay('none'); - if (navigation && newChannel) { - navigation.navigate('OneOnOneChannelDetailScreen', { - channel: newChannel, - }); - } - }; - - const messageUser = async () => { - if (!client.user?.id || !member) { - return; - } - - const members = [client.user.id, member.user?.id || '']; - - const channels = await client.queryChannels({ - members, - }); - - const newChannel = - channels.length === 1 - ? channels[0] - : client.channel('messaging', { - members, - }); - - setOverlay('none'); - if (navigation) { - navigation.navigate('ChannelScreen', { - channel: newChannel, - channelId: newChannel.id, - }); - } - }; - - const removeFromGroup = () => { - if (!channel || !member) { - return; - } - showConfirmation({ - confirmText: 'REMOVE', - onConfirm: () => { - if (member.user?.id) { - channel.removeMembers([member.user.id]); - } - setOverlay('none'); - }, - subtext: `Are you sure you want to remove User from ${channel?.data?.name || 'group'}?`, - title: 'Remove User', - }); - }; - - const cancel = () => { - setOverlay('none'); - }; - - return { viewInfo, messageUser, removeFromGroup, cancel }; -}; diff --git a/examples/SampleApp/src/icons/Archive.tsx b/examples/SampleApp/src/icons/Archive.tsx deleted file mode 100644 index 30ca283207..0000000000 --- a/examples/SampleApp/src/icons/Archive.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import Svg, { Path } from 'react-native-svg'; - -import { useLegacyColors } from '../theme/useLegacyColors'; -import { IconProps } from '../utils/base'; - -export const Archive: React.FC = ({ height = 512, width = 512 }) => { - const { grey } = useLegacyColors(); - - return ( - - - - ); -}; diff --git a/examples/SampleApp/src/icons/SendDirectMessage.tsx b/examples/SampleApp/src/icons/SendDirectMessage.tsx new file mode 100644 index 0000000000..6662a84dd1 --- /dev/null +++ b/examples/SampleApp/src/icons/SendDirectMessage.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { I18nManager } from 'react-native'; + +import Svg, { G, Path } from 'react-native-svg'; + +import { IconProps } from 'stream-chat-react-native'; + +export const SendDirectMessage = ({ + fill, + height, + pathFill, + size, + stroke, + width, + ...rest +}: IconProps) => { + const color = stroke ?? pathFill ?? fill ?? 'black'; + + return ( + + + + + + ); +}; diff --git a/examples/SampleApp/src/icons/UserMinus.tsx b/examples/SampleApp/src/icons/UserMinus.tsx deleted file mode 100644 index 85b21b20f3..0000000000 --- a/examples/SampleApp/src/icons/UserMinus.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - -import { IconProps, RootPath, RootSvg } from 'stream-chat-react-native'; - -export const UserMinus = (props: IconProps) => ( - - - - -); diff --git a/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx b/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx new file mode 100644 index 0000000000..2a42d1782d --- /dev/null +++ b/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx @@ -0,0 +1,75 @@ +import React, { useCallback } from 'react'; + +import type { RouteProp } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import { + ChannelDetailsScreen as StreamChannelDetailsScreen, + GetChannelMemberActionItems, +} from 'stream-chat-react-native'; + +import { SendDirectMessage } from '../icons/SendDirectMessage'; + +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 getChannelMemberActionItems = useCallback( + ({ context, defaultItems }) => { + // Don't offer sending a direct message to yourself. + if (context.isCurrentUser) { + return defaultItems; + } + const user = context.member.user; + return [ + { + action: () => { + navigation.navigate('NewDirectMessagingScreen', { initialUser: user }); + return Promise.resolve(); + }, + Icon: SendDirectMessage, + id: 'sendDirectMessage', + label: context.t('Send Direct Message'), + type: 'standard', + }, + ...defaultItems, + ]; + }, + [navigation], + ); + + return ( + + ); +}; diff --git a/examples/SampleApp/src/screens/ChannelListScreen.tsx b/examples/SampleApp/src/screens/ChannelListScreen.tsx index 30837eaca3..1e9b09d995 100644 --- a/examples/SampleApp/src/screens/ChannelListScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelListScreen.tsx @@ -142,36 +142,28 @@ export const ChannelListScreen: React.FC = () => { [], ); - const getChannelActionItems = useStableCallback( - ({ context: { isDirectChat, channel }, defaultItems }) => { - const viewInfo = () => { - if (!channel) { - return; - } - if (navigation) { - if (isDirectChat) { - navigation.navigate('OneOnOneChannelDetailScreen', { - channel, - }); - } else { - navigation.navigate('GroupChannelDetailsScreen', { - channel, - }); - } - } - }; - - const viewInfoItem: ChannelActionItem = { - action: viewInfo, - Icon: ChannelInfo, - id: 'info', - label: 'View Info', - placement: 'sheet', - type: 'standard', - }; - return [viewInfoItem, ...defaultItems]; - }, - ); + const getChannelActionItems = useStableCallback(({ context: { channel }, defaultItems }) => { + const viewInfo = () => { + if (!channel) { + return; + } + if (navigation) { + navigation.navigate('ChannelDetailsScreen', { + channel, + }); + } + }; + + const viewInfoItem: ChannelActionItem = { + action: viewInfo, + Icon: ChannelInfo, + id: 'info', + label: 'View Info', + placement: 'sheet', + type: 'standard', + }; + return [viewInfoItem, ...defaultItems]; + }); if (!chatClient) { return null; diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 9712fd4bc8..195ef38167 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -59,11 +59,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 @@ -78,16 +73,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/NewDirectMessagingScreen.tsx b/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx index 647b88b8f9..bf5bff3682 100644 --- a/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx +++ b/examples/SampleApp/src/screens/NewDirectMessagingScreen.tsx @@ -4,6 +4,7 @@ import { StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-nativ import { SafeAreaView } from 'react-native-safe-area-context'; import { useFocusEffect } from '@react-navigation/native'; +import type { RouteProp } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { Channel as StreamChatChannel } from 'stream-chat'; import { @@ -111,10 +112,12 @@ export type NewDirectMessagingScreenNavigationProp = NativeStackNavigationProp< export type NewDirectMessagingScreenProps = { navigation: NewDirectMessagingScreenNavigationProp; + route: RouteProp; }; export const NewDirectMessagingScreen: React.FC = ({ navigation, + route, }) => { const { theme: { semantics }, @@ -137,12 +140,24 @@ export const NewDirectMessagingScreen: React.FC = const searchInputRef = useRef(null); const currentChannel = useRef(undefined); const isDraft = useRef(true); + const initialUserIdRef = useRef(undefined); const [focusOnMessageInput, setFocusOnMessageInput] = useState(false); const [focusOnSearchInput, setFocusOnSearchInput] = useState(true); // As we don't use the state value, we can omit it here and separate it with a comma within the array. const [, setMessageInputText] = useState(''); + useEffect(() => { + const initialUser = route.params?.initialUser; + if (!initialUser || initialUserIdRef.current === initialUser.id) { + return; + } + // Ensures we initialize the selection only once per navigation. + initialUserIdRef.current = initialUser.id; + reset(); + toggleUser(initialUser); + }, [route.params?.initialUser, reset, toggleUser]); + // When selectedUsers are changed, initiate a channel with those users as members, // and set it as a channel on current screen. const selectedUsersLength = selectedUsers.length; @@ -173,6 +188,11 @@ export const NewDirectMessagingScreen: React.FC = return; } + // With members selected, collapse the user search so the composer takes over. + // The manual "tap a user in the list" path sets this directly; doing it here as + // well covers the seeded path (navigated in with a preselected user). + setFocusOnSearchInput(false); + const members = [chatClient.user.id, ...selectedUserIds]; // Check if the channel already exists. 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/screens/SharedGroupsScreen.tsx b/examples/SampleApp/src/screens/SharedGroupsScreen.tsx deleted file mode 100644 index 1c9d2f1122..0000000000 --- a/examples/SampleApp/src/screens/SharedGroupsScreen.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import React, { useMemo } from 'react'; -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; - -import { NavigationProp, RouteProp, useNavigation } from '@react-navigation/native'; -import { - ChannelList, - ChannelPreviewViewProps, - getChannelPreviewDisplayAvatar, - GroupAvatar, - useChannelPreviewDisplayName, - useChannelsContext, - useTheme, - Avatar, - getInitialsFromName, -} from 'stream-chat-react-native'; - -import { ScreenHeader } from '../components/ScreenHeader'; -import { useAppContext } from '../context/AppContext'; -import { Contacts } from '../icons/Contacts'; -import { useLegacyColors } from '../theme/useLegacyColors'; - -import type { StackNavigatorParamList } from '../types'; - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - emptyListContainer: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - }, - emptyListSubtitle: { - marginTop: 8, - textAlign: 'center', - }, - emptyListTitle: { - fontSize: 16, - marginTop: 10, - }, - groupContainer: { - alignItems: 'center', - flexDirection: 'row', - }, - nameText: { - fontWeight: '700', - marginLeft: 8, - }, - previewContainer: { - alignItems: 'center', - borderBottomWidth: 1, - flexDirection: 'row', - justifyContent: 'space-between', - padding: 12, - }, -}); - -type CustomPreviewProps = ChannelPreviewViewProps; - -export const SharedGroupsPreview: React.FC = ({ channel }) => { - const { chatClient } = useAppContext(); - const name = useChannelPreviewDisplayName(channel, 30); - const navigation = useNavigation>(); - useTheme(); - const { black, grey, grey_whisper, white_snow } = useLegacyColors(); - - const displayAvatar = getChannelPreviewDisplayAvatar(channel, chatClient); - - const placeholder = useMemo(() => { - if (displayAvatar?.name) { - return {getInitialsFromName(displayAvatar?.name)}; - } else { - return ?; - } - }, [displayAvatar.name]); - - if (!chatClient) { - return null; - } - - if (Object.keys(channel.state.members).length === 2) { - return null; - } - - const switchToChannel = () => { - navigation.reset({ - index: 1, - routes: [ - { - name: 'MessagingScreen', - }, - { - name: 'ChannelScreen', - params: { - channelId: channel.id, - }, - }, - ], - }); - }; - - return ( - - - {displayAvatar.images ? ( - - ) : ( - - )} - {name} - - - {Object.keys(channel.state.members).length} Members - - - ); -}; - -const EmptyListComponent = () => { - useTheme(); - const { black, grey, grey_gainsboro } = useLegacyColors(); - - return ( - - - No shared groups - - Groups shared with user will appear here - - - ); -}; - -// Custom empty state that also shows when there's only the 1:1 direct channel -export const SharedGroupsEmptyState = () => { - const { channels, loadingChannels, refreshing } = useChannelsContext(); - - if (loadingChannels || refreshing) { - return null; - } - - if (!channels || channels.length <= 1) { - return ; - } - - return null; -}; - -type SharedGroupsScreenRouteProp = RouteProp; - -type SharedGroupsScreenProps = { - route: SharedGroupsScreenRouteProp; -}; - -export const SharedGroupsScreen: React.FC = ({ - route: { - params: { user }, - }, -}) => { - const { chatClient } = useAppContext(); - - if (!chatClient?.user) { - return null; - } - - return ( - - - - - ); -}; diff --git a/examples/SampleApp/src/types.ts b/examples/SampleApp/src/types.ts index 43a9c1e377..623a8f0533 100644 --- a/examples/SampleApp/src/types.ts +++ b/examples/SampleApp/src/types.ts @@ -24,19 +24,13 @@ export type StackNavigatorParamList = { messageId?: string; }; MapScreen: SharedLocationResponse; - GroupChannelDetailsScreen: { + ChannelDetailsScreen: { channel: Channel; }; MessagingScreen: undefined; - NewDirectMessagingScreen: undefined; + NewDirectMessagingScreen: { initialUser?: UserResponse } | undefined; NewGroupChannelAddMemberScreen: undefined; NewGroupChannelAssignNameScreen: undefined; - OneOnOneChannelDetailScreen: { - channel: Channel; - }; - SharedGroupsScreen: { - user: UserResponse; - }; ThreadScreen: { channel: Channel; thread: LocalMessage | ThreadType; diff --git a/package/src/components/ChannelDetailsScreen/ChannelDetailsScreen.tsx b/package/src/components/ChannelDetailsScreen/ChannelDetailsScreen.tsx new file mode 100644 index 0000000000..ec14fede9f --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/ChannelDetailsScreen.tsx @@ -0,0 +1,224 @@ +import React, { useMemo } from 'react'; +import { ScrollView, StyleSheet, View } from 'react-native'; + +import type { Channel, ChannelMemberResponse } 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 type { TranslationContextValue } from '../../contexts/translationContext/TranslationContext'; +import type { GetChannelActionItems } from '../../hooks/actions/useChannelActionItems'; +import type { GetChannelMemberActionItems } from '../../hooks/actions/useChannelMemberActionItems'; +import { useIsDirectChat } from '../../hooks/useIsDirectChat'; +import { primitives } from '../../theme'; +import { GlobalFileUploadRequest } from '../../types/types'; +import { NotificationList } from '../Notifications/NotificationList'; +import { NotificationTargetProvider } from '../Notifications/NotificationTargetContext'; + +/** + * Resolves the trailing role label rendered next to a member row in the channel details screen. + * + * Return `null` or `undefined` to render no label for the given member. + */ +export type GetMemberRoleLabel = (params: { + channel: Channel; + member: ChannelMemberResponse; + t: TranslationContextValue['t']; +}) => string | null | undefined; + +export type ChannelDetailsScreenProps = { + channel: Channel; + /** + * Compress image with quality (from 0 to 1, where 1 is best quality). + * On iOS, values larger than 0.8 don't produce a noticeable quality increase in most images, + * while a value of 0.8 will reduce the file size by about half or less compared to a value of 1. + * Image picker defaults to 0.8 for iOS and 1 for Android + */ + compressImageQuality?: number; + /** + * Customize the list of action items rendered in the channel details actions section. + * + * Receives the default items the SDK produces for the current channel and returns the + * final list to render. Use this to filter, reorder, replace, or add items. + * + * The SDK still wires `onChannelDismiss` into the resulting `leave` and `deleteChannel` + * items (matched by `id`) after this callback runs, so those actions continue to dismiss + * the screen on success regardless of how the items are customized. + */ + getChannelActionItems?: GetChannelActionItems; + /** + * Customize the list of action items rendered in the per-member actions bottom sheet + * (the sheet that opens when a member row is tapped). + * + * Receives the default items the SDK produces for the tapped member (e.g. `muteUser`, + * `block`) and returns the final list to render. Use this to filter, reorder, replace, + * or add items — for example, to inject a "Send Direct Message" action in your app. + */ + getChannelMemberActionItems?: GetChannelMemberActionItems; + /** + * Override the role label shown next to each member in the channel details screen. + * + * The default implementation labels members as `Owner` (channel creator), + * `Admin` (`user.role === 'admin'`), or `Moderator` (`channel_role === 'channel_moderator'`), + * with priority Owner > Admin > Moderator. Return `null` to render no label. + */ + getMemberRoleLabel?: GetMemberRoleLabel; + /** + * 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, leave, or block actions). */ + onChannelDismiss?: () => void; + /** + * Fired when the user taps the "Edit" button in the channel details header. + * The button is only rendered when the current user has the `update-channel` + * capability. By default it opens the channel edit details modal. Not shown in direct (1:1) channels. + */ + onEditChannelPress?: () => void; + /** + * Fired when the user taps a member row. Receives the tapped member. + * + * Applies both to the member preview on the channel details screen and to the full + * list opened via the "view all members" modal. If omitted, the default behavior is + * to open the per-member actions bottom sheet (mute, block, etc.). + */ + onMemberPress?: (member: ChannelMemberResponse) => void; + /** + * Fired when the user taps the "view all members" button, by default it opens the members bottom sheet. + */ + onViewAllMembersPress?: () => void; + /** + * Override file upload request (used to upload channel image). By default it will use Stream's CDN. + * @param file File object to upload + */ + doFileUploadRequest?: GlobalFileUploadRequest; +}; + +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 ( + + + + + {ChannelDetailsNavigationSection ? : null} + {isDirect ? null : } + + + + ); +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelDetailsScreen = ({ + channel, + compressImageQuality, + doFileUploadRequest, + getChannelActionItems, + getChannelMemberActionItems, + getMemberRoleLabel, + onAddMembersPress, + onBack, + onChannelDismiss, + onEditChannelPress, + onMemberPress, + onViewAllMembersPress, +}: ChannelDetailsScreenProps) => { + const { ChannelDetailsScreenContent: ChannelDetailsScreenContentOverride } = + useComponentsContext(); + const value = useMemo( + () => ({ + channel, + compressImageQuality, + doFileUploadRequest, + getChannelActionItems, + getChannelMemberActionItems, + getMemberRoleLabel, + onAddMembersPress, + onBack, + onChannelDismiss, + onEditChannelPress, + onMemberPress, + onViewAllMembersPress, + }), + [ + channel, + compressImageQuality, + doFileUploadRequest, + getChannelActionItems, + getChannelMemberActionItems, + getMemberRoleLabel, + onAddMembersPress, + onBack, + onChannelDismiss, + onEditChannelPress, + onMemberPress, + 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__/ChannelDetailsActionItem.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsActionItem.test.tsx new file mode 100644 index 0000000000..e3593f13f1 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsActionItem.test.tsx @@ -0,0 +1,117 @@ +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 { ChannelDetailsActionItem } from '../components/ChannelDetailsActionItem'; + +const TestIcon = jest.fn(() => null); + +const renderItem = (props: Partial> = {}) => + render( + + + , + ); + +describe('ChannelDetailsActionItem', () => { + 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('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('colors the icon via stroke', () => { + renderItem(); + const icon = lastIconProps(); + expect(icon.stroke).toBeTruthy(); + }); + + it('paints the icon and label differently when destructive vs standard', () => { + const { rerender } = renderItem({ destructive: false }); + const standardIcon = lastIconProps().stroke; + const standardLabelColor = labelColor(); + + TestIcon.mockClear(); + rerender( + + + , + ); + const destructiveIcon = lastIconProps().stroke; + const destructiveLabelColor = labelColor(); + + expect(destructiveIcon).not.toBe(standardIcon); + expect(destructiveLabelColor).not.toBe(standardLabelColor); + expect(destructiveIcon).toBe(destructiveLabelColor); + }); + }); + + describe('accessibility', () => { + it('leaves the trailing slot exposed to assistive tech (hiding is the caller’s job)', () => { + renderItem({ testID: 'item', trailing: 5 }); + // The component no longer force-hides the trailing slot — a plain node stays visible to a11y. + expect(screen.queryByTestId('trailing')).toBeTruthy(); + }); + }); +}); 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..e99679f057 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsActionsSection.test.tsx @@ -0,0 +1,338 @@ +import React from 'react'; +import { Switch, Text } from 'react-native'; + +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import type { Channel } 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 type { ChannelActionItem } from '../../../hooks/actions/useChannelActionItems'; +import * as useChannelActionsModule from '../../../hooks/actions/useChannelActions'; +import * as useIsDirectChatModule from '../../../hooks/useIsDirectChat'; +import * as useMutedUsersModule from '../../ChannelList/hooks/useMutedUsers'; +import * as useIsChannelMutedModule from '../../ChannelPreview/hooks/useIsChannelMuted'; +import type { ChannelDetailsActionItemProps } from '../components/ChannelDetailsActionItem'; +import { ChannelDetailsActionsSection } from '../components/ChannelDetailsActionsSection'; +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 = ChannelDetailsActionItemProps & { testID?: string }; + +const probeCalls: Probe[] = []; +const ActionItemProbe = (props: Probe) => { + probeCalls.push(props); + return ( + <> + + {props.label} + + {props.trailing} + + ); +}; + +const sectionElement = () => ( + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + ({ unsubscribe: () => undefined }), + userID: 'me', + }, + } as never + } + > + + + + + + + + +); + +const renderSection = () => render(sectionElement()); + +describe('ChannelDetailsActionsSection', () => { + let useIsDirectChatSpy: jest.SpyInstance; + let useActionItemsSpy: jest.SpyInstance; + let useIsChannelMutedSpy: jest.SpyInstance; + let useMutedUsersSpy: jest.SpyInstance; + let getOtherUserSpy: jest.SpyInstance; + + beforeEach(() => { + probeCalls.length = 0; + useIsDirectChatSpy = jest + .spyOn(useIsDirectChatModule, 'useIsDirectChat') + .mockReturnValue(false); + useActionItemsSpy = jest + .spyOn(useChannelDetailsActionItemsModule, 'useChannelDetailsActionItems') + .mockReturnValue([]); + useIsChannelMutedSpy = jest + .spyOn(useIsChannelMutedModule, 'useIsChannelMuted') + .mockReturnValue({ createdAt: null, expiresAt: null, muted: false }); + useMutedUsersSpy = jest.spyOn(useMutedUsersModule, 'useMutedUsers').mockReturnValue([]); + getOtherUserSpy = jest + .spyOn(useChannelActionsModule, 'getOtherUserInDirectChannel') + .mockReturnValue(undefined); + }); + + 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 ChannelDetailsActionItem', () => { + useActionItemsSpy.mockReturnValue([leaveItem]); + renderSection(); + const [item] = probeCalls; + expect(item.Icon).toBe(leaveItem.Icon); + expect(item.label).toBe('Leave 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 a non-toggle list item is pressed', () => { + const action = jest.fn(); + useActionItemsSpy.mockReturnValue([buildItem({ action, id: 'leave', label: 'Leave Group' })]); + renderSection(); + fireEvent.press(screen.getByTestId('channel-details-action-leave')); + expect(action).toHaveBeenCalledTimes(1); + }); + + it('does not forward onPress for the mute toggle row (Switch-driven)', () => { + useActionItemsSpy.mockReturnValue([buildItem({ id: 'mute', label: 'Mute Group' })]); + renderSection(); + const [item] = probeCalls; + expect(item.onPress).toBeUndefined(); + }); + }); + + describe('ChannelDetailsActionItem 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); + }); + }); + + describe('mute / muteUser trailing Switch', () => { + const leaveItem = buildItem({ id: 'leave', label: 'Leave Group', type: 'destructive' }); + + it('passes a Switch as trailing only for mute and muteUser items', () => { + useActionItemsSpy.mockReturnValue([ + buildItem({ id: 'mute', label: 'Mute Group' }), + leaveItem, + ]); + renderSection(); + const byId = Object.fromEntries(probeCalls.map((p) => [p.testID, p.trailing])); + expect(byId['channel-details-action-mute']).toBeTruthy(); + expect(byId['channel-details-action-leave']).toBeUndefined(); + }); + + it('reflects channelMuted state on the mute item Switch', () => { + useIsChannelMutedSpy.mockReturnValue({ createdAt: null, expiresAt: null, muted: true }); + useActionItemsSpy.mockReturnValue([buildItem({ id: 'mute', label: 'Unmute Group' })]); + renderSection(); + const muteSwitch = screen.getByTestId('channel-details-action-mute-switch'); + expect(muteSwitch.props.value).toBe(true); + }); + + it('invokes the item action with an onFailure callback when the mute Switch is toggled', () => { + const action = jest.fn(); + useActionItemsSpy.mockReturnValue([buildItem({ action, id: 'mute', label: 'Mute Group' })]); + renderSection(); + const muteSwitch = screen.getByTestId('channel-details-action-mute-switch'); + fireEvent(muteSwitch, 'valueChange', true); + expect(action).toHaveBeenCalledTimes(1); + expect(typeof action.mock.calls[0][0].onFailure).toBe('function'); + }); + + it('optimistically reflects the new value on the mute Switch before the action resolves', () => { + useIsChannelMutedSpy.mockReturnValue({ createdAt: null, expiresAt: null, muted: false }); + useActionItemsSpy.mockReturnValue([ + buildItem({ action: jest.fn(), id: 'mute', label: 'Mute Group' }), + ]); + renderSection(); + const muteSwitch = screen.getByTestId('channel-details-action-mute-switch'); + expect(muteSwitch.props.value).toBe(false); + fireEvent(muteSwitch, 'valueChange', true); + expect(screen.getByTestId('channel-details-action-mute-switch').props.value).toBe(true); + }); + + it('rolls back the mute Switch when the action invokes onFailure', () => { + const action = jest.fn(); + useIsChannelMutedSpy.mockReturnValue({ createdAt: null, expiresAt: null, muted: false }); + useActionItemsSpy.mockReturnValue([buildItem({ action, id: 'mute', label: 'Mute Group' })]); + renderSection(); + const muteSwitch = screen.getByTestId('channel-details-action-mute-switch'); + fireEvent(muteSwitch, 'valueChange', true); + expect(screen.getByTestId('channel-details-action-mute-switch').props.value).toBe(true); + act(() => { + action.mock.calls[0][0].onFailure(); + }); + expect(screen.getByTestId('channel-details-action-mute-switch').props.value).toBe(false); + }); + + it('rolls back the mute Switch to the current hook value (not !value) when it changed mid-flight', () => { + const action = jest.fn(); + useIsChannelMutedSpy.mockReturnValue({ createdAt: null, expiresAt: null, muted: false }); + useActionItemsSpy.mockReturnValue([buildItem({ action, id: 'mute', label: 'Mute Group' })]); + const { rerender } = renderSection(); + const muteSwitch = screen.getByTestId('channel-details-action-mute-switch'); + // Optimistically flip on while the mute request is in flight. + fireEvent(muteSwitch, 'valueChange', true); + expect(screen.getByTestId('channel-details-action-mute-switch').props.value).toBe(true); + // A server event reports the channel as muted before the request resolves. + useIsChannelMutedSpy.mockReturnValue({ createdAt: null, expiresAt: null, muted: true }); + rerender(sectionElement()); + // The request fails: revert to the current hook value (true), not !value (false). + act(() => { + action.mock.calls[0][0].onFailure(); + }); + expect(screen.getByTestId('channel-details-action-mute-switch').props.value).toBe(true); + }); + + it('reflects userMuted state on the muteUser item Switch in direct chats', () => { + useIsDirectChatSpy.mockReturnValue(true); + getOtherUserSpy.mockReturnValue({ user: { id: 'other-user' } }); + useMutedUsersSpy.mockReturnValue([{ target: { id: 'other-user' }, user: { id: 'me' } }]); + useActionItemsSpy.mockReturnValue([buildItem({ id: 'muteUser', label: 'Unmute User' })]); + renderSection(); + const userMuteSwitch = screen.getByTestId('channel-details-action-muteUser-switch'); + expect(userMuteSwitch.props.value).toBe(true); + }); + + it('optimistically updates and rolls back the muteUser Switch on failure', () => { + const action = jest.fn(); + useIsDirectChatSpy.mockReturnValue(true); + getOtherUserSpy.mockReturnValue({ user: { id: 'other-user' } }); + useMutedUsersSpy.mockReturnValue([]); + useActionItemsSpy.mockReturnValue([ + buildItem({ action, id: 'muteUser', label: 'Mute User' }), + ]); + renderSection(); + const userMuteSwitch = screen.getByTestId('channel-details-action-muteUser-switch'); + expect(userMuteSwitch.props.value).toBe(false); + fireEvent(userMuteSwitch, 'valueChange', true); + expect(screen.getByTestId('channel-details-action-muteUser-switch').props.value).toBe(true); + expect(action).toHaveBeenCalledTimes(1); + act(() => { + action.mock.calls[0][0].onFailure(); + }); + expect(screen.getByTestId('channel-details-action-muteUser-switch').props.value).toBe(false); + }); + + it('rolls back the muteUser Switch to the current hook value (not !value) when it changed mid-flight', () => { + const action = jest.fn(); + useIsDirectChatSpy.mockReturnValue(true); + getOtherUserSpy.mockReturnValue({ user: { id: 'other-user' } }); + useMutedUsersSpy.mockReturnValue([]); + useActionItemsSpy.mockReturnValue([ + buildItem({ action, id: 'muteUser', label: 'Mute User' }), + ]); + const { rerender } = renderSection(); + const userMuteSwitch = screen.getByTestId('channel-details-action-muteUser-switch'); + // Optimistically flip on while the mute request is in flight. + fireEvent(userMuteSwitch, 'valueChange', true); + expect(screen.getByTestId('channel-details-action-muteUser-switch').props.value).toBe(true); + // A server event reports the user as muted before the request resolves. + useMutedUsersSpy.mockReturnValue([{ target: { id: 'other-user' }, user: { id: 'me' } }]); + rerender(sectionElement()); + // The request fails: revert to the current hook value (true), not !value (false). + act(() => { + action.mock.calls[0][0].onFailure(); + }); + expect(screen.getByTestId('channel-details-action-muteUser-switch').props.value).toBe(true); + }); + + it('userMuted is false when the other user is not in mutedUsers', () => { + useIsDirectChatSpy.mockReturnValue(true); + getOtherUserSpy.mockReturnValue({ user: { id: 'other-user' } }); + useMutedUsersSpy.mockReturnValue([{ target: { id: 'someone-else' }, user: { id: 'me' } }]); + useActionItemsSpy.mockReturnValue([buildItem({ id: 'muteUser', label: 'Mute User' })]); + renderSection(); + const userMuteSwitch = screen.getByTestId('channel-details-action-muteUser-switch'); + expect(userMuteSwitch.props.value).toBe(false); + }); + + it('renders Switch components in the tree for mute toggles', () => { + useActionItemsSpy.mockReturnValue([buildItem({ id: 'mute', label: 'Mute Group' })]); + const { UNSAFE_getAllByType } = renderSection(); + expect(UNSAFE_getAllByType(Switch)).toHaveLength(1); + }); + }); +}); 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..21a6132daf --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsMemberSection.test.tsx @@ -0,0 +1,298 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import { NotificationManager } from 'stream-chat'; +import type { Channel, ChannelMemberResponse } 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/actions/useChannelActions'; +import { generateMember } from '../../../mock-builders/generator/member'; +import { generateUser } from '../../../mock-builders/generator/user'; +import { ChannelDetailsMemberSection } from '../components/ChannelDetailsMemberSection'; +import type { ChannelMemberActionsSheetProps } from '../components/members/ChannelMemberActionsSheet'; +import type { ChannelMemberItemProps } from '../components/members/ChannelMemberItem'; +import * as useChannelDetailsMembersPreviewModule from '../hooks/useChannelDetailsMembersPreview'; + +jest.mock('../../../hooks/actions/useChannelActions'); +const mockedUseChannelActions = jest.mocked(useChannelActions); + +const MemberListProbe = () => full-member-list; + +const AddMembersProbe = () => add-members; + +const memberItemProbeCalls: ChannelMemberItemProps[] = []; +const MemberItemProbe = (props: ChannelMemberItemProps) => { + memberItemProbeCalls.push(props); + return {props.member.user?.name}; +}; + +const MemberActionsSheetProbe = ({ member }: ChannelMemberActionsSheetProps) => ( + {member.user?.id ?? ''} +); + +const buildChannel = ( + members: ChannelMemberResponse[], + memberCount?: number, + overrides?: Partial, +): Channel => + ({ + 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, + onMemberPress, + onViewAllMembersPress, +}: { + channel: Channel; + capabilities?: Partial; + onAddMembersPress?: () => void; + onMemberPress?: (member: ChannelMemberResponse) => void; + onViewAllMembersPress?: () => void; +}) => + render( + + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + ({ unsubscribe: () => undefined }), + userID: 'me', + }, + } as never + } + > + + + + + + + + + , + ); + +const makeMembers = (count: number) => + Array.from({ length: count }, (_, idx) => + generateMember({ user: generateUser({ id: `u-${idx}`, name: `User ${idx}` }) }), + ); + +describe('ChannelDetailsMemberSection', () => { + let previewSpy: jest.SpyInstance; + + beforeEach(() => { + memberItemProbeCalls.length = 0; + previewSpy = jest.spyOn( + useChannelDetailsMembersPreviewModule, + 'useChannelDetailsMembersPreview', + ); + mockedUseChannelActions.mockReturnValue({ + addMembers: jest.fn(), + } 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 all-members modal 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('hides the preview add button 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(); + }); + + 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, + }); + + fireEvent.press(screen.getByTestId('channel-details-member-section-add-button')); + + expect(onAddMembersPress).toHaveBeenCalledTimes(1); + }); + + 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('opens the per-member actions sheet when a member row is pressed and no onMemberPress override is provided', () => { + const members = makeMembers(3); + previewSpy.mockReturnValue({ hasMore: false, total: 3, visible: members }); + const channel = buildChannel(members, 3); + + renderSection({ channel }); + + expect(screen.queryByTestId('member-actions-sheet-probe')).toBeNull(); + + const lastCallForFirstMember = [...memberItemProbeCalls] + .reverse() + .find((call) => call.member.user?.id === 'u-0'); + act(() => { + lastCallForFirstMember?.onPress?.(lastCallForFirstMember.member); + }); + + expect(screen.getByTestId('member-actions-sheet-probe')).toBeTruthy(); + expect(screen.getByTestId('member-actions-sheet-probe').props.children).toBe('u-0'); + }); + + it('calls onMemberPress instead of opening the per-member actions sheet when provided', () => { + const members = makeMembers(3); + previewSpy.mockReturnValue({ hasMore: false, total: 3, visible: members }); + const channel = buildChannel(members, 3); + const onMemberPress = jest.fn(); + + renderSection({ channel, onMemberPress }); + + const lastCallForSecondMember = [...memberItemProbeCalls] + .reverse() + .find((call) => call.member.user?.id === 'u-1'); + act(() => { + lastCallForSecondMember?.onPress?.(lastCallForSecondMember.member); + }); + + expect(onMemberPress).toHaveBeenCalledTimes(1); + expect(onMemberPress.mock.calls[0][0].user?.id).toBe('u-1'); + expect(screen.queryByTestId('member-actions-sheet-probe')).toBeNull(); + }); + + it('swaps the all-members modal for the Add-members sheet when the modal Add button is pressed', () => { + 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(); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsNavigationSection.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsNavigationSection.test.tsx new file mode 100644 index 0000000000..1c8ac8f986 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsNavigationSection.test.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import { render } from '@testing-library/react-native'; + +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; +import type { ChannelDetailsActionItemProps } from '../components/ChannelDetailsActionItem'; +import { ChannelDetailsNavigationSection } from '../components/ChannelDetailsNavigationSection'; + +const probeCalls: ChannelDetailsActionItemProps[] = []; + +jest.mock('../components/ChannelDetailsActionItem', () => { + const ReactLib = require('react'); + const { Text: RNText } = require('react-native'); + return { + ChannelDetailsActionItem: (props: ChannelDetailsActionItemProps) => { + probeCalls.push(props); + return ReactLib.createElement( + RNText, + { onPress: props.onPress, testID: props.testID }, + props.label, + ); + }, + }; +}); + +const renderSection = () => + render( + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + + , + ); + +describe('ChannelDetailsNavigationSection', () => { + beforeEach(() => { + probeCalls.length = 0; + }); + + it('renders the three navigation rows with their labels and testIDs', () => { + const { getByTestId } = renderSection(); + + expect(getByTestId('channel-details-pinned-messages')).toBeTruthy(); + expect(getByTestId('channel-details-photos-and-videos')).toBeTruthy(); + expect(getByTestId('channel-details-files')).toBeTruthy(); + + expect(probeCalls.map((p) => p.testID)).toEqual([ + 'channel-details-pinned-messages', + 'channel-details-photos-and-videos', + 'channel-details-files', + ]); + expect(probeCalls.map((p) => p.label)).toEqual(['Pinned Messages', 'Photos & Videos', 'Files']); + }); + + it('passes an Icon and a trailing chevron to every row and leaves them non-interactive', () => { + renderSection(); + + expect(probeCalls).toHaveLength(3); + probeCalls.forEach((props) => { + expect(props.Icon).toBeTruthy(); + expect(props.trailing).toBeTruthy(); + expect(props.onPress).toBeUndefined(); + }); + }); + + it('reuses a single memoized chevron element across all rows', () => { + renderSection(); + + const [first, second, third] = probeCalls.map((p) => p.trailing); + expect(first).toBe(second); + expect(second).toBe(third); + }); +}); 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..f6a468b5ee --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsProfile.test.tsx @@ -0,0 +1,145 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react-native'; +import type { Channel } 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 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 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 useChannelPreviewDisplayNameSpy: jest.SpyInstance; + let useChannelDetailsMemberStatusTextSpy: jest.SpyInstance; + let useChannelMuteActiveSpy: jest.SpyInstance; + + beforeEach(() => { + channelAvatarCalls.length = 0; + 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('exposes the title row as a header labelled with the display name', () => { + renderProfile(); + const header = screen.getByRole('header'); + expect(header.props.accessibilityLabel).toBe('Display Name'); + }); + + 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('subtitle', () => { + it('renders the status text returned by useChannelDetailsMemberStatusText', () => { + renderProfile(); + expect(screen.getByText('12 members, 3 online')).toBeTruthy(); + }); + + it('renders a direct-chat status string from the hook', () => { + useChannelDetailsMemberStatusTextSpy.mockReturnValue('Online'); + renderProfile(); + expect(screen.getByText('Online')).toBeTruthy(); + }); + + it('does not render a subtitle when the status text is empty', () => { + useChannelDetailsMemberStatusTextSpy.mockReturnValue(''); + 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('announces the muted status in the header accessibility label', () => { + useChannelMuteActiveSpy.mockReturnValue(true); + renderProfile(); + expect(screen.getByRole('header').props.accessibilityLabel).toBe('Display Name, Muted'); + }); + + 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..8f49a2dd85 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsScreen.test.tsx @@ -0,0 +1,178 @@ +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 { 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); + }); + }); + + 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__/ChannelDetailsScreenHeader.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsScreenHeader.test.tsx new file mode 100644 index 0000000000..595e064450 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelDetailsScreenHeader.test.tsx @@ -0,0 +1,146 @@ +import React, { PropsWithChildren } from 'react'; +import { Text, View } from 'react-native'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { NotificationManager } from 'stream-chat'; +import type { Channel } 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 { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; +import { useChannelActions } from '../../../hooks/actions/useChannelActions'; +import * as useIsDirectChatModule from '../../../hooks/useIsDirectChat'; +import { ChannelDetailsScreenHeader } from '../components/ChannelDetailsScreenHeader'; + +jest.mock('../../../hooks/actions/useChannelActions'); +const mockedUseChannelActions = jest.mocked(useChannelActions); + +const EditDetailsProbe = () => ( + + edit-details + +); + +const buildChannel = (capabilities: string[] = []): Channel => + ({ + cid: 'messaging:test', + data: { own_capabilities: capabilities }, + on: () => ({ unsubscribe: () => undefined }), + state: { members: {} }, + }) as unknown as Channel; + +const Providers = ({ children }: PropsWithChildren) => ( + + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + {children} + + + + +); + +const renderHeader = ({ + channel, + onBack, + onEditChannelPress, +}: { + channel: Channel; + onBack?: () => void; + onEditChannelPress?: () => void; +}) => + render( + + + + + + + , + ); + +describe('ChannelDetailsScreenHeader', () => { + beforeEach(() => { + jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(false); + mockedUseChannelActions.mockReturnValue({ + updateImage: jest.fn(), + updateName: jest.fn(), + } as unknown as ReturnType); + }); + + afterEach(() => { + jest.restoreAllMocks(); + mockedUseChannelActions.mockReset(); + }); + + it('does not render the Edit button when the user lacks the update-channel capability', () => { + renderHeader({ channel: buildChannel([]) }); + + expect(screen.queryByTestId('channel-details-edit-button')).toBeNull(); + }); + + it('renders the Edit button when the user has the update-channel capability', () => { + renderHeader({ channel: buildChannel(['update-channel']) }); + + const button = screen.getByTestId('channel-details-edit-button'); + expect(button).toBeTruthy(); + expect(screen.getByText('Edit')).toBeTruthy(); + }); + + it('does not render the Edit button in a direct (1:1) channel even with the update-channel capability', () => { + jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(true); + + renderHeader({ channel: buildChannel(['update-channel']) }); + + expect(screen.queryByTestId('channel-details-edit-button')).toBeNull(); + }); + + it('invokes onEditChannelPress when the Edit button is pressed', () => { + const onEditChannelPress = jest.fn(); + renderHeader({ channel: buildChannel(['update-channel']), onEditChannelPress }); + + fireEvent.press(screen.getByTestId('channel-details-edit-button')); + + expect(onEditChannelPress).toHaveBeenCalledTimes(1); + }); + + it('opens the edit modal when the Edit button is pressed and onEditChannelPress is not provided', () => { + renderHeader({ channel: buildChannel(['update-channel']) }); + + expect(screen.queryByTestId('channel-edit-details-probe')).toBeNull(); + + fireEvent.press(screen.getByTestId('channel-details-edit-button')); + + expect(screen.getByTestId('channel-edit-details-probe')).toBeTruthy(); + }); + + it('renders the back button only when onBack is provided', () => { + const { rerender } = renderHeader({ channel: buildChannel([]) }); + expect(screen.queryByTestId('channel-details-back-button')).toBeNull(); + + rerender( + + + + + + + , + ); + + expect(screen.getByTestId('channel-details-back-button')).toBeTruthy(); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelEditDetails.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelEditDetails.test.tsx new file mode 100644 index 0000000000..90b9b6caf5 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelEditDetails.test.tsx @@ -0,0 +1,334 @@ +import React from 'react'; +import { Image, Pressable, Text } from 'react-native'; + +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import type { Channel } 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 { generateFileReference } from '../../../mock-builders/attachments'; +import { NativeHandlers } from '../../../native'; +import { ChannelEditDetails } from '../components/ChannelEditDetails'; +import type { ChannelEditImageSheetProps } from '../components/ChannelEditImageSheet'; +import type { ChannelEditNameProps } from '../components/ChannelEditName'; + +type SheetProbeRecord = ChannelEditImageSheetProps; +const sheetCalls: SheetProbeRecord[] = []; + +const SheetProbe = (props: ChannelEditImageSheetProps) => { + sheetCalls.push(props); + if (!props.visible) return null; + return ( + <> + + close + + + camera + + + library + + {props.onSelectReset ? ( + + reset + + ) : null} + + ); +}; + +const buildChannel = (overrides?: { image?: string; name?: string }): Channel => + ({ + cid: 'messaging:test', + data: { + name: overrides && 'name' in overrides ? overrides.name : 'Original', + ...(overrides && 'image' in overrides ? { image: overrides.image } : {}), + }, + on: () => ({ unsubscribe: () => undefined }), + state: { members: {} }, + }) as unknown as Channel; + +// The avatar renders its image through the default `SvgAwareImage`, which for a +// raster URI is a plain RN `Image`. Read back the displayed `uri` (or undefined +// when the avatar falls back to the member/user placeholder). +const avatarImageUri = (): string | undefined => + screen.UNSAFE_queryByType(Image)?.props?.source?.uri; + +const renderComponent = ({ + channel, + onImagePicked = jest.fn(), + onImageReset, + onNameChange = jest.fn(), +}: { + channel: Channel; + onImagePicked?: (file: File) => void; + onImageReset?: () => void; + onNameChange?: (name: string) => void; +}) => + render( + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + ({ unsubscribe: () => undefined }), userID: 'me' } } as never + } + > + + + + + + + + , + ); + +const latestSheetProps = () => sheetCalls[sheetCalls.length - 1]; + +describe('ChannelEditDetails', () => { + beforeEach(() => { + sheetCalls.length = 0; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the upload button', () => { + renderComponent({ channel: buildChannel() }); + + expect(screen.getByTestId('channel-edit-upload-button')).toBeTruthy(); + }); + + it('delegates the name field to the context-resolved ChannelEditName', () => { + const nameCalls: ChannelEditNameProps[] = []; + const NameProbe = (props: ChannelEditNameProps) => { + nameCalls.push(props); + return null; + }; + const onNameChange = jest.fn(); + + render( + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + ({ unsubscribe: () => undefined }), userID: 'me' } } as never + } + > + + + + + + + + , + ); + + expect(nameCalls).toHaveLength(1); + expect(nameCalls[0].onNameChange).toBe(onNameChange); + }); + + describe('upload button + edit-picture sheet', () => { + it('renders the sheet hidden by default', () => { + renderComponent({ channel: buildChannel() }); + + expect(latestSheetProps().visible).toBe(false); + }); + + it('opens the sheet when the upload button is pressed', () => { + renderComponent({ channel: buildChannel() }); + + fireEvent.press(screen.getByTestId('channel-edit-upload-button')); + + expect(latestSheetProps().visible).toBe(true); + }); + + it('closes the sheet when the sheet calls onClose', () => { + renderComponent({ channel: buildChannel() }); + + fireEvent.press(screen.getByTestId('channel-edit-upload-button')); + fireEvent.press(screen.getByTestId('sheet-probe-close')); + + expect(latestSheetProps().visible).toBe(false); + }); + + it('forwards a camera-captured file to onImagePicked after the sheet closes', async () => { + const file = generateFileReference(); + jest.spyOn(NativeHandlers, 'takePhoto').mockResolvedValue(file); + const onImagePicked = jest.fn(); + + renderComponent({ channel: buildChannel(), onImagePicked }); + fireEvent.press(screen.getByTestId('channel-edit-upload-button')); + + // The sheet's row stub triggers onSelectCamera then onClose, mirroring the + // production sheet's order. The picker call is deferred until the sheet + // visibility flips to false (plus the dismiss-buffer timeout). + act(() => { + fireEvent.press(screen.getByTestId('sheet-probe-camera')); + fireEvent.press(screen.getByTestId('sheet-probe-close')); + }); + + await waitFor(() => expect(NativeHandlers.takePhoto).toHaveBeenCalledTimes(1)); + expect(onImagePicked).toHaveBeenCalledWith(file); + }); + + it('forwards a picked gallery file to onImagePicked after the sheet closes', async () => { + const file = generateFileReference(); + jest + .spyOn(NativeHandlers, 'pickImage') + .mockResolvedValue({ assets: [file], cancelled: false }); + const onImagePicked = jest.fn(); + + renderComponent({ channel: buildChannel(), onImagePicked }); + fireEvent.press(screen.getByTestId('channel-edit-upload-button')); + + act(() => { + fireEvent.press(screen.getByTestId('sheet-probe-library')); + fireEvent.press(screen.getByTestId('sheet-probe-close')); + }); + + await waitFor(() => expect(NativeHandlers.pickImage).toHaveBeenCalledTimes(1)); + expect(onImagePicked).toHaveBeenCalledTimes(1); + expect(onImagePicked).toHaveBeenCalledWith(expect.objectContaining({ uri: file.uri })); + }); + + it('does not call the picker while the sheet is still visible', async () => { + jest.spyOn(NativeHandlers, 'pickImage').mockResolvedValue({ assets: [], cancelled: false }); + + renderComponent({ channel: buildChannel() }); + fireEvent.press(screen.getByTestId('channel-edit-upload-button')); + fireEvent.press(screen.getByTestId('sheet-probe-library')); + + // Action is queued but should NOT have been invoked yet — the sheet is + // still visible. + await act(async () => { + await Promise.resolve(); + }); + expect(NativeHandlers.pickImage).not.toHaveBeenCalled(); + }); + + it('does not call onImagePicked when the camera flow is cancelled', async () => { + jest.spyOn(NativeHandlers, 'takePhoto').mockResolvedValue({ cancelled: true } as never); + const onImagePicked = jest.fn(); + + renderComponent({ channel: buildChannel(), onImagePicked }); + fireEvent.press(screen.getByTestId('channel-edit-upload-button')); + + act(() => { + fireEvent.press(screen.getByTestId('sheet-probe-camera')); + fireEvent.press(screen.getByTestId('sheet-probe-close')); + }); + + await waitFor(() => expect(NativeHandlers.takePhoto).toHaveBeenCalledTimes(1)); + expect(onImagePicked).not.toHaveBeenCalled(); + }); + + it('omits onSelectReset from the sheet when onImageReset is not provided', () => { + renderComponent({ channel: buildChannel() }); + + expect(latestSheetProps().onSelectReset).toBeUndefined(); + }); + + it('invokes onImageReset after the sheet closes when the Reset row is pressed', async () => { + const onImageReset = jest.fn(); + renderComponent({ channel: buildChannel(), onImageReset }); + + fireEvent.press(screen.getByTestId('channel-edit-upload-button')); + expect(latestSheetProps().onSelectReset).toBeDefined(); + + act(() => { + fireEvent.press(screen.getByTestId('sheet-probe-reset')); + fireEvent.press(screen.getByTestId('sheet-probe-close')); + }); + + await waitFor(() => expect(onImageReset).toHaveBeenCalledTimes(1)); + }); + }); + + describe('avatar preview', () => { + it('shows the live channel image while untouched', () => { + renderComponent({ channel: buildChannel({ image: 'https://example.com/live.png' }) }); + + expect(avatarImageUri()).toBe('https://example.com/live.png'); + }); + + it('previews a gallery-picked image before it is saved', async () => { + const file = generateFileReference(); + jest + .spyOn(NativeHandlers, 'pickImage') + .mockResolvedValue({ assets: [file], cancelled: false }); + + renderComponent({ channel: buildChannel({ image: 'https://example.com/live.png' }) }); + fireEvent.press(screen.getByTestId('channel-edit-upload-button')); + + act(() => { + fireEvent.press(screen.getByTestId('sheet-probe-library')); + fireEvent.press(screen.getByTestId('sheet-probe-close')); + }); + + await waitFor(() => expect(avatarImageUri()).toBe(file.uri)); + }); + + it('previews a camera-captured image before it is saved', async () => { + const file = generateFileReference(); + jest.spyOn(NativeHandlers, 'takePhoto').mockResolvedValue(file); + + renderComponent({ channel: buildChannel({ image: 'https://example.com/live.png' }) }); + fireEvent.press(screen.getByTestId('channel-edit-upload-button')); + + act(() => { + fireEvent.press(screen.getByTestId('sheet-probe-camera')); + fireEvent.press(screen.getByTestId('sheet-probe-close')); + }); + + await waitFor(() => expect(avatarImageUri()).toBe(file.uri)); + }); + + it('drops the live image when the user resets the picture', async () => { + const onImageReset = jest.fn(); + renderComponent({ + channel: buildChannel({ image: 'https://example.com/live.png' }), + onImageReset, + }); + expect(avatarImageUri()).toBe('https://example.com/live.png'); + + fireEvent.press(screen.getByTestId('channel-edit-upload-button')); + + act(() => { + fireEvent.press(screen.getByTestId('sheet-probe-reset')); + fireEvent.press(screen.getByTestId('sheet-probe-close')); + }); + + await waitFor(() => expect(onImageReset).toHaveBeenCalledTimes(1)); + expect(avatarImageUri()).toBeUndefined(); + }); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelEditDetailsModal.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelEditDetailsModal.test.tsx new file mode 100644 index 0000000000..c68a3fb0b2 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelEditDetailsModal.test.tsx @@ -0,0 +1,244 @@ +import React from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; + +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import { NotificationManager } from 'stream-chat'; +import type { Channel } 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 { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; +import { useChannelActions } from '../../../hooks/actions/useChannelActions'; +import type { ChannelEditDetailsProps } from '../components/ChannelEditDetails'; +import { ChannelEditDetailsModal } from '../components/ChannelEditDetailsModal'; + +jest.mock('../../../hooks/actions/useChannelActions'); +const mockedUseChannelActions = jest.mocked(useChannelActions); + +const EditDetailsProbe = ({ onNameChange }: ChannelEditDetailsProps) => ( + + + onNameChange('Different')} testID='probe-set-name'> + set + + onNameChange('')} testID='probe-clear-name'> + clear + + onNameChange(' ')} testID='probe-whitespace-name'> + whitespace + + +); + +const buildChannel = (overrides?: { name?: string; cid?: string }): Channel => + ({ + cid: overrides?.cid ?? 'messaging:test', + data: { name: overrides?.name ?? 'Original' }, + on: () => ({ unsubscribe: () => undefined }), + state: { members: {} }, + }) as unknown as Channel; + +const renderModal = ({ + channel, + onClose = jest.fn(), + visible = true, +}: { + channel: Channel; + onClose?: () => void; + visible?: boolean; +}) => + render( + + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + + + + + + + + + , + ); + +describe('ChannelEditDetailsModal', () => { + let updateNameSpy: jest.Mock; + + beforeEach(() => { + updateNameSpy = jest.fn(async (_name: string, options?: { onSuccess?: () => unknown }) => { + await options?.onSuccess?.(); + }); + mockedUseChannelActions.mockReturnValue({ + updateName: updateNameSpy, + } as unknown as ReturnType); + }); + + afterEach(() => { + jest.restoreAllMocks(); + mockedUseChannelActions.mockReset(); + }); + + it('disables the confirm button on initial render when the name is unchanged', () => { + renderModal({ channel: buildChannel() }); + + expect( + screen.getByTestId('channel-details-edit-confirm-button').props.accessibilityState, + ).toMatchObject({ disabled: true }); + }); + + it('enables the confirm button after typing a different name', () => { + renderModal({ channel: buildChannel() }); + + fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), 'Different'); + + expect( + screen.getByTestId('channel-details-edit-confirm-button').props.accessibilityState, + ).toMatchObject({ disabled: false }); + }); + + it('enables the confirm button after clearing a channel that previously had a name', () => { + renderModal({ channel: buildChannel({ name: 'Original' }) }); + + fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), ''); + + expect( + screen.getByTestId('channel-details-edit-confirm-button').props.accessibilityState, + ).toMatchObject({ disabled: false }); + }); + + it('keeps confirm disabled when the trimmed value matches the initial name', () => { + renderModal({ channel: buildChannel({ name: 'Original' }) }); + + fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), ' Original '); + + expect( + screen.getByTestId('channel-details-edit-confirm-button').props.accessibilityState, + ).toMatchObject({ disabled: true }); + }); + + it('passes the trimmed name to updateName when the user confirms', async () => { + renderModal({ channel: buildChannel({ name: 'Original' }) }); + + fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), ' Renamed '); + + await act(async () => { + fireEvent.press(screen.getByTestId('channel-details-edit-confirm-button')); + await Promise.resolve(); + }); + + expect(updateNameSpy).toHaveBeenCalledWith( + 'Renamed', + expect.objectContaining({ onSuccess: expect.any(Function) }), + ); + }); + + it('passes an empty string to updateName when the user clears and confirms', async () => { + renderModal({ channel: buildChannel({ name: 'Original' }) }); + + fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), ''); + + await act(async () => { + fireEvent.press(screen.getByTestId('channel-details-edit-confirm-button')); + await Promise.resolve(); + }); + + expect(updateNameSpy).toHaveBeenCalledWith( + '', + expect.objectContaining({ onSuccess: expect.any(Function) }), + ); + }); + + it('closes the modal after updateName invokes onSuccess', async () => { + const onClose = jest.fn(); + renderModal({ channel: buildChannel({ name: 'Original' }), onClose }); + + fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), 'Renamed'); + + await act(async () => { + fireEvent.press(screen.getByTestId('channel-details-edit-confirm-button')); + await Promise.resolve(); + }); + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); + + it('keeps the modal open and re-enables confirm when updateName does not invoke onSuccess', async () => { + updateNameSpy.mockResolvedValueOnce(undefined); + const onClose = jest.fn(); + renderModal({ channel: buildChannel({ name: 'Original' }), onClose }); + + fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), 'Renamed'); + + await act(async () => { + fireEvent.press(screen.getByTestId('channel-details-edit-confirm-button')); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(onClose).not.toHaveBeenCalled(); + expect( + screen.getByTestId('channel-details-edit-confirm-button').props.accessibilityState, + ).toMatchObject({ disabled: false, busy: false }); + }); + + it('marks confirm as busy while updateName is in flight', async () => { + let releaseUpdate: (() => void) | undefined; + updateNameSpy.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseUpdate = resolve; + }), + ); + renderModal({ channel: buildChannel({ name: 'Original' }) }); + + fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), 'Renamed'); + + await act(async () => { + fireEvent.press(screen.getByTestId('channel-details-edit-confirm-button')); + await Promise.resolve(); + }); + + expect( + screen.getByTestId('channel-details-edit-confirm-button').props.accessibilityState, + ).toMatchObject({ busy: true, disabled: true }); + + await act(async () => { + releaseUpdate?.(); + await Promise.resolve(); + }); + }); + + it('invokes onClose when the user taps the close button', () => { + const onClose = jest.fn(); + renderModal({ channel: buildChannel({ name: 'Original' }), onClose }); + + fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), 'Renamed'); + fireEvent.press(screen.getByLabelText('a11y/Close')); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('renders nothing when the channel has no cid (no notification host id)', () => { + renderModal({ channel: buildChannel({ cid: '' }) }); + + expect(screen.queryByTestId('channel-details-edit-confirm-button')).toBeNull(); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelEditImageSheet.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelEditImageSheet.test.tsx new file mode 100644 index 0000000000..9572bd6571 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelEditImageSheet.test.tsx @@ -0,0 +1,178 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; + +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 { ChannelDetailsActionItemProps } from '../components/ChannelDetailsActionItem'; +import { ChannelEditImageSheet } from '../components/ChannelEditImageSheet'; + +jest.mock('../../UIComponents/BottomSheetModal', () => { + const React = require('react'); + const { + BottomSheetProvider, + } = require('../../../contexts/bottomSheetContext/BottomSheetContext'); + // Emulate the real modal: both `close` and `dismiss` run `onClose` and then the + // optional finished-callback, and the modal supplies the BottomSheetContext that + // `ChannelEditImageSheet` reads `close`/`dismiss` from. + return { + BottomSheetModal: ({ + children, + onClose, + visible, + }: { + children: React.ReactNode; + onClose: () => void; + visible: boolean; + }) => { + if (!visible) { + return null; + } + const runClose = (callback?: () => void) => { + onClose(); + callback?.(); + }; + return ( + + {children} + + ); + }, + }; +}); + +type Probe = ChannelDetailsActionItemProps & { testID?: string }; + +const probeCalls: Probe[] = []; +const ActionItemProbe = (props: Probe) => { + probeCalls.push(props); + return ( + + {props.label} + + ); +}; + +const renderSheet = ({ + onClose = jest.fn(), + onSelectCamera = jest.fn(), + onSelectLibrary = jest.fn(), + onSelectReset, + visible = true, +}: { + onClose?: () => void; + onSelectCamera?: () => void; + onSelectLibrary?: () => void; + onSelectReset?: () => void; + visible?: boolean; +} = {}) => + render( + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + + + + , + ); + +describe('ChannelEditImageSheet', () => { + beforeEach(() => { + probeCalls.length = 0; + }); + + it('renders the localized header title', () => { + renderSheet(); + + expect(screen.getByText('Edit Group Picture')).toBeTruthy(); + }); + + it('renders only Take Photo and Choose Image rows when onSelectReset is omitted', () => { + renderSheet(); + + expect(probeCalls.map((p) => p.label)).toEqual(['Take Photo', 'Choose Image']); + expect(probeCalls.every((p) => !p.destructive)).toBe(true); + }); + + it('renders the destructive Reset Picture row when onSelectReset is provided', () => { + renderSheet({ onSelectReset: jest.fn() }); + + expect(probeCalls.map((p) => p.label)).toEqual(['Take Photo', 'Choose Image', 'Reset Picture']); + const byTestID = Object.fromEntries(probeCalls.map((p) => [p.testID, p.destructive])); + expect(byTestID['channel-edit-picture-take-photo']).toBeFalsy(); + expect(byTestID['channel-edit-picture-choose-image']).toBeFalsy(); + expect(byTestID['channel-edit-picture-reset']).toBe(true); + }); + + it('closes the sheet and invokes onSelectCamera when Take Photo is pressed', () => { + const onClose = jest.fn(); + const onSelectCamera = jest.fn(); + renderSheet({ onClose, onSelectCamera }); + + fireEvent.press(screen.getByTestId('channel-edit-picture-take-photo')); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSelectCamera).toHaveBeenCalledTimes(1); + }); + + it('closes the sheet and invokes onSelectLibrary when Choose Image is pressed', () => { + const onClose = jest.fn(); + const onSelectLibrary = jest.fn(); + renderSheet({ onClose, onSelectLibrary }); + + fireEvent.press(screen.getByTestId('channel-edit-picture-choose-image')); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSelectLibrary).toHaveBeenCalledTimes(1); + }); + + it('closes the sheet and invokes onSelectReset when Reset Picture is pressed', () => { + const onClose = jest.fn(); + const onSelectReset = jest.fn(); + renderSheet({ onClose, onSelectReset }); + + fireEvent.press(screen.getByTestId('channel-edit-picture-reset')); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSelectReset).toHaveBeenCalledTimes(1); + }); + + it('invokes onClose when the header close button is pressed', () => { + const onClose = jest.fn(); + renderSheet({ onClose }); + + fireEvent.press(screen.getByTestId('channel-edit-picture-sheet-close-button')); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('renders nothing when visible is false', () => { + const { toJSON } = renderSheet({ visible: false }); + + expect(toJSON()).toBeNull(); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/ChannelEditName.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/ChannelEditName.test.tsx new file mode 100644 index 0000000000..e6743f7ea8 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/ChannelEditName.test.tsx @@ -0,0 +1,93 @@ +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +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 { ChannelEditName } from '../components/ChannelEditName'; + +const buildChannel = (overrides?: { name?: string }): Channel => + ({ + cid: 'messaging:test', + data: { + name: overrides && 'name' in overrides ? overrides.name : 'Original', + }, + on: () => ({ unsubscribe: () => undefined }), + state: { members: {} }, + }) as unknown as Channel; + +const renderComponent = ({ + channel, + onNameChange = jest.fn(), +}: { + channel: Channel; + onNameChange?: (name: string) => void; +}) => + render( + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + ({ unsubscribe: () => undefined }), userID: 'me' } } as never + } + > + + + + + + , + ); + +describe('ChannelEditName', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the input pre-filled with the channel name', () => { + renderComponent({ channel: buildChannel({ name: 'Original' }) }); + + expect(screen.getByTestId('channel-edit-name-input').props.value).toBe('Original'); + }); + + it('renders the input empty when the channel has no name', () => { + renderComponent({ channel: buildChannel({ name: undefined }) }); + + expect(screen.getByTestId('channel-edit-name-input').props.value).toBe(''); + }); + + it('fires onNameChange with the typed value', () => { + const onNameChange = jest.fn(); + renderComponent({ channel: buildChannel({ name: 'Original' }), onNameChange }); + + fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), 'Renamed'); + + expect(onNameChange).toHaveBeenLastCalledWith('Renamed'); + }); + + it('fires onNameChange with an empty string when the user clears the input', () => { + const onNameChange = jest.fn(); + renderComponent({ channel: buildChannel({ name: 'Original' }), onNameChange }); + + fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), ''); + + expect(onNameChange).toHaveBeenLastCalledWith(''); + }); + + it('does not fire onNameChange on initial mount', () => { + const onNameChange = jest.fn(); + renderComponent({ channel: buildChannel({ name: 'Original' }), onNameChange }); + + expect(onNameChange).not.toHaveBeenCalled(); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/members/AddMemberSearchResultItem.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/members/AddMemberSearchResultItem.test.tsx new file mode 100644 index 0000000000..8278b5c502 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/members/AddMemberSearchResultItem.test.tsx @@ -0,0 +1,84 @@ +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { ThemeProvider } from '../../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../../contexts/translationContext/TranslationContext'; +import { generateUser } from '../../../../mock-builders/generator/user'; +import { AddMemberSearchResultItem } from '../../components/members/AddMemberSearchResultItem'; + +const renderRow = (props: React.ComponentProps) => + 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('AddMemberSearchResultItem', () => { + it('renders a selectable row with unselected accessibility state by default', () => { + const user = generateUser({ id: 'u-1', name: 'Alice' }); + renderRow({ isAlreadyMember: false, onPress: jest.fn(), selected: false, user }); + + const row = screen.getByTestId('channel-add-members-row-u-1'); + expect(row.props.accessibilityState).toMatchObject({ disabled: false, selected: false }); + expect(screen.getByLabelText('a11y/Select Alice')).toBeTruthy(); + expect(screen.queryByTestId('channel-add-members-row-u-1-member-label')).toBeNull(); + }); + + it('flips accessibilityState.selected when selected is true', () => { + const user = generateUser({ id: 'u-1', name: 'Alice' }); + renderRow({ isAlreadyMember: false, onPress: jest.fn(), selected: true, user }); + + expect( + screen.getByTestId('channel-add-members-row-u-1').props.accessibilityState, + ).toMatchObject({ disabled: false, selected: true }); + }); + + it('calls onPress when the row is pressed', () => { + const onPress = jest.fn(); + const user = generateUser({ id: 'u-1', name: 'Alice' }); + renderRow({ isAlreadyMember: false, onPress, selected: false, user }); + + fireEvent.press(screen.getByTestId('channel-add-members-row-u-1')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('falls back to the user id when no name is set', () => { + const user = generateUser({ id: 'u-no-name', name: undefined }); + renderRow({ isAlreadyMember: false, onPress: jest.fn(), selected: false, user }); + + expect(screen.getByLabelText('a11y/Select u-no-name')).toBeTruthy(); + expect(screen.getByText('u-no-name')).toBeTruthy(); + }); + + describe('when isAlreadyMember is true', () => { + it('renders the disabled variant with a member label and no button role', () => { + const user = generateUser({ id: 'u-2', name: 'Bob' }); + renderRow({ isAlreadyMember: true, onPress: jest.fn(), selected: false, user }); + + const row = screen.getByTestId('channel-add-members-row-u-2'); + expect(row.props.accessibilityState).toMatchObject({ disabled: true, selected: false }); + expect(row.props.accessibilityRole).toBeUndefined(); + expect(screen.getByTestId('channel-add-members-row-u-2-member-label')).toBeTruthy(); + expect(screen.getByText('Already a member')).toBeTruthy(); + expect(screen.getByLabelText('a11y/Bob is already a member')).toBeTruthy(); + }); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/members/ChannelAddMembers.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/members/ChannelAddMembers.test.tsx new file mode 100644 index 0000000000..4e23e81be1 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/members/ChannelAddMembers.test.tsx @@ -0,0 +1,214 @@ +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; +import type { Channel, UserResponse } from 'stream-chat'; + +import { ChannelDetailsContextProvider } from '../../../../contexts/channelDetailsContext/channelDetailsContext'; +import { ThemeProvider } from '../../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../../contexts/translationContext/TranslationContext'; +import { generateUser } from '../../../../mock-builders/generator/user'; +import type { AddMemberSearchResultItemProps } from '../../components/members/AddMemberSearchResultItem'; +import { ChannelAddMembers } from '../../components/members/ChannelAddMembers'; +import { + type UseChannelAddMembersResult, + useChannelAddMembers, +} from '../../hooks/members/useChannelAddMembers'; + +const mockRowProbe: AddMemberSearchResultItemProps[] = []; + +jest.mock('../../hooks/members/useChannelAddMembers', () => ({ + useChannelAddMembers: jest.fn(), +})); + +jest.mock('../../../UIComponents/SearchInput', () => { + const ReactLib = require('react'); + const { Text } = require('react-native'); + return { + SearchInput: ({ + onChangeText, + onClear, + }: { + onChangeText: (t: string) => void; + onClear: () => void; + }) => + ReactLib.createElement( + ReactLib.Fragment, + null, + ReactLib.createElement( + Text, + { onPress: () => onChangeText('query'), testID: 'search-change' }, + 'change', + ), + ReactLib.createElement(Text, { onPress: onClear, testID: 'search-clear' }, 'clear'), + ), + }; +}); + +jest.mock('../../components/members/AddMemberSearchResultItem', () => { + const ReactLib = require('react'); + const { Text } = require('react-native'); + return { + AddMemberSearchResultItem: (props: AddMemberSearchResultItemProps) => { + mockRowProbe.push(props); + return ReactLib.createElement( + Text, + { onPress: () => props.onPress(props.user), testID: `add-member-row-${props.user.id}` }, + props.user.id, + ); + }, + }; +}); + +const channel = { + cid: 'messaging:test', + on: () => ({ unsubscribe: () => undefined }), +} as unknown as Channel; + +const baseHookResult = (): UseChannelAddMembersResult => ({ + clearSearch: jest.fn(), + hasMore: true, + isAlreadyMember: jest.fn(() => false), + isSelected: jest.fn(() => false), + loading: false, + loadMore: jest.fn(), + onChangeSearchText: jest.fn(), + results: [], + selectedUsers: [], + toggleUser: jest.fn(), +}); + +const mockHook = (overrides: Partial = {}) => { + const value = { ...baseHookResult(), ...overrides }; + (useChannelAddMembers as jest.Mock).mockReturnValue(value); + return value; +}; + +const tree = ( + props: { + onSelectionChange?: (selectedUsers: UserResponse[]) => void; + additionalFlatListProps?: object; + } = {}, +) => ( + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + + + + +); + +const renderComponent = ( + props: { + onSelectionChange?: (selectedUsers: UserResponse[]) => void; + additionalFlatListProps?: object; + } = {}, +) => render(tree(props)); + +describe('ChannelAddMembers', () => { + beforeEach(() => { + mockRowProbe.length = 0; + mockHook(); + }); + + afterEach(() => jest.clearAllMocks()); + + it('wires the search input to the hook callbacks', () => { + const hook = mockHook(); + renderComponent(); + + fireEvent.press(screen.getByTestId('search-change')); + expect(hook.onChangeSearchText).toHaveBeenCalledWith('query'); + + fireEvent.press(screen.getByTestId('search-clear')); + expect(hook.clearSearch).toHaveBeenCalledTimes(1); + }); + + it('renders a row per result and forwards selection/membership flags and toggle handler', () => { + const userA = generateUser({ id: 'u-1' }); + const userB = generateUser({ id: 'u-2' }); + const isSelected = jest.fn((id: string) => id === 'u-2'); + const isAlreadyMember = jest.fn((id: string) => id === 'u-1'); + const toggleUser = jest.fn(); + mockHook({ + isAlreadyMember, + isSelected, + results: [userA, userB], + toggleUser, + }); + + renderComponent(); + + expect(mockRowProbe).toHaveLength(2); + expect(mockRowProbe[0]).toMatchObject({ isAlreadyMember: true, selected: false }); + expect(mockRowProbe[1]).toMatchObject({ isAlreadyMember: false, selected: true }); + + fireEvent.press(screen.getByTestId('add-member-row-u-1')); + expect(toggleUser).toHaveBeenCalledWith(expect.objectContaining({ id: 'u-1' })); + }); + + it('shows the loading skeleton while loading and the empty state when no results', () => { + mockHook({ loading: true }); + const { rerender } = renderComponent(); + expect(screen.getByTestId('user-list-loading-skeleton')).toBeTruthy(); + + mockHook({ loading: false, results: [] }); + rerender(tree()); + + expect(screen.queryByTestId('user-list-loading-skeleton')).toBeNull(); + expect(screen.getByText('No user found')).toBeTruthy(); + }); + + it('renders the loading-more indicator only while loading with existing results', () => { + mockHook({ loading: true, results: [generateUser({ id: 'alice' })] }); + renderComponent(); + expect(screen.UNSAFE_getByType(require('react-native').ActivityIndicator)).toBeTruthy(); + }); + + it('wires onEndReached and the end-reached threshold on the list', () => { + const loadMore = jest.fn(); + mockHook({ loadMore }); + renderComponent(); + + const list = screen.getByTestId('channel-add-members-list'); + expect(list.props.onEndReachedThreshold).toBe(0.2); + list.props.onEndReached(); + expect(loadMore).toHaveBeenCalledTimes(1); + }); + + it('forwards additionalFlatListProps to the underlying list', () => { + mockHook(); + renderComponent({ additionalFlatListProps: { bounces: false, testID: 'custom-list' } }); + + const list = screen.getByTestId('custom-list'); + expect(list.props.bounces).toBe(false); + expect(screen.queryByTestId('channel-add-members-list')).toBeNull(); + }); + + it('emits onSelectionChange only when the selection reference changes (not on mount)', () => { + const onSelectionChange = jest.fn(); + const selectionA: never[] = []; + mockHook({ selectedUsers: selectionA }); + + const { rerender } = render(tree({ onSelectionChange })); + + expect(onSelectionChange).not.toHaveBeenCalled(); + + const selectionB = [generateUser({ id: 'u-1' })]; + mockHook({ selectedUsers: selectionB as never }); + rerender(tree({ onSelectionChange })); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onSelectionChange).toHaveBeenCalledWith(selectionB); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/members/ChannelAddMembersModal.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/members/ChannelAddMembersModal.test.tsx new file mode 100644 index 0000000000..8fcf525480 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/members/ChannelAddMembersModal.test.tsx @@ -0,0 +1,252 @@ +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/actions/useChannelActions'; +import { generateMember } from '../../../../mock-builders/generator/member'; +import { generateUser } from '../../../../mock-builders/generator/user'; +import type { ChannelAddMembersProps } from '../../components/members/ChannelAddMembers'; +import { ChannelAddMembersModal } from '../../components/members/ChannelAddMembersModal'; + +jest.mock('../../../../hooks/actions/useChannelActions'); +const mockedUseChannelActions = jest.mocked(useChannelActions); + +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 renderModal = ({ + capabilities, + channel, + onClose = jest.fn(), + visible = true, +}: { + channel: Channel; + capabilities?: Partial; + onClose?: () => void; + visible?: boolean; +}) => + 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('ChannelAddMembersModal', () => { + let addMembersSpy: jest.Mock; + + beforeEach(() => { + 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('keeps the confirm button disabled until ChannelAddMembers reports a selection', () => { + const channel = buildChannel(makeMembers(3), 3); + + renderModal({ channel }); + + 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 () => { + const channel = buildChannel(makeMembers(3), 3); + const onClose = jest.fn(); + + renderModal({ channel, onClose }); + + 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(onClose).toHaveBeenCalledTimes(1)); + }); + + it('resets the selection and calls onClose when closed via the X', () => { + const channel = buildChannel(makeMembers(3), 3); + const onClose = jest.fn(); + + const { rerender } = renderModal({ channel, onClose }); + + fireEvent.press(screen.getByTestId('probe-select-one')); + expect( + screen.getByTestId('channel-details-add-members-confirm-button').props.accessibilityState, + ).toMatchObject({ disabled: false }); + + fireEvent.press(screen.getByLabelText('a11y/Close')); + expect(onClose).toHaveBeenCalledTimes(1); + + // Simulate the parent hiding then re-opening the modal; the selection must reset. + rerender( + + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + + + + + + + + + , + ); + + expect( + screen.getByTestId('channel-details-add-members-confirm-button').props.accessibilityState, + ).toMatchObject({ disabled: true }); + }); + + it('keeps the sheet open and re-enables confirm when addMembers does not invoke onSuccess', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + addMembersSpy.mockResolvedValueOnce(undefined); + const channel = buildChannel(makeMembers(3), 3); + const onClose = jest.fn(); + + renderModal({ channel, onClose }); + + 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(onClose).not.toHaveBeenCalled(); + 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__/members/ChannelAllMembersModal.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/members/ChannelAllMembersModal.test.tsx new file mode 100644 index 0000000000..f29accd61f --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/members/ChannelAllMembersModal.test.tsx @@ -0,0 +1,159 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { NotificationManager } from 'stream-chat'; +import type { Channel, ChannelMemberResponse } 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 { generateMember } from '../../../../mock-builders/generator/member'; +import { generateUser } from '../../../../mock-builders/generator/user'; +import { ChannelAllMembersModal } from '../../components/members/ChannelAllMembersModal'; +import * as useChannelDetailsMembersPreviewModule from '../../hooks/useChannelDetailsMembersPreview'; + +const MemberListProbe = () => full-member-list; + +const buildChannel = ( + members: ChannelMemberResponse[], + memberCount?: number, + overrides?: Partial, +): Channel => + ({ + 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 renderModal = ({ + capabilities, + channel, + onAddMembersPress = jest.fn(), + onClose = jest.fn(), + visible = true, +}: { + channel: Channel; + capabilities?: Partial; + onAddMembersPress?: () => void; + onClose?: () => void; + visible?: boolean; +}) => + 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('ChannelAllMembersModal', () => { + let previewSpy: jest.SpyInstance; + + beforeEach(() => { + previewSpy = jest.spyOn( + useChannelDetailsMembersPreviewModule, + 'useChannelDetailsMembersPreview', + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders the member list and closes when the close button is pressed', () => { + previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); + const channel = buildChannel(makeMembers(12), 12); + const onClose = jest.fn(); + + renderModal({ channel, onClose }); + + expect(screen.getByTestId('member-list-probe')).toBeTruthy(); + + fireEvent.press(screen.getByLabelText('a11y/Close')); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('hides the add-members button when the user lacks update-channel-members capability', () => { + previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); + const channel = buildChannel(makeMembers(12), 12); + + renderModal({ channel }); + + expect(screen.queryByTestId('channel-details-member-list-add-button')).toBeNull(); + }); + + it('shows the add-members button and invokes onAddMembersPress when pressed', () => { + previewSpy.mockReturnValue({ hasMore: true, total: 12, visible: makeMembers(5) }); + const channel = buildChannel(makeMembers(12), 12); + const onAddMembersPress = jest.fn(); + + renderModal({ + capabilities: { updateChannelMembers: true }, + channel, + onAddMembersPress, + }); + + fireEvent.press(screen.getByTestId('channel-details-member-list-add-button')); + + expect(onAddMembersPress).toHaveBeenCalledTimes(1); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/members/ChannelMemberActionsSheet.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/members/ChannelMemberActionsSheet.test.tsx new file mode 100644 index 0000000000..53b567a4d7 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/members/ChannelMemberActionsSheet.test.tsx @@ -0,0 +1,194 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import { fireEvent, render, screen } 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 type { ChannelMemberActionItem } from '../../../../hooks/actions/useChannelMemberActionItems'; +import * as useChannelMemberActionItemsModule from '../../../../hooks/actions/useChannelMemberActionItems'; +import { generateMember } from '../../../../mock-builders/generator/member'; +import { generateUser } from '../../../../mock-builders/generator/user'; +import type { ChannelDetailsActionItemProps } from '../../components/ChannelDetailsActionItem'; +import { ChannelMemberActionsSheet } from '../../components/members/ChannelMemberActionsSheet'; + +jest.mock('../../../UIComponents/BottomSheetModal', () => { + const React = require('react'); + return { + BottomSheetModal: ({ children, visible }: { children: React.ReactNode; visible: boolean }) => + visible ? <>{children} : null, + }; +}); + +const NoopIcon = () => null; + +const buildItem = (overrides: Partial = {}): ChannelMemberActionItem => ({ + action: jest.fn(), + Icon: NoopIcon, + id: 'muteUser', + label: 'Mute User', + type: 'standard', + ...overrides, +}); + +const channel = { + cid: 'messaging:test', + on: () => ({ unsubscribe: () => undefined }), +} as unknown as Channel; + +const member: ChannelMemberResponse = generateMember({ + user: generateUser({ id: 'maya', name: 'Maya Ross', online: true }), +}); + +type Probe = ChannelDetailsActionItemProps & { testID?: string }; + +const probeCalls: Probe[] = []; +const ActionItemProbe = (props: Probe) => { + probeCalls.push(props); + return ( + + {props.label} + + ); +}; + +const renderSheet = ({ + onClose = jest.fn(), + visible = true, +}: { onClose?: () => void; visible?: boolean } = {}) => + render( + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + ({ unsubscribe: () => undefined }), + userID: 'me', + }, + } as never + } + > + + + + + + + + , + ); + +describe('ChannelMemberActionsSheet', () => { + let actionsSpy: jest.SpyInstance; + + beforeEach(() => { + probeCalls.length = 0; + actionsSpy = jest + .spyOn(useChannelMemberActionItemsModule, 'useChannelMemberActionItems') + .mockReturnValue([]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders the member name and status in the header', () => { + actionsSpy.mockReturnValue([]); + renderSheet(); + + expect(screen.getByText('Maya Ross')).toBeTruthy(); + expect(screen.getByText('Online')).toBeTruthy(); + }); + + it('renders one ChannelDetailsActionItem per item returned by the hook', () => { + const muteItem = buildItem({ id: 'muteUser', label: 'Mute User' }); + const blockItem = buildItem({ id: 'block', label: 'Block User', type: 'destructive' }); + actionsSpy.mockReturnValue([muteItem, blockItem]); + + renderSheet(); + + expect(probeCalls).toHaveLength(2); + expect(probeCalls.map((p) => p.label)).toEqual(['Mute User', 'Block User']); + }); + + it('flags destructive items', () => { + const muteItem = buildItem({ id: 'muteUser', label: 'Mute User' }); + const blockItem = buildItem({ id: 'block', label: 'Block User', type: 'destructive' }); + actionsSpy.mockReturnValue([muteItem, blockItem]); + + renderSheet(); + + const byId = Object.fromEntries(probeCalls.map((p) => [p.testID, p.destructive])); + expect(byId['channel-details-member-action-muteUser']).toBe(false); + expect(byId['channel-details-member-action-block']).toBe(true); + }); + + it('invokes the action and closes when an item is pressed', () => { + const action = jest.fn(); + const onClose = jest.fn(); + actionsSpy.mockReturnValue([buildItem({ action, id: 'muteUser', label: 'Mute User' })]); + + renderSheet({ onClose }); + fireEvent.press(screen.getByTestId('channel-details-member-action-muteUser')); + + expect(action).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('forwards the channel + member + getChannelMemberActionItems to the hook', () => { + const getChannelMemberActionItems = jest.fn(({ defaultItems }) => defaultItems); + actionsSpy.mockReturnValue([]); + + render( + ({ unsubscribe: () => undefined }) }, + } as never + } + > + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + + + + + + + , + , + ); + + expect(actionsSpy).toHaveBeenCalledWith({ + channel, + getChannelMemberActionItems, + member, + }); + }); + + it('renders nothing when visible is false', () => { + actionsSpy.mockReturnValue([buildItem()]); + const { toJSON } = renderSheet({ visible: false }); + expect(toJSON()).toBeNull(); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/members/ChannelMemberItem.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/members/ChannelMemberItem.test.tsx new file mode 100644 index 0000000000..61ac9d086e --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/members/ChannelMemberItem.test.tsx @@ -0,0 +1,200 @@ +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; +import Dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import type { Channel, ChannelMemberResponse } from 'stream-chat'; + +import { ThemeProvider } from '../../../../contexts'; +import { ChannelDetailsContextProvider } from '../../../../contexts/channelDetailsContext/channelDetailsContext'; +import { ChatContext } from '../../../../contexts/chatContext/ChatContext'; +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 type { GetMemberRoleLabel } from '../../ChannelDetailsScreen'; +import { ChannelMemberItem } from '../../components/members/ChannelMemberItem'; + +Dayjs.extend(relativeTime); + +const memberFor = (overrides: Partial> = {}) => + generateMember({ + user: generateUser({ + id: 'alice', + name: 'Alice', + online: false, + ...overrides, + }), + }); + +const defaultChannel = { + cid: 'messaging:test', + data: { created_by: { id: 'creator' } }, + on: () => ({ unsubscribe: () => undefined }), +} as unknown as Channel; + +const renderRow = ({ + channel = defaultChannel, + currentUserId, + getMemberRoleLabel, + mutedUsers = [], + ...props +}: React.ComponentProps & { + channel?: Channel; + currentUserId?: string; + getMemberRoleLabel?: GetMemberRoleLabel; + mutedUsers?: Array<{ target: { id: string }; user: { id: string } }>; +}) => + render( + + ) => { + if (key === 'timestamp/UserActivityStatus' && options && 'timestamp' in options) { + return `Last seen ${Dayjs(options.timestamp as Date).fromNow()}`; + } + return key; + }) as never, + tDateTimeParser: (input) => Dayjs(input), + userLanguage: 'en', + }} + > + ({ unsubscribe: () => undefined }), + userID: currentUserId, + }, + } as never + } + > + + + + + + , + ); + +describe('ChannelMemberItem 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({ currentUserId: 'alice', member: memberFor() }); + expect(screen.getByLabelText('You, Offline')).toBeTruthy(); + }); + + it('includes the role label in the accessible label between name and status', () => { + renderRow({ + getMemberRoleLabel: () => 'Admin', + member: memberFor(), + }); + expect(screen.getByLabelText('Alice, Admin, Offline')).toBeTruthy(); + }); + + it('includes "Muted" in the accessible label when the member is muted', () => { + renderRow({ + currentUserId: 'me', + member: memberFor(), + mutedUsers: [{ target: { id: 'alice' }, user: { id: 'me' } }], + }); + expect(screen.getByLabelText('Alice, Muted, Offline')).toBeTruthy(); + }); +}); + +describe('ChannelMemberItem muted indicator', () => { + it('renders the muted icon when the member is muted', () => { + renderRow({ + currentUserId: 'me', + member: memberFor(), + mutedUsers: [{ target: { id: 'alice' }, user: { id: 'me' } }], + }); + expect(screen.getByTestId('channel-member-muted-indicator')).toBeTruthy(); + }); + + it('does not render the muted icon when the member is not muted', () => { + renderRow({ member: memberFor() }); + expect(screen.queryByTestId('channel-member-muted-indicator')).toBeNull(); + }); +}); + +describe('ChannelMemberItem large variant', () => { + it('renders the role label in the large profile header', () => { + renderRow({ + getMemberRoleLabel: () => 'Admin', + member: memberFor(), + size: 'lg', + }); + expect(screen.getByText('Admin')).toBeTruthy(); + }); +}); + +describe('ChannelMemberItem 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(); + }); +}); + +describe('ChannelMemberItem role label rendering', () => { + it('renders the role label string returned by useMemberRoleLabel', () => { + renderRow({ + getMemberRoleLabel: () => 'Admin', + member: memberFor(), + }); + expect(screen.getByText('Admin')).toBeTruthy(); + }); + + it('renders no role label when the hook returns null', () => { + renderRow({ + getMemberRoleLabel: () => null, + member: memberFor(), + }); + expect(screen.queryByText('Admin')).toBeNull(); + expect(screen.queryByText('Moderator')).toBeNull(); + expect(screen.queryByText('Owner')).toBeNull(); + }); +}); + +describe('ChannelMemberItem press behavior', () => { + it('calls onPress when the row is pressed', () => { + const onPress = jest.fn(); + renderRow({ member: memberFor(), onPress, testID: 'member-row' }); + + fireEvent.press(screen.getByTestId('member-row')); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('renders a non-interactive row when no onPress is provided', () => { + renderRow({ member: memberFor(), testID: 'member-row' }); + + const row = screen.getByTestId('member-row'); + expect(row.props.accessibilityRole).toBeUndefined(); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/members/ChannelMemberList.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/members/ChannelMemberList.test.tsx new file mode 100644 index 0000000000..41a073c100 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/members/ChannelMemberList.test.tsx @@ -0,0 +1,249 @@ +import React from 'react'; +import { ActivityIndicator, type FlatListProps as RNFlatListProps, Text } from 'react-native'; + +import { act, 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 type { ChannelMemberActionsSheetProps } from '../../components/members/ChannelMemberActionsSheet'; +import type { ChannelMemberItemProps } from '../../components/members/ChannelMemberItem'; +import { ChannelMemberList } from '../../components/members/ChannelMemberList'; +import { useChannelAllMembers } from '../../hooks/members/useChannelAllMembers'; + +type FlatListProps = RNFlatListProps; + +const mockFlatList = jest.fn((_props: FlatListProps) => null); + +jest.mock('../../hooks/members/useChannelAllMembers', () => ({ + useChannelAllMembers: jest.fn(), +})); + +jest.mock('react-native', () => { + const actual = jest.requireActual('react-native'); + + return new Proxy(actual, { + get(target, prop, receiver) { + if (prop === 'FlatList') { + return (props: FlatListProps) => mockFlatList(props); + } + + return Reflect.get(target, prop, receiver); + }, + }); +}); + +const channel = { + cid: 'messaging:test', + on: () => ({ unsubscribe: () => undefined }), +} as unknown as Channel; + +type HookResult = ReturnType; + +const baseHookResult = (): HookResult => ({ + hasMore: false, + loading: false, + loadMore: jest.fn(), + results: [], +}); + +const mockHook = (overrides: Partial = {}) => { + const value = { ...baseHookResult(), ...overrides }; + (useChannelAllMembers as jest.Mock).mockReturnValue(value); + return value; +}; + +const itemProbeCalls: ChannelMemberItemProps[] = []; +const MemberListItemProbe = (props: ChannelMemberItemProps) => { + itemProbeCalls.push(props); + return {props.member.user?.name}; +}; + +const sheetProbeCalls: ChannelMemberActionsSheetProps[] = []; +const MemberActionsSheetProbe = (props: ChannelMemberActionsSheetProps) => { + sheetProbeCalls.push(props); + return {props.member.user?.id ?? ''}; +}; + +const renderList = ({ + additionalFlatListProps, + currentUserId, + onMemberPress, +}: { + additionalFlatListProps?: Partial; + currentUserId?: string; + onMemberPress?: (member: ChannelMemberResponse) => void; +} = {}) => + render( + + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + ({ unsubscribe: () => undefined }), + userID: currentUserId, + }, + } as never + } + > + + + + + + + + , + ); + +const latestListProps = () => { + const calls = mockFlatList.mock.calls; + return calls[calls.length - 1]?.[0]; +}; + +describe('ChannelMemberList', () => { + beforeEach(() => { + mockFlatList.mockClear(); + itemProbeCalls.length = 0; + sheetProbeCalls.length = 0; + mockHook(); + }); + + afterEach(() => jest.clearAllMocks()); + + it('renders the loading skeleton while loading with no results yet', () => { + mockHook({ loading: true, results: [] }); + + const list = renderList(); + + expect(list.getByTestId('member-list-loading-skeleton')).toBeTruthy(); + expect(mockFlatList).not.toHaveBeenCalled(); + }); + + it('renders the list (not the skeleton) once results exist even while loading', () => { + mockHook({ + loading: true, + results: [generateMember({ user: generateUser({ id: 'alice' }) })], + }); + + const list = renderList(); + + expect(list.queryByTestId('member-list-loading-skeleton')).toBeNull(); + expect(mockFlatList).toHaveBeenCalled(); + }); + + it('feeds the hook results into the flat list with a stable keyExtractor', () => { + const alice = generateMember({ user: generateUser({ id: 'alice', name: 'Alice' }) }); + const bob = generateMember({ user: generateUser({ id: 'bob', name: 'Bob' }) }); + mockHook({ results: [alice, bob] }); + + renderList(); + + const props = latestListProps(); + expect((props?.data as ChannelMemberResponse[]).map((m) => m.user?.id)).toEqual([ + 'alice', + 'bob', + ]); + expect(props?.keyExtractor?.(alice, 0)).toBe('alice'); + }); + + it('wires onEndReached to loadMore (with threshold) only when there is more to load', () => { + const loadMore = jest.fn(); + mockHook({ hasMore: true, loadMore, results: [] }); + + renderList(); + + const props = latestListProps(); + expect(props?.onEndReachedThreshold).toBe(0.2); + expect(props?.onEndReached).toBe(loadMore); + }); + + it('omits onEndReached when there is no more to load', () => { + mockHook({ hasMore: false, results: [] }); + + renderList(); + + expect(latestListProps()?.onEndReached).toBeUndefined(); + }); + + it('renders a footer spinner only while loading more (loading with existing results)', () => { + const results = [generateMember({ user: generateUser({ id: 'alice' }) })]; + mockHook({ loading: true, results }); + renderList(); + const footer = latestListProps()?.ListFooterComponent as React.ReactElement; + expect(footer).not.toBeNull(); + expect(footer.type).toBe(ActivityIndicator); + + mockFlatList.mockClear(); + mockHook({ loading: false, results }); + renderList(); + expect(latestListProps()?.ListFooterComponent).toBeNull(); + }); + + it('forwards additionalFlatListProps to the underlying flat list', () => { + mockHook({ results: [generateMember({ user: generateUser({ id: 'alice' }) })] }); + + renderList({ additionalFlatListProps: { bounces: false, testID: 'custom-member-list' } }); + + const props = latestListProps(); + expect(props?.testID).toBe('custom-member-list'); + expect(props?.bounces).toBe(false); + }); + + it('opens the per-member actions sheet on press when no onMemberPress override is provided, and closes it', () => { + const bob = generateMember({ user: generateUser({ id: 'bob', name: 'Bob' }) }); + mockHook({ results: [bob] }); + + const list = renderList(); + + const { renderItem } = latestListProps() ?? {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + render((renderItem as any)({ index: 0, item: bob, separators: {} as never })); + const captured = itemProbeCalls.find((p) => p.member.user?.id === 'bob'); + + expect(list.queryByTestId('member-actions-sheet-probe')).toBeNull(); + act(() => captured?.onPress?.(bob)); + expect(list.getByTestId('member-actions-sheet-probe').props.children).toBe('bob'); + + act(() => sheetProbeCalls[sheetProbeCalls.length - 1]?.onClose?.()); + expect(list.queryByTestId('member-actions-sheet-probe')).toBeNull(); + }); + + it('calls onMemberPress instead of opening the sheet when an override is provided', () => { + const alice = generateMember({ user: generateUser({ id: 'alice', name: 'Alice' }) }); + const onMemberPress = jest.fn(); + mockHook({ results: [alice] }); + + const list = renderList({ onMemberPress }); + + const { renderItem } = latestListProps() ?? {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + render((renderItem as any)({ index: 0, item: alice, separators: {} as never })); + const captured = itemProbeCalls.find((p) => p.member.user?.id === 'alice'); + + act(() => captured?.onPress?.(alice)); + + expect(onMemberPress).toHaveBeenCalledTimes(1); + expect(onMemberPress.mock.calls[0][0].user?.id).toBe('alice'); + expect(list.queryByTestId('member-actions-sheet-probe')).toBeNull(); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/members/useChannelAddMembers.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/members/useChannelAddMembers.test.tsx new file mode 100644 index 0000000000..1f8cb43a38 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/members/useChannelAddMembers.test.tsx @@ -0,0 +1,275 @@ +import React from 'react'; + +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import type { Channel, UserResponse } from 'stream-chat'; + +import { ChatContext } from '../../../../contexts/chatContext/ChatContext'; +import { TranslationProvider } from '../../../../contexts/translationContext/TranslationContext'; +import { generateUser } from '../../../../mock-builders/generator/user'; +import { useChannelMembersState } from '../../../ChannelList/hooks/useChannelMembersState'; +import { useNotificationApi } from '../../../Notifications/hooks/useNotificationApi'; +import { useChannelAddMembers } from '../../hooks/members/useChannelAddMembers'; + +jest.mock('../../../ChannelList/hooks/useChannelMembersState', () => ({ + useChannelMembersState: jest.fn(() => ({})), +})); + +jest.mock('../../../Notifications/hooks/useNotificationApi', () => ({ + useNotificationApi: jest.fn(() => ({ addNotification: jest.fn() })), +})); + +type QueryUsersMock = jest.Mock, [unknown, unknown, unknown]>; + +const t = ((key: string, options?: Record) => { + if (options && typeof options === 'object') { + return Object.entries(options).reduce((acc, [k, v]) => acc.replace(`{{${k}}}`, String(v)), key); + } + return key; +}) as never; + +const channel = { cid: 'messaging:test' } as unknown as Channel; + +const buildUsers = (count: number, prefix = 'u') => + Array.from({ length: count }, (_, i) => + generateUser({ id: `${prefix}-${i}`, name: `User ${i}` }), + ); + +const renderUseChannelAddMembers = (queryUsers: QueryUsersMock) => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + input) as never, userLanguage: 'en' }} + > + + {children} + + + ); + return renderHook(() => useChannelAddMembers({ channel }), { wrapper }); +}; + +describe('useChannelAddMembers', () => { + let addNotification: jest.Mock; + + beforeEach(() => { + addNotification = jest.fn(); + (useNotificationApi as jest.Mock).mockReturnValue({ addNotification }); + (useChannelMembersState as jest.Mock).mockReturnValue({}); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('fetches the first page on mount with the role sort and pagination opts', async () => { + const queryUsers: QueryUsersMock = jest.fn().mockResolvedValue({ users: buildUsers(2) }); + + const { result } = renderUseChannelAddMembers(queryUsers); + + expect(result.current.loading).toBe(true); + + await waitFor(() => expect(queryUsers).toHaveBeenCalledTimes(1)); + expect(queryUsers).toHaveBeenCalledWith({}, { name: 1 }, { limit: 25, offset: 0 }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.results.map((u) => u.id)).toEqual(['u-0', 'u-1']); + }); + + it('debounces search and only queries with the latest text', async () => { + jest.useFakeTimers(); + const queryUsers: QueryUsersMock = jest.fn().mockResolvedValue({ users: [] }); + + const { result } = renderUseChannelAddMembers(queryUsers); + + // flush the (non-debounced) mount fetch + await act(async () => { + await Promise.resolve(); + }); + expect(queryUsers).toHaveBeenCalledTimes(1); + + act(() => { + result.current.onChangeSearchText('E'); + result.current.onChangeSearchText('Et'); + result.current.onChangeSearchText('Eth'); + }); + + // debounce has not fired yet + expect(queryUsers).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.advanceTimersByTime(200); + 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' } }); + }); + + it('clearSearch cancels the pending debounce and refetches with an empty query', async () => { + jest.useFakeTimers(); + const queryUsers: QueryUsersMock = jest.fn().mockResolvedValue({ users: [] }); + + const { result } = renderUseChannelAddMembers(queryUsers); + await act(async () => { + await Promise.resolve(); + }); + + act(() => result.current.onChangeSearchText('abc')); + act(() => result.current.clearSearch()); + + await act(async () => { + jest.advanceTimersByTime(200); + await Promise.resolve(); + }); + + // mount fetch + clearSearch fetch, both with the empty filter; the debounced + // autocomplete was cancelled so it never ran. + expect(queryUsers).toHaveBeenCalledTimes(2); + expect( + queryUsers.mock.calls.every(([filter]) => Object.keys(filter as object).length === 0), + ).toBe(true); + }); + + it('appends the next page on loadMore, dedupes by id, and sets hasMore=false on a short page', async () => { + const firstPage = buildUsers(25, 'page1'); + const overlap = firstPage[firstPage.length - 1]; + const secondPage = [overlap, ...buildUsers(10, 'page2')]; + const queryUsers: QueryUsersMock = jest + .fn() + .mockResolvedValueOnce({ users: firstPage }) + .mockResolvedValueOnce({ users: secondPage }); + + const { result } = renderUseChannelAddMembers(queryUsers); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.results).toHaveLength(25); + expect(result.current.hasMore).toBe(true); + + act(() => result.current.loadMore()); + + await waitFor(() => expect(queryUsers).toHaveBeenCalledTimes(2)); + expect(queryUsers).toHaveBeenNthCalledWith(2, {}, { name: 1 }, { limit: 25, offset: 25 }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + // 25 + 10 fresh (the overlapping page1 row is deduped away) + expect(result.current.results).toHaveLength(35); + expect(result.current.hasMore).toBe(false); + }); + + it('loadMore is a no-op once hasMore is false', async () => { + const queryUsers: QueryUsersMock = jest.fn().mockResolvedValue({ users: buildUsers(10) }); + + const { result } = renderUseChannelAddMembers(queryUsers); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.hasMore).toBe(false); + + act(() => result.current.loadMore()); + expect(queryUsers).toHaveBeenCalledTimes(1); + }); + + it('ignores a stale (superseded) response when a newer request has started', async () => { + let resolveFirst: ((value: { users: UserResponse[] }) => void) | undefined; + const queryUsers: QueryUsersMock = jest + .fn() + .mockReturnValueOnce( + new Promise<{ users: UserResponse[] }>((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValueOnce({ users: buildUsers(1, 'fresh') }); + + const { result } = renderUseChannelAddMembers(queryUsers); + + // start a second (newer) request before the first resolves + act(() => result.current.clearSearch()); + await waitFor(() => expect(result.current.results.map((u) => u.id)).toEqual(['fresh-0'])); + + // the stale first response resolves last and must be ignored + act(() => resolveFirst?.({ users: buildUsers(3, 'stale') })); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.results.map((u) => u.id)).toEqual(['fresh-0']); + }); + + it('reports membership via isAlreadyMember from the channel member state', async () => { + (useChannelMembersState as jest.Mock).mockReturnValue({ 'u-0': { user_id: 'u-0' } }); + const queryUsers: QueryUsersMock = jest.fn().mockResolvedValue({ users: buildUsers(2) }); + + const { result } = renderUseChannelAddMembers(queryUsers); + + await waitFor(() => expect(result.current.results).toHaveLength(2)); + expect(result.current.isAlreadyMember('u-0')).toBe(true); + expect(result.current.isAlreadyMember('u-1')).toBe(false); + }); + + it('toggleUser adds/removes selection and isSelected reflects it', async () => { + const queryUsers: QueryUsersMock = jest.fn().mockResolvedValue({ users: buildUsers(1) }); + + const { result } = renderUseChannelAddMembers(queryUsers); + await waitFor(() => expect(result.current.results).toHaveLength(1)); + + const user = result.current.results[0]; + + act(() => result.current.toggleUser(user)); + expect(result.current.isSelected('u-0')).toBe(true); + expect(result.current.selectedUsers).toHaveLength(1); + + act(() => result.current.toggleUser(user)); + expect(result.current.isSelected('u-0')).toBe(false); + expect(result.current.selectedUsers).toHaveLength(0); + }); + + it('toggleUser ignores rows without an id', async () => { + const queryUsers: QueryUsersMock = jest.fn().mockResolvedValue({ users: [] }); + + const { result } = renderUseChannelAddMembers(queryUsers); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => result.current.toggleUser({} as never)); + + expect(result.current.selectedUsers).toHaveLength(0); + }); + + it('surfaces an error notification when queryUsers rejects', async () => { + const queryUsers: QueryUsersMock = jest.fn().mockRejectedValue(new Error('boom')); + + const { result } = renderUseChannelAddMembers(queryUsers); + + await waitFor(() => expect(addNotification).toHaveBeenCalledTimes(1)); + expect(addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + severity: 'error', + type: 'api:channel:query-users:failed', + }), + }), + ); + expect(result.current.loading).toBe(false); + }); + + it('cancels the pending debounced search on unmount', async () => { + jest.useFakeTimers(); + const queryUsers: QueryUsersMock = jest.fn().mockResolvedValue({ users: [] }); + + const { result, unmount } = renderUseChannelAddMembers(queryUsers); + await act(async () => { + await Promise.resolve(); + }); + + act(() => result.current.onChangeSearchText('abc')); + unmount(); + + await act(async () => { + jest.advanceTimersByTime(200); + await Promise.resolve(); + }); + + // only the mount fetch ran; the debounced autocomplete was cancelled on unmount + expect(queryUsers).toHaveBeenCalledTimes(1); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/members/useChannelAllMembers.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/members/useChannelAllMembers.test.tsx new file mode 100644 index 0000000000..81921cc1d3 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/members/useChannelAllMembers.test.tsx @@ -0,0 +1,234 @@ +import React from 'react'; + +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import type { Channel, ChannelMemberResponse } from 'stream-chat'; + +import { TranslationProvider } from '../../../../contexts/translationContext/TranslationContext'; +import { generateMember } from '../../../../mock-builders/generator/member'; +import { generateUser } from '../../../../mock-builders/generator/user'; +import { useNotificationApi } from '../../../Notifications/hooks/useNotificationApi'; +import { useChannelAllMembers } from '../../hooks/members/useChannelAllMembers'; + +jest.mock('../../../Notifications/hooks/useNotificationApi', () => ({ + useNotificationApi: jest.fn(() => ({ addNotification: jest.fn() })), +})); + +const t = ((key: string) => key) as never; + +const translationWrapper = ({ children }: { children: React.ReactNode }) => ( + input) as never, userLanguage: 'en' }} + > + {children} + +); + +type QueryMembersMock = jest.Mock< + Promise<{ members: ChannelMemberResponse[] }>, + [unknown, unknown, unknown] +>; + +const buildChannel = ({ + members, + memberCount, + queryMembers, +}: { + members: ChannelMemberResponse[]; + memberCount?: number; + queryMembers?: QueryMembersMock; +}): Channel => + ({ + cid: 'messaging:test', + data: memberCount == null ? {} : { member_count: memberCount }, + on: () => ({ unsubscribe: () => undefined }), + queryMembers: queryMembers ?? jest.fn(), + state: { + members: Object.fromEntries( + members.map((m) => [m.user?.id ?? m.user_id ?? '', m]).filter(([k]) => Boolean(k)), + ), + }, + }) as unknown as Channel; + +const buildMembers = (count: number, prefix = 'u') => + Array.from({ length: count }, (_, i) => + generateMember({ user: generateUser({ id: `${prefix}-${i}`, name: `User ${i}` }) }), + ); + +describe('useChannelAllMembers', () => { + let addNotification: jest.Mock; + + beforeEach(() => { + addNotification = jest.fn(); + (useNotificationApi as jest.Mock).mockReturnValue({ addNotification }); + }); + + describe('local mode', () => { + it('returns local members when member_count matches the loaded count', () => { + const members = buildMembers(3); + const queryMembers: QueryMembersMock = jest.fn(); + const channel = buildChannel({ memberCount: 3, members, queryMembers }); + + const { result } = renderHook(() => useChannelAllMembers({ channel })); + + expect(queryMembers).not.toHaveBeenCalled(); + expect(result.current.results.map((m) => m.user?.id)).toEqual(['u-0', 'u-1', 'u-2']); + expect(result.current.hasMore).toBe(false); + expect(result.current.loading).toBe(false); + }); + + it('treats undefined member_count as fully loaded', () => { + const members = buildMembers(2); + const queryMembers: QueryMembersMock = jest.fn(); + const channel = buildChannel({ members, queryMembers }); + + const { result } = renderHook(() => useChannelAllMembers({ channel })); + + expect(queryMembers).not.toHaveBeenCalled(); + expect(result.current.results).toHaveLength(2); + expect(result.current.hasMore).toBe(false); + }); + + it('loadMore is a no-op in local mode', () => { + const members = buildMembers(1); + const queryMembers: QueryMembersMock = jest.fn(); + const channel = buildChannel({ memberCount: 1, members, queryMembers }); + + const { result } = renderHook(() => useChannelAllMembers({ channel })); + + act(() => result.current.loadMore()); + expect(queryMembers).not.toHaveBeenCalled(); + }); + }); + + describe('paginated mode', () => { + it('fetches the first page on mount and exposes loading state', async () => { + const loaded = buildMembers(25, 'loaded'); + const firstPage = buildMembers(25, 'page1'); + const queryMembers: QueryMembersMock = jest.fn().mockResolvedValue({ members: firstPage }); + const channel = buildChannel({ memberCount: 250, members: loaded, queryMembers }); + + const { result } = renderHook(() => useChannelAllMembers({ channel })); + + expect(result.current.loading).toBe(true); + expect(result.current.hasMore).toBe(true); + + await waitFor(() => expect(queryMembers).toHaveBeenCalledTimes(1)); + expect(queryMembers).toHaveBeenCalledWith({}, { created_at: 1 }, { limit: 25, offset: 0 }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.results).toHaveLength(25); + expect(result.current.results[0]?.user?.id).toBe('page1-0'); + expect(result.current.hasMore).toBe(true); + }); + + it('appends the next page on loadMore with the correct offset and dedupes', async () => { + const firstPage = buildMembers(25, 'page1'); + const overlap = firstPage[firstPage.length - 1]; + const secondPageFresh = buildMembers(10, 'page2'); + const secondPage = overlap ? [overlap, ...secondPageFresh] : secondPageFresh; + const queryMembers: QueryMembersMock = jest + .fn() + .mockResolvedValueOnce({ members: firstPage }) + .mockResolvedValueOnce({ members: secondPage }); + const channel = buildChannel({ + memberCount: 300, + members: buildMembers(25, 'loaded'), + queryMembers, + }); + + const { result } = renderHook(() => useChannelAllMembers({ channel })); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.results).toHaveLength(25); + + act(() => result.current.loadMore()); + + await waitFor(() => expect(queryMembers).toHaveBeenCalledTimes(2)); + expect(queryMembers).toHaveBeenNthCalledWith( + 2, + {}, + { created_at: 1 }, + { limit: 25, offset: 25 }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.results).toHaveLength(35); + expect(result.current.hasMore).toBe(false); + }); + + it('marks hasMore=false when the first page is shorter than PAGE_SIZE', async () => { + const firstPage = buildMembers(10, 'page1'); + const queryMembers: QueryMembersMock = jest.fn().mockResolvedValue({ members: firstPage }); + const channel = buildChannel({ + memberCount: 200, + members: buildMembers(25, 'loaded'), + queryMembers, + }); + + const { result } = renderHook(() => useChannelAllMembers({ channel })); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.hasMore).toBe(false); + + act(() => result.current.loadMore()); + expect(queryMembers).toHaveBeenCalledTimes(1); + }); + + it('guards against concurrent loadMore calls', async () => { + let resolveSecond: ((value: { members: ChannelMemberResponse[] }) => void) | undefined; + const queryMembers: QueryMembersMock = jest + .fn() + .mockResolvedValueOnce({ members: buildMembers(25, 'page1') }) + .mockReturnValueOnce( + new Promise<{ members: ChannelMemberResponse[] }>((resolve) => { + resolveSecond = resolve; + }), + ); + const channel = buildChannel({ + memberCount: 500, + members: buildMembers(25, 'loaded'), + queryMembers, + }); + + const { result } = renderHook(() => useChannelAllMembers({ channel })); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => result.current.loadMore()); + await waitFor(() => expect(result.current.loading).toBe(true)); + expect(result.current.results.length).toBeGreaterThan(0); + + act(() => result.current.loadMore()); + act(() => result.current.loadMore()); + + expect(queryMembers).toHaveBeenCalledTimes(2); + + act(() => resolveSecond?.({ members: buildMembers(25, 'page2') })); + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + it('recovers from a queryMembers rejection and notifies the user', async () => { + const queryMembers: QueryMembersMock = jest.fn().mockRejectedValue(new Error('boom')); + const channel = buildChannel({ + memberCount: 200, + members: buildMembers(25, 'loaded'), + queryMembers, + }); + + const { result } = renderHook(() => useChannelAllMembers({ channel }), { + wrapper: translationWrapper, + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.results).toEqual([]); + expect(addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + severity: 'error', + type: 'api:channel:query-members:failed', + }), + }), + ); + }); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/members/useMemberRoleLabel.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/members/useMemberRoleLabel.test.tsx new file mode 100644 index 0000000000..0ade692c46 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/members/useMemberRoleLabel.test.tsx @@ -0,0 +1,130 @@ +import React from 'react'; + +import { renderHook } from '@testing-library/react-native'; +import type { Channel, ChannelMemberResponse } from 'stream-chat'; + +import { ChannelDetailsContextProvider } from '../../../../contexts/channelDetailsContext/channelDetailsContext'; +import { TranslationProvider } from '../../../../contexts/translationContext/TranslationContext'; +import { generateMember } from '../../../../mock-builders/generator/member'; +import { generateUser } from '../../../../mock-builders/generator/user'; +import type { GetMemberRoleLabel } from '../../ChannelDetailsScreen'; +import { useMemberRoleLabel } from '../../hooks/members/useMemberRoleLabel'; + +const buildChannel = (createdById = 'creator'): Channel => + ({ + cid: 'messaging:test', + data: { created_by: { id: createdById } }, + on: () => ({ unsubscribe: () => undefined }), + }) as unknown as Channel; + +const buildMember = ( + overrides: { + user?: Partial>; + channel_role?: ChannelMemberResponse['channel_role']; + } = {}, +): ChannelMemberResponse => + generateMember({ + channel_role: overrides.channel_role, + user: generateUser({ id: 'alice', name: 'Alice', ...(overrides.user ?? {}) }), + }); + +const t = ((key: string) => key) as never; + +const renderRoleLabel = ( + member: ChannelMemberResponse, + { + channel = buildChannel(), + getMemberRoleLabel, + }: { channel?: Channel; getMemberRoleLabel?: GetMemberRoleLabel } = {}, +) => + renderHook(() => useMemberRoleLabel(member), { + wrapper: ({ children }) => ( + input) as never, userLanguage: 'en' }} + > + + {children} + + + ), + }); + +describe('useMemberRoleLabel', () => { + describe('default rules', () => { + it('returns "Owner" when the member is the channel creator', () => { + const { result } = renderRoleLabel(buildMember(), { channel: buildChannel('alice') }); + expect(result.current).toBe('Owner'); + }); + + it('returns "Admin" when the member has the admin user role', () => { + const { result } = renderRoleLabel(buildMember({ user: { role: 'admin' } })); + expect(result.current).toBe('Admin'); + }); + + it('returns "Moderator" when the member has the channel_moderator channel_role', () => { + const { result } = renderRoleLabel(buildMember({ channel_role: 'channel_moderator' })); + expect(result.current).toBe('Moderator'); + }); + + it('returns null for a plain member', () => { + const { result } = renderRoleLabel(buildMember()); + expect(result.current).toBeNull(); + }); + + it('prefers Owner over Admin when both rules match', () => { + const { result } = renderRoleLabel(buildMember({ user: { role: 'admin' } }), { + channel: buildChannel('alice'), + }); + expect(result.current).toBe('Owner'); + }); + + it('prefers Admin over Moderator when both rules match', () => { + const { result } = renderRoleLabel( + buildMember({ channel_role: 'channel_moderator', user: { role: 'admin' } }), + ); + expect(result.current).toBe('Admin'); + }); + + it('does not match Owner when the member has no user id', () => { + const member = generateMember({ user: undefined, user_id: 'orphan' }); + const { result } = renderRoleLabel(member, { channel: buildChannel('orphan') }); + expect(result.current).toBeNull(); + }); + }); + + describe('custom getMemberRoleLabel', () => { + it('uses the return value of the custom function', () => { + const { result } = renderRoleLabel(buildMember({ user: { role: 'admin' } }), { + channel: buildChannel('alice'), + getMemberRoleLabel: () => 'VIP', + }); + expect(result.current).toBe('VIP'); + }); + + it('returns null when the custom function returns null', () => { + const { result } = renderRoleLabel(buildMember({ user: { role: 'admin' } }), { + getMemberRoleLabel: () => null, + }); + expect(result.current).toBeNull(); + }); + + it('returns null when the custom function returns undefined', () => { + const { result } = renderRoleLabel(buildMember({ user: { role: 'admin' } }), { + getMemberRoleLabel: () => undefined, + }); + expect(result.current).toBeNull(); + }); + + it('passes channel, member, and t to the custom function', () => { + const channel = buildChannel('alice'); + const member = buildMember({ user: { role: 'admin' } }); + const getMemberRoleLabel = jest.fn(() => 'Custom'); + renderRoleLabel(member, { channel, getMemberRoleLabel }); + expect(getMemberRoleLabel).toHaveBeenCalledWith({ + channel, + member, + t: expect.any(Function), + }); + }); + }); +}); 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..f333b7cb63 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/useChannelDetailsActionItems.test.tsx @@ -0,0 +1,173 @@ +import { renderHook } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +import * as channelDetailsContextModule from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import type { + ChannelActionItem, + GetChannelActionItems, +} from '../../../hooks/actions/useChannelActionItems'; +import * as useChannelActionItemsModule from '../../../hooks/actions/useChannelActionItems'; +import { useChannelDetailsActionItems } 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 = { 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; +}; + +const mockUseChannelActionItems = (items: ChannelActionItem[]) => + jest.spyOn(useChannelActionItemsModule, 'useChannelActionItems').mockReturnValue(items); + +describe('useChannelDetailsActionItems', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('passes the channel and undefined getChannelActionItems through when the prop is not set', () => { + mockContext(); + const spy = mockUseChannelActionItems([]); + + renderHook(() => useChannelDetailsActionItems()); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ channel, getChannelActionItems: undefined }); + }); + + it('forwards the getChannelActionItems prop from context unchanged', () => { + const getChannelActionItems: GetChannelActionItems = ({ defaultItems }) => defaultItems; + mockContext({ getChannelActionItems }); + const spy = mockUseChannelActionItems([]); + + renderHook(() => useChannelDetailsActionItems()); + + expect(spy).toHaveBeenCalledWith({ channel, getChannelActionItems }); + }); + + it('returns non-leave/non-delete items referentially unchanged', () => { + mockContext(); + const muteItem = buildItem({ id: 'mute' }); + const customItem = buildItem({ id: 'archive' }); + mockUseChannelActionItems([muteItem, customItem]); + + const { result } = renderHook(() => useChannelDetailsActionItems()); + + expect(result.current).toHaveLength(2); + expect(result.current[0]).toBe(muteItem); + expect(result.current[1]).toBe(customItem); + }); + + it.each([ + { id: 'leave', label: 'Leave Group' }, + { id: 'deleteChannel', label: 'Delete Group' }, + { id: 'block', label: 'Block User' }, + ])( + 'wraps the $id action to call onChannelDismiss after the original action resolves', + async ({ id, label }) => { + const { onChannelDismiss } = mockContext(); + + const callOrder: string[] = []; + let resolveAction: (() => void) | undefined; + const originalAction = jest.fn( + (options?: { onSuccess?: () => unknown }) => + new Promise((resolve) => { + callOrder.push('action-start'); + resolveAction = async () => { + callOrder.push('action-resolved'); + await options?.onSuccess?.(); + resolve(); + }; + }), + ); + (onChannelDismiss as jest.Mock).mockImplementation(() => { + callOrder.push('onChannelDismiss'); + }); + + const item = buildItem({ action: originalAction, id, label, type: 'destructive' }); + mockUseChannelActionItems([item]); + + const { result } = renderHook(() => useChannelDetailsActionItems()); + const [wrapped] = result.current; + + expect(wrapped).not.toBe(item); + expect(wrapped.id).toBe(id); + expect(wrapped.label).toBe(label); + expect(wrapped.type).toBe('destructive'); + + const pending = wrapped.action(); + expect(originalAction).toHaveBeenCalledTimes(1); + expect(onChannelDismiss).not.toHaveBeenCalled(); + + resolveAction!(); + await pending; + + expect(onChannelDismiss).toHaveBeenCalledTimes(1); + expect(onChannelDismiss).toHaveBeenCalledWith(); + expect(callOrder).toEqual(['action-start', 'action-resolved', 'onChannelDismiss']); + }, + ); + + it('composes a caller-supplied onSuccess with onChannelDismiss and passes other options through', () => { + const { onChannelDismiss } = mockContext(); + const originalLeave = jest.fn(); + mockUseChannelActionItems([buildItem({ action: originalLeave, id: 'leave' })]); + + const { result } = renderHook(() => useChannelDetailsActionItems()); + const callerOnSuccess = jest.fn(); + const callerOnFailure = jest.fn(); + result.current[0].action({ + // @ts-expect-error - extra caller-supplied option to ensure the wrapper merges options + extra: 'value', + onFailure: callerOnFailure, + onSuccess: callerOnSuccess, + }); + + expect(originalLeave).toHaveBeenCalledTimes(1); + const passedOptions = (originalLeave as jest.Mock).mock.calls[0][0]; + // Caller-supplied options (including onFailure) pass through untouched. + expect(passedOptions).toMatchObject({ extra: 'value', onFailure: callerOnFailure }); + // onSuccess is composed: it runs the caller's callback and then onChannelDismiss. + expect(typeof passedOptions.onSuccess).toBe('function'); + + passedOptions.onSuccess(); + expect(callerOnSuccess).toHaveBeenCalledTimes(1); + expect(onChannelDismiss).toHaveBeenCalledTimes(1); + }); + + it.each([{ id: 'leave' }, { id: 'deleteChannel' }])( + 'does not throw when onChannelDismiss is undefined on the $id path', + async ({ id }) => { + mockContext({ onChannelDismiss: undefined }); + const originalAction = jest.fn().mockResolvedValue(undefined); + mockUseChannelActionItems([buildItem({ action: originalAction, id })]); + + const { result } = renderHook(() => useChannelDetailsActionItems()); + + await expect(result.current[0].action()).resolves.toBeUndefined(); + expect(originalAction).toHaveBeenCalledTimes(1); + const passedOptions = (originalAction as jest.Mock).mock.calls[0][0]; + expect(typeof passedOptions.onSuccess).toBe('function'); + expect(() => passedOptions.onSuccess()).not.toThrow(); + }, + ); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/useChannelDetailsMemberStatusText.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/useChannelDetailsMemberStatusText.test.tsx new file mode 100644 index 0000000000..af8e3314f1 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/useChannelDetailsMemberStatusText.test.tsx @@ -0,0 +1,117 @@ +import React from 'react'; + +import { renderHook } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; +import { useChannelMemberCount } from '../../../hooks/useChannelMemberCount'; +import { useIsDirectChat } from '../../../hooks/useIsDirectChat'; +import { useChannelMembersState } from '../../ChannelList/hooks/useChannelMembersState'; +import { useChannelOnlineMemberCount } from '../../ChannelList/hooks/useChannelOnlineMemberCount'; +import { useChannelDetailsMemberStatusText } from '../hooks/useChannelDetailsMemberStatusText'; + +jest.mock('../../../hooks/useChannelMemberCount', () => ({ + useChannelMemberCount: jest.fn(), +})); + +jest.mock('../../../hooks/useIsDirectChat', () => ({ + useIsDirectChat: jest.fn(() => false), +})); + +jest.mock('../../ChannelList/hooks/useChannelMembersState', () => ({ + useChannelMembersState: jest.fn(() => ({})), +})); + +jest.mock('../../ChannelList/hooks/useChannelOnlineMemberCount', () => ({ + useChannelOnlineMemberCount: jest.fn(), +})); + +const t = ((key: string, options?: Record) => { + if (options && typeof options === 'object') { + return Object.entries(options).reduce((acc, [k, v]) => acc.replace(`{{${k}}}`, String(v)), key); + } + return key; +}) as never; + +const OWN_USER_ID = 'own-user'; +const channel = { + cid: 'messaging:test', + getClient: () => ({ userID: OWN_USER_ID }), +} as unknown as Channel; + +const renderStatusText = () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + input) as never, userLanguage: 'en' }} + > + {children} + + ); + return renderHook(() => useChannelDetailsMemberStatusText(channel), { wrapper }); +}; + +describe('useChannelDetailsMemberStatusText', () => { + afterEach(() => jest.clearAllMocks()); + + it('formats the reactive member count and online count', () => { + (useChannelMemberCount as jest.Mock).mockReturnValue(5); + (useChannelOnlineMemberCount as jest.Mock).mockReturnValue(2); + (useChannelMembersState as jest.Mock).mockReturnValue({}); + + const { result } = renderStatusText(); + + expect(result.current).toBe('5 members, 2 online'); + }); + + it('recomputes when the online count changes', () => { + (useChannelMemberCount as jest.Mock).mockReturnValue(4); + (useChannelOnlineMemberCount as jest.Mock).mockReturnValue(0); + (useChannelMembersState as jest.Mock).mockReturnValue({}); + + const { result, rerender } = renderStatusText(); + expect(result.current).toBe('4 members, 0 online'); + + (useChannelOnlineMemberCount as jest.Mock).mockReturnValue(3); + rerender({}); + + expect(result.current).toBe('4 members, 3 online'); + }); + + describe('direct chats', () => { + beforeEach(() => { + (useIsDirectChat as jest.Mock).mockReturnValue(true); + }); + + it('returns "Online" when the other member is online', () => { + (useChannelMembersState as jest.Mock).mockReturnValue({ + [OWN_USER_ID]: { user: { id: OWN_USER_ID, online: true } }, + other: { user: { id: 'other-user', online: true } }, + }); + + const { result } = renderStatusText(); + + expect(result.current).toBe('Online'); + }); + + it('returns an empty string when the other member is offline', () => { + (useChannelMembersState as jest.Mock).mockReturnValue({ + [OWN_USER_ID]: { user: { id: OWN_USER_ID, online: true } }, + other: { user: { id: 'other-user', online: false } }, + }); + + const { result } = renderStatusText(); + + expect(result.current).toBe(''); + }); + + it('ignores the current user when resolving the other member', () => { + (useChannelMembersState as jest.Mock).mockReturnValue({ + [OWN_USER_ID]: { user: { id: OWN_USER_ID, online: true } }, + }); + + const { result } = renderStatusText(); + + expect(result.current).toBe(''); + }); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/useChannelDetailsMembersPreview.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/useChannelDetailsMembersPreview.test.tsx new file mode 100644 index 0000000000..10f59e7263 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/useChannelDetailsMembersPreview.test.tsx @@ -0,0 +1,89 @@ +import { renderHook } from '@testing-library/react-native'; +import type { Channel, ChannelMemberResponse } from 'stream-chat'; + +import { generateMember } from '../../../mock-builders/generator/member'; +import { generateUser } from '../../../mock-builders/generator/user'; +import { useChannelDetailsMembersPreview } from '../hooks/useChannelDetailsMembersPreview'; + +const buildChannel = ({ + members, + memberCount, +}: { + members: ChannelMemberResponse[]; + memberCount?: number; +}): Channel => + ({ + cid: 'messaging:test', + data: memberCount == null ? {} : { member_count: memberCount }, + 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; + +const buildMember = (id: string, created_at?: string) => + generateMember({ created_at, user: generateUser({ id, name: id }) }); + +describe('useChannelDetailsMembersPreview', () => { + it('orders visible members by created_at ascending', () => { + const members = [ + buildMember('c', '2023-03-01T00:00:00.000Z'), + buildMember('a', '2023-01-01T00:00:00.000Z'), + buildMember('b', '2023-02-01T00:00:00.000Z'), + ]; + const channel = buildChannel({ members }); + + const { result } = renderHook(() => useChannelDetailsMembersPreview(channel)); + + expect(result.current.visible.map((m) => m.user?.id)).toEqual(['a', 'b', 'c']); + }); + + it('sorts members without created_at to the end', () => { + const members = [ + buildMember('no-date'), + buildMember('newer', '2023-02-01T00:00:00.000Z'), + buildMember('older', '2023-01-01T00:00:00.000Z'), + ]; + const channel = buildChannel({ members }); + + const { result } = renderHook(() => useChannelDetailsMembersPreview(channel)); + + expect(result.current.visible.map((m) => m.user?.id)).toEqual(['older', 'newer', 'no-date']); + }); + + it('limits visible members to max and sets hasMore', () => { + const members = Array.from({ length: 8 }, (_, i) => + buildMember(`u-${i}`, `2023-01-0${i + 1}T00:00:00.000Z`), + ); + const channel = buildChannel({ members, memberCount: 8 }); + + const { result } = renderHook(() => useChannelDetailsMembersPreview(channel, 5)); + + expect(result.current.visible).toHaveLength(5); + expect(result.current.visible.map((m) => m.user?.id)).toEqual([ + 'u-0', + 'u-1', + 'u-2', + 'u-3', + 'u-4', + ]); + expect(result.current.hasMore).toBe(true); + expect(result.current.total).toBe(8); + }); + + it('uses the loaded member count when member_count is unavailable', () => { + const members = [ + buildMember('a', '2023-01-01T00:00:00.000Z'), + buildMember('b', '2023-02-01T00:00:00.000Z'), + ]; + const channel = buildChannel({ members }); + + const { result } = renderHook(() => useChannelDetailsMembersPreview(channel)); + + expect(result.current.total).toBe(2); + expect(result.current.hasMore).toBe(false); + expect(result.current.visible).toHaveLength(2); + }); +}); diff --git a/package/src/components/ChannelDetailsScreen/__tests__/useEditChannelImage.test.tsx b/package/src/components/ChannelDetailsScreen/__tests__/useEditChannelImage.test.tsx new file mode 100644 index 0000000000..d58c0e43ad --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/useEditChannelImage.test.tsx @@ -0,0 +1,235 @@ +import React, { PropsWithChildren } from 'react'; +import { Alert } from 'react-native'; + +import { act, renderHook } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +import { ChannelDetailsContextProvider } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; +import { generateFileReference } from '../../../mock-builders/attachments'; +import { NativeHandlers } from '../../../native'; +import { useEditChannelImage } from '../hooks/useEditChannelImage'; + +jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); + +const buildChannel = (): Channel => + ({ + cid: 'messaging:test', + data: { name: 'Test' }, + on: () => ({ unsubscribe: () => undefined }), + state: { members: {} }, + }) as unknown as Channel; + +const wrap = ({ compressImageQuality }: { compressImageQuality?: number }) => { + const Wrapper = ({ children }: PropsWithChildren) => ( + key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + + {children} + + + ); + return Wrapper; +}; + +describe('useEditChannelImage', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('takePhoto', () => { + it('forwards compressImageQuality and mediaType=image to NativeHandlers.takePhoto', async () => { + const file = generateFileReference(); + jest.spyOn(NativeHandlers, 'takePhoto').mockResolvedValue(file); + + const { result } = renderHook(() => useEditChannelImage(), { + wrapper: wrap({ compressImageQuality: 0.5 }), + }); + + let returned; + await act(async () => { + returned = await result.current.takePhoto(); + }); + + expect(NativeHandlers.takePhoto).toHaveBeenCalledTimes(1); + expect(NativeHandlers.takePhoto).toHaveBeenCalledWith({ + compressImageQuality: 0.5, + mediaType: 'image', + }); + expect(returned).toBe(file); + }); + + it('shows the permission alert and returns undefined when askToOpenSettings is true', async () => { + jest + .spyOn(NativeHandlers, 'takePhoto') + .mockResolvedValue({ askToOpenSettings: true } as never); + + const { result } = renderHook(() => useEditChannelImage(), { + wrapper: wrap({}), + }); + + let returned; + await act(async () => { + returned = await result.current.takePhoto(); + }); + + expect(Alert.alert).toHaveBeenCalledTimes(1); + expect(Alert.alert).toHaveBeenCalledWith( + 'Allow camera access in device settings', + 'Device camera is used to take photos or videos.', + expect.any(Array), + ); + expect(returned).toBeUndefined(); + }); + + it('returns undefined when the user cancels', async () => { + jest.spyOn(NativeHandlers, 'takePhoto').mockResolvedValue({ cancelled: true } as never); + + const { result } = renderHook(() => useEditChannelImage(), { + wrapper: wrap({}), + }); + + let returned; + await act(async () => { + returned = await result.current.takePhoto(); + }); + + expect(Alert.alert).not.toHaveBeenCalled(); + expect(returned).toBeUndefined(); + }); + }); + + describe('pickImageFromNativePicker', () => { + it('requests a single image from NativeHandlers.pickImage', async () => { + const file = generateFileReference(); + jest + .spyOn(NativeHandlers, 'pickImage') + .mockResolvedValue({ assets: [file], cancelled: false }); + + const { result } = renderHook(() => useEditChannelImage(), { + wrapper: wrap({}), + }); + + await act(async () => { + await result.current.pickImageFromNativePicker(); + }); + + expect(NativeHandlers.pickImage).toHaveBeenCalledTimes(1); + expect(NativeHandlers.pickImage).toHaveBeenCalledWith({ maxNumberOfFiles: 1 }); + }); + + it('compresses the picked asset and returns a file with the compressed uri', async () => { + const file = { + ...generateFileReference({ uri: 'file:///original' }), + height: 1000, + width: 1000, + }; + jest + .spyOn(NativeHandlers, 'pickImage') + .mockResolvedValue({ assets: [file], cancelled: false }); + const compressSpy = jest + .spyOn(NativeHandlers, 'compressImage') + .mockResolvedValue('file:///compressed'); + + const { result } = renderHook(() => useEditChannelImage(), { + wrapper: wrap({ compressImageQuality: 0.5 }), + }); + + let returned; + await act(async () => { + returned = await result.current.pickImageFromNativePicker(); + }); + + expect(compressSpy).toHaveBeenCalledWith({ + compressImageQuality: 0.5, + height: 1000, + uri: 'file:///original', + width: 1000, + }); + expect(returned).toEqual({ ...file, uri: 'file:///compressed' }); + }); + + it('skips compression when compressImageQuality is undefined', async () => { + const file = { + ...generateFileReference({ uri: 'file:///original' }), + height: 1000, + width: 1000, + }; + jest + .spyOn(NativeHandlers, 'pickImage') + .mockResolvedValue({ assets: [file], cancelled: false }); + const compressSpy = jest.spyOn(NativeHandlers, 'compressImage'); + + const { result } = renderHook(() => useEditChannelImage(), { + wrapper: wrap({}), + }); + + let returned; + await act(async () => { + returned = await result.current.pickImageFromNativePicker(); + }); + + expect(compressSpy).not.toHaveBeenCalled(); + expect(returned).toEqual({ ...file, uri: 'file:///original' }); + }); + + it('shows the permission alert and returns undefined when askToOpenSettings is true', async () => { + jest + .spyOn(NativeHandlers, 'pickImage') + .mockResolvedValue({ askToOpenSettings: true } as never); + + const { result } = renderHook(() => useEditChannelImage(), { + wrapper: wrap({}), + }); + + let returned; + await act(async () => { + returned = await result.current.pickImageFromNativePicker(); + }); + + expect(Alert.alert).toHaveBeenCalledTimes(1); + expect(Alert.alert).toHaveBeenCalledWith( + 'Allow access to your Gallery', + 'Device gallery permissions is used to take photos or videos.', + expect.any(Array), + ); + expect(returned).toBeUndefined(); + }); + + it('returns undefined when the user cancels', async () => { + jest.spyOn(NativeHandlers, 'pickImage').mockResolvedValue({ assets: [], cancelled: true }); + + const { result } = renderHook(() => useEditChannelImage(), { + wrapper: wrap({}), + }); + + let returned; + await act(async () => { + returned = await result.current.pickImageFromNativePicker(); + }); + + expect(Alert.alert).not.toHaveBeenCalled(); + expect(returned).toBeUndefined(); + }); + + it('returns undefined when no assets are returned', async () => { + jest.spyOn(NativeHandlers, 'pickImage').mockResolvedValue({ assets: [], cancelled: false }); + + const { result } = renderHook(() => useEditChannelImage(), { + wrapper: wrap({}), + }); + + let returned; + await act(async () => { + returned = await result.current.pickImageFromNativePicker(); + }); + + expect(returned).toBeUndefined(); + }); + }); +}); 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..83c2a4e3b7 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/__tests__/useUserActivityStatus.test.tsx @@ -0,0 +1,69 @@ +import React, { type PropsWithChildren } from 'react'; + +import { renderHook } from '@testing-library/react-native'; +import type { UserResponse } from 'stream-chat'; + +import { + TranslationProvider, + type TranslationContextValue, +} from '../../../contexts/translationContext/TranslationContext'; +import { Streami18n } from '../../../utils/i18n/Streami18n'; +import { useUserActivityStatus } from '../hooks/useUserActivityStatus'; + +let translators: TranslationContextValue; + +beforeAll(async () => { + const i18nInstance = new Streami18n(); + translators = (await i18nInstance.getTranslators()) as unknown as TranslationContextValue; +}); + +const wrapper = ({ children }: PropsWithChildren) => ( + {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/ChannelDetailsActionItem.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsActionItem.tsx new file mode 100644 index 0000000000..b1aad36f2e --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsActionItem.tsx @@ -0,0 +1,129 @@ +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 ChannelDetailsActionItemProps = { + Icon: React.ComponentType; + label: string; + destructive?: boolean; + onPress?: () => void; + testID?: string; + trailing?: React.ReactNode; +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelDetailsActionItem = ({ + Icon, + destructive = false, + label, + onPress, + testID, + trailing, +}: ChannelDetailsActionItemProps) => { + const { + theme: { + channelDetailsScreen: { + actionItem: { + 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/ChannelDetailsActionsSection.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsActionsSection.tsx new file mode 100644 index 0000000000..ca0df26544 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsActionsSection.tsx @@ -0,0 +1,180 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { StyleSheet, Switch, View } from 'react-native'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { ChannelActionItem } from '../../../hooks/actions/useChannelActionItems'; +import { getOtherUserInDirectChannel } from '../../../hooks/actions/useChannelActions'; +import { useIsDirectChat } from '../../../hooks/useIsDirectChat'; +import { primitives } from '../../../theme'; +import { useRtlMirrorSwitchStyle } from '../../../utils/rtlMirrorSwitchStyle'; +import { useIsChannelMuted } from '../../ChannelPreview/hooks/useIsChannelMuted'; +import { useUserMuteActive } from '../../Message/hooks/useUserMuteActive'; +import { useChannelDetailsActionItems } from '../hooks'; + +const ChannelMuteToggleRow = ({ item }: { item: ChannelActionItem }) => { + const { channel } = useChannelDetailsContext(); + const { ChannelDetailsActionItem } = useComponentsContext(); + const rtlMirrorSwitchStyle = useRtlMirrorSwitchStyle(); + const { muted } = useIsChannelMuted(channel); + const switchColors = useSwitchColors(); + const [isMuted, setIsMuted] = useState(muted); + const mutedRef = useRef(muted); + + useEffect(() => { + mutedRef.current = muted; + setIsMuted(muted); + }, [muted]); + + const handleValueChange = useCallback( + (value: boolean) => { + setIsMuted(value); + item.action({ onFailure: () => setIsMuted(mutedRef.current) }); + }, + [item], + ); + + const testID = `channel-details-action-${item.id}`; + + return ( + + } + /> + ); +}; + +const UserMuteToggleRow = ({ item }: { item: ChannelActionItem }) => { + const { channel } = useChannelDetailsContext(); + const { ChannelDetailsActionItem } = useComponentsContext(); + const isDirect = useIsDirectChat(channel); + const rtlMirrorSwitchStyle = useRtlMirrorSwitchStyle(); + const switchColors = useSwitchColors(); + const otherUser = isDirect ? getOtherUserInDirectChannel(channel)?.user : undefined; + const userMuted = useUserMuteActive(otherUser); + const [isUserMuted, setIsUserMuted] = useState(userMuted); + const userMutedRef = useRef(userMuted); + + useEffect(() => { + userMutedRef.current = userMuted; + setIsUserMuted(userMuted); + }, [userMuted]); + + const handleValueChange = useCallback( + (value: boolean) => { + setIsUserMuted(value); + item.action({ onFailure: () => setIsUserMuted(userMutedRef.current) }); + }, + [item], + ); + + const testID = `channel-details-action-${item.id}`; + + return ( + + } + /> + ); +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelDetailsActionsSection = () => { + const { + theme: { + channelDetailsScreen: { sectionCard: sectionCardOverride }, + semantics, + }, + } = useTheme(); + const { ChannelDetailsActionItem } = useComponentsContext(); + const styles = useStyles(); + + const items = useChannelDetailsActionItems(); + + if (items.length === 0) return null; + + return ( + + {items.map((item) => { + if (item.id === 'mute') { + return ; + } + if (item.id === 'muteUser') { + return ; + } + + return ( + item.action()} + testID={`channel-details-action-${item.id}`} + /> + ); + })} + + ); +}; + +const useSwitchColors = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => ({ + thumbColor: semantics.controlToggleSwitchKnob, + trackColor: { + false: semantics.controlToggleSwitchBg, + true: semantics.controlToggleSwitchBgSelected, + }, + }), + [semantics], + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + sectionCard: { + borderRadius: primitives.radiusLg, + overflow: 'hidden', + paddingVertical: primitives.spacingXs, + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberSection.tsx b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberSection.tsx new file mode 100644 index 0000000000..d1ea0839e5 --- /dev/null +++ b/package/src/components/ChannelDetailsScreen/components/ChannelDetailsMemberSection.tsx @@ -0,0 +1,203 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { I18nManager, Pressable, StyleSheet, Text, View } from 'react-native'; + +import type { ChannelMemberResponse } from 'stream-chat'; + +import { ChannelAddMembersModal } from './members/ChannelAddMembersModal'; +import { ChannelAllMembersModal } from './members/ChannelAllMembersModal'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useChannelOwnCapabilities } from '../../../hooks/useChannelOwnCapabilities'; +import { primitives } from '../../../theme'; +import { Button } from '../../ui/Button/Button'; +import { useChannelDetailsMembersPreview } from '../hooks/useChannelDetailsMembersPreview'; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelDetailsMemberSection = () => { + const { channel, onAddMembersPress, onMemberPress, onViewAllMembersPress } = + useChannelDetailsContext(); + const { t } = useTranslationContext(); + const ownCapabilities = useChannelOwnCapabilities(channel); + const updateChannelMembers = ownCapabilities?.includes('update-channel-members') ?? false; + const { + theme: { + channelDetailsScreen: { + memberSection: { + footer: footerOverride, + header: headerOverride, + headerTitle: headerTitleOverride, + viewAllLabel: viewAllLabelOverride, + }, + sectionCard: sectionCardOverride, + }, + semantics, + }, + } = useTheme(); + const { ChannelMemberActionsSheet, ChannelMemberItem } = useComponentsContext(); + const { hasMore, total, visible } = useChannelDetailsMembersPreview(channel); + const styles = useStyles(); + const [isMemberListVisible, setMemberListVisible] = useState(false); + const [isAddMembersVisible, setAddMembersVisible] = useState(false); + const [selectedMember, setSelectedMember] = useState(null); + + const handleViewAllPress = useCallback(() => { + if (onViewAllMembersPress) { + onViewAllMembersPress(); + return; + } + setMemberListVisible(true); + }, [onViewAllMembersPress]); + + const handleMemberListClose = useCallback(() => setMemberListVisible(false), []); + + const handleAddMembersClose = useCallback(() => setAddMembersVisible(false), []); + + const handleAddMembersPress = useCallback(() => { + if (onAddMembersPress) { + onAddMembersPress(); + return; + } + setMemberListVisible(false); + setAddMembersVisible(true); + }, [onAddMembersPress]); + + const handleMemberActionsClose = useCallback(() => setSelectedMember(null), []); + + const handleMemberPress = useCallback( + (member: ChannelMemberResponse) => { + if (onMemberPress) { + onMemberPress(member); + return; + } + setSelectedMember(member); + }, + [onMemberPress], + ); + + return ( + + + + {t('{{count}} members', { count: total })} + + {updateChannelMembers ? ( + +