diff --git a/.claude/skills/accessibility/SKILL.md b/.claude/skills/accessibility/SKILL.md index fe57c6a277..74345ea635 100644 --- a/.claude/skills/accessibility/SKILL.md +++ b/.claude/skills/accessibility/SKILL.md @@ -9,12 +9,49 @@ 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 13 locale files in `package/src/i18n/` (`ar, en, es, fr, he, hi, it, ja, ko, nl, pt-br, ru, tr`). +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`). **Platform caveat:** `'menu'` and `'menuitem'` are honored by iOS VoiceOver but Android TalkBack silently ignores them (no `UIAccessibilityTraits` equivalent). For interactive items that must be announceable on both platforms, use `'button'` on the leaf `Pressable`; the `'menu'` role can stay on the container as an iOS hint. iOS-supported roles that survive to VoiceOver: `button`, `link`, `search`, `image`, `keyboardkey`, `text`, `adjustable`, `imagebutton`, `header`, `summary`, `none`. +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 13 locale files in `package/src/i18n/` (`ar, en, es, fr, he, hi, it, ja, ko, nl, pt-br, ru, tr`). 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. 6. **Backward-compatible.** All new props are optional. Component override pattern (`WithComponents`) must continue to work. +7. **Floating overlays need a tall parent for Android a11y.** Android's accessibility framework uses each view's measured layout bounds (`getBoundsInScreen()`) to decide what's focusable at a given screen coordinate. Children rendered *outside* their parent's measured rect get pruned / reported with inverted (empty) bounds — RN doesn't clip them by default so the visual looks fine, but TalkBack can't focus them and `uiautomator dump` shows degenerate `[x,y][x,y]` rects. **Implication:** when mounting a floating overlay (autocomplete picker, popover, tooltip), pick a parent whose measured bounds contain the rendered area. A `flex: 1` Channel-area parent works; a `position: absolute` wrapper inside a small input-row container does not. This is why `AutoCompleteSuggestionList` is mounted from `MessageList` / `MessageFlashList` (full-screen flex parent) instead of `MessageComposer` (~228px composer parent — the suggestion list overflowed it and was a11y-invisible). Verify with `adb shell uiautomator dump` after mounting; if rows show `top > bottom`, the parent isn't tall enough. + +## Diagnosing Android a11y with `uiautomator dump` + +When TalkBack ignores a view, can't focus a row, or seems to focus the wrong thing, dump the a11y tree and read the bounds directly. This was the load-bearing technique behind rule #7. + +**Procedure:** + +```bash +# 1. Put the app in the state you want to inspect (open the suggestion list, modal, etc.) +adb shell uiautomator dump /sdcard/window_dump.xml +adb pull /sdcard/window_dump.xml ./window_dump.xml + +# 2. Find your view. Grep by a known accessibilityLabel, text, or resource-id. +grep -A2 'text="@channel"' window_dump.xml +grep -B1 -A1 'content-desc="Mention suggestions available"' window_dump.xml +``` + +**Reading the output:** each `` has `bounds="[left,top][right,bottom]"` in screen pixels. + +| Symptom in `bounds` | Meaning | +|---|---| +| `[0,0][0,0]` | View never measured (mid-mount or detached from a11y tree). | +| `top > bottom` or `left > right` | Clipped by parent — `getBoundsInScreen()` clamped to a smaller ancestor. TalkBack treats this as empty. **Move the mount to a taller parent.** | +| Bounds outside the screen | Off-screen or pushed by keyboard; TalkBack won't focus it. | +| Bounds present, `clickable="true"`, `focusable="true"`, but still unreachable | Check `importantForAccessibility` chain and sibling z-order — something opaque may be above it. | + +**Other useful node attributes:** +- `class` — the underlying Android View class (`android.widget.HorizontalScrollView`, etc.). Useful when an RN component compiles to something unexpected. +- `package` — confirms you're looking at *your* app, not the system UI. +- `clickable`, `focusable`, `enabled` — these must all be true for a row to take TalkBack focus. +- `content-desc` — what TalkBack will speak. If empty when you expected an `accessibilityLabel`, the prop didn't bind to the right native view. + +**Caveats:** +- The dump is a single snapshot. If the view animates in, dump after the animation settles. +- TalkBack can affect what gets dumped on some devices — turn it off when diagnosing layout, on when diagnosing focus order. +- The XML reflects native bounds *after* RN's layout pass, so a wrong dump usually means RN gave Android wrong layout, not that the dump lied. ## Where to put what @@ -54,6 +91,8 @@ Two complementary mechanisms: Use `useAnnounceOnStateChange(message, { debounceMs, priority })` for transitions (AI typing, indicators) — it dedups consecutive same-message calls and applies a default 250ms debounce. +Use `useAnnounceOnShow(visible, message, { delayMs, priority })` for **transient surfaces that appear and disappear repeatedly** (modals, sheets, autocomplete pickers). It announces on each `visible: false → true` transition and resets on hide, so the next show re-announces. The two announcer hooks are not interchangeable: `useAnnounceOnStateChange` dedupes on string equality (correct for "AI is typing" → "AI is generating"), while `useAnnounceOnShow` dedupes on visibility transition (correct for "Suggestions available" each time the picker reopens with the same label). Pair with `useA11yLabel('a11y/…')` for the message so the announcement is i18n'd and gated on the SDK's a11y opt-in. + For incoming messages: use `useIncomingMessageAnnouncements({ channel, ownUserId, activeThreadId, threadList })`. It throttles to 1 announcement per second, batches multi-message bursts, and bounds memory at 500 announced ids. ### 3) Modal / sheet focus trap @@ -68,6 +107,7 @@ const a11yProps = useResolvedModalAccessibilityProps(); ``` This returns: + - iOS: `{ accessibilityViewIsModal: true }` - Android: `{ importantForAccessibility: 'yes' }` - Either platform when `enabled` is false: `{}` @@ -81,9 +121,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). @@ -179,15 +217,18 @@ Live example: `Reply.tsx` — fires when a reply preview shows in the composer. - **Subscribing to `AccessibilityInfo` events when `enabled` is false** — wastes a listener slot. The provided hooks already gate on this; mirror that pattern. - **`useScreenReaderEnabled()` inside list items** — toggling SR re-renders every item. Only subscribe in components that actually swap UI on SR (`AudioRecorder`, `ImageGallery`, `Message`'s alternative-actions button). - **Using live regions to force-announce static modal text** — fix the dialog semantics instead (`useResolvedModalAccessibilityProps` + correct `accessibilityRole='alert'`). +- **Auto-focusing the suggestions/listbox of a typeahead on appear** — anti-pattern for combobox-style UI. Each keystroke that produces new suggestions would re-steal focus from the active `TextInput`, breaking continuous typing. ARIA combobox spec specifically forbids this; iOS VoiceOver and Android TalkBack have the same constraint. Announce on show via `useAnnounceOnShow` instead and rely on standard screen-reader navigation gestures (swipe) for the user to reach the list when they want. - **Mutating `AccessibilityInfo` polyfill state in tests without restoring** — use the mock-builder helpers in `package/src/mock-builders/accessibility/` (or jest.mock the module) and reset between tests. ## 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). @@ -215,15 +256,18 @@ Recommended for non-trivial changes: - `package/src/a11y/hooks/useAnnounceOnStateChange.ts` — announce on string-change with dedup. - `package/src/a11y/hooks/useAnnounceOnShow.ts` — announce on `visible: false → true` transitions, resets on hide (no dedup). - `package/src/a11y/hooks/useResolvedModalAccessibilityProps.ts` — modal a11y props. +- `package/src/a11y/hooks/useAnnounceOnShow.ts` — announce-on-visible helper for transient surfaces. - `package/src/components/ui/Avatar/Avatar.tsx` — example of `name` + `useA11yLabel` usage. -- `package/src/components/UIComponents/BottomSheetModal.tsx` — example of `useResolvedModalAccessibilityProps`. +- `package/src/components/UIComponents/BottomSheetModal.tsx` — example of `useResolvedModalAccessibilityProps` and `useAnnounceOnShow`. - `package/src/components/AITypingIndicatorView/AITypingIndicatorView.tsx` — example of `useAnnounceOnStateChange`. +- `package/src/components/AutoCompleteInput/AutoCompleteSuggestionList.tsx` — example of `useAnnounceOnShow` with a per-trigger label (mention/command/emoji). - `package/src/components/Message/MessageItemView/MessageFooter.tsx` — example of cross-platform auto-compose on a View (`accessible + accessibilityRole='text'`). - `package/src/components/Message/MessageItemView/MessageContent.tsx` — example of conditional drill-in (`accessible={hasInteractiveContent ? false : undefined}`). ## 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/.yarn/patches/react-native-gesture-handler-npm-3.0.0-8eca038751.patch b/.yarn/patches/react-native-gesture-handler-npm-3.0.0-8eca038751.patch deleted file mode 100644 index 86414efda5..0000000000 --- a/.yarn/patches/react-native-gesture-handler-npm-3.0.0-8eca038751.patch +++ /dev/null @@ -1,20 +0,0 @@ -diff --git a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx -index 3c4d402aec92fb0d6758428c8c7ed5262a9b3852..a4e1c56e5204c2fd9f413196b249332d37bebc53 100644 ---- a/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx -+++ b/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx -@@ -572,13 +572,13 @@ const Swipeable = (props: SwipeableProps) => { - {...remainingProps} - onLayout={onRowLayout} - style={[styles.container, containerStyle]}> -- {leftElement()} -- {rightElement()} - - - {children} - - -+ {leftElement()} -+ {rightElement()} - - - ); diff --git a/.yarnrc.yml b/.yarnrc.yml index 0de43bcd5c..e4a698db6a 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -14,6 +14,9 @@ nodeLinker: node-modules npmMinimalAgeGate: 3d +npmPreapprovedPackages: + - stream-chat + npmPublishProvenance: true yarnPath: .yarn/releases/yarn-4.15.0.cjs 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/README.md b/README.md index a010d8eb45..b9cdd8d46d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![NPM](https://img.shields.io/npm/v/stream-chat-react-native.svg)](https://www.npmjs.com/package/stream-chat-react-native) [![Build Status](https://github.com/GetStream/stream-chat-react-native/actions/workflows/release.yml/badge.svg)](https://github.com/GetStream/stream-chat-react-native/actions) [![Component Reference](https://img.shields.io/badge/docs-component%20reference-blue.svg)](https://getstream.io/chat/docs/sdk/reactnative) -![JS Bundle Size](https://img.shields.io/badge/js_bundle_size-368%20KB-blue) +![JS Bundle Size](https://img.shields.io/badge/js_bundle_size-1734%20KB-blue) diff --git a/ai-docs/accessibility.md b/ai-docs/accessibility.md index d4db899d2a..3a460f397a 100644 --- a/ai-docs/accessibility.md +++ b/ai-docs/accessibility.md @@ -85,7 +85,8 @@ Importable from `stream-chat-react-native`: - `useReducedMotionPreference()` — live boolean from `AccessibilityInfo.reduceMotionChanged`. - `useResolvedModalAccessibilityProps()` — returns `{ accessibilityViewIsModal, importantForAccessibility }` for the active platform. - `useA11yLabel(key, params)` — translated label or `undefined` when disabled. -- `useAnnounceOnStateChange(message, options)` — debounced live-region helper. +- `useAnnounceOnStateChange(message, options)` — debounced live-region helper that announces on message **change** and dedupes consecutive identical strings (good for state-driven labels like loading/error transitions). +- `useAnnounceOnShow(visible, message, { delayMs?, priority? })` — announces on each `visible: false → true` transition and resets on hide, so re-shows re-announce. Pair with `useA11yLabel(...)` for the message. Used by `BottomSheetModal` and `AutoCompleteSuggestionList`. - `useIncomingMessageAnnouncements({ channel, ownUserId, activeThreadId, threadList })` — throttled, batched announcement of new messages. - `` — connection-state announcer (mounted by ``). diff --git a/examples/ExpoMessaging/app/channel/[cid]/details/files.tsx b/examples/ExpoMessaging/app/channel/[cid]/details/files.tsx new file mode 100644 index 0000000000..e2e15b5baf --- /dev/null +++ b/examples/ExpoMessaging/app/channel/[cid]/details/files.tsx @@ -0,0 +1,29 @@ +import React, { useContext } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { ChannelDetailsContextProvider, FileAttachmentList, useTheme } from 'stream-chat-expo'; + +import { ScreenHeader } from '../../../../components/ScreenHeader'; +import { AppContext } from '../../../../context/AppContext'; + +const styles = StyleSheet.create({ + flex: { flex: 1 }, +}); + +export default function ChannelFilesScreen() { + useTheme(); + const { channel } = useContext(AppContext); + + if (!channel) { + return null; + } + + return ( + + + + + + + ); +} diff --git a/examples/ExpoMessaging/app/channel/[cid]/details/images.tsx b/examples/ExpoMessaging/app/channel/[cid]/details/images.tsx new file mode 100644 index 0000000000..07c3c18fc4 --- /dev/null +++ b/examples/ExpoMessaging/app/channel/[cid]/details/images.tsx @@ -0,0 +1,28 @@ +import React, { useContext } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { ChannelDetailsContextProvider, MediaList } from 'stream-chat-expo'; + +import { ScreenHeader } from '../../../../components/ScreenHeader'; +import { AppContext } from '../../../../context/AppContext'; + +const styles = StyleSheet.create({ + flex: { flex: 1 }, +}); + +export default function ChannelImagesScreen() { + const { channel } = useContext(AppContext); + + if (!channel) { + return null; + } + + return ( + + + + + + + ); +} diff --git a/examples/ExpoMessaging/app/channel/[cid]/details/index.tsx b/examples/ExpoMessaging/app/channel/[cid]/details/index.tsx new file mode 100644 index 0000000000..5cd226519e --- /dev/null +++ b/examples/ExpoMessaging/app/channel/[cid]/details/index.tsx @@ -0,0 +1,94 @@ +import React, { useCallback, useContext, useState } from 'react'; + +import { useRouter } from 'expo-router'; + +import { + ChannelAddMembersModal, + ChannelAllMembersModal, + ChannelDetails, + ChannelDetailsContextProvider, + ChannelDetailsNavigationSectionType, + GetChannelDetailsNavigationItems, + GetChannelMemberActionItems, +} from 'stream-chat-expo'; + +import { AppContext } from '../../../../context/AppContext'; + +const navigationItems: { + [key in ChannelDetailsNavigationSectionType]: 'pinned' | 'images' | 'files'; +} = { + 'pinned-messages': 'pinned', + 'photos-and-videos': 'images', + files: 'files', +}; + +export default function ChannelDetailsScreen() { + const router = useRouter(); + const { channel } = useContext(AppContext); + + const onBack = useCallback(() => { + if (router.canGoBack()) { + router.back(); + } else { + router.replace('/'); + } + }, [router]); + + const getNavigationItems = useCallback( + ({ defaultItems }) => + defaultItems.map((item) => { + const subRoute = navigationItems[item.section]; + if (!subRoute || !channel?.cid) { + return item; + } + return { + ...item, + onPress: () => router.push(`/channel/${channel.cid}/details/${subRoute}`), + }; + }), + [router, channel?.cid], + ); + + const popToRoot = useCallback(() => router.replace('/'), [router]); + + const [isAddMembersVisible, setAddMembersVisible] = useState(false); + const handleAddMembersClose = useCallback(() => setAddMembersVisible(false), []); + const handleAddMembersPress = useCallback(() => { + setAllMembersVisible(false); + setAddMembersVisible(true); + }, []); + + const [isAllMembersVisible, setAllMembersVisible] = useState(false); + const handleAllMembersClose = useCallback(() => setAllMembersVisible(false), []); + const handleAllMembersPress = useCallback(() => setAllMembersVisible(true), []); + + const getChannelMemberActionItems = useCallback( + ({ defaultItems }) => defaultItems, + [], + ); + + if (!channel) { + return null; + } + + return ( + <> + + + + + + + ); +} diff --git a/examples/ExpoMessaging/app/channel/[cid]/details/pinned.tsx b/examples/ExpoMessaging/app/channel/[cid]/details/pinned.tsx new file mode 100644 index 0000000000..99f90867b2 --- /dev/null +++ b/examples/ExpoMessaging/app/channel/[cid]/details/pinned.tsx @@ -0,0 +1,61 @@ +import React, { useCallback, useContext } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { useRouter } from 'expo-router'; + +import { + ChannelDetailsContextProvider, + PinnedMessageItem, + PinnedMessageItemProps, + PinnedMessageList, + useTheme, + WithComponents, +} from 'stream-chat-expo'; + +import { ScreenHeader } from '../../../../components/ScreenHeader'; +import { AppContext } from '../../../../context/AppContext'; + +const styles = StyleSheet.create({ + flex: { flex: 1 }, +}); + +/** + * Custom pinned-message row that navigates back to the parent channel screen + * with the tapped message's id, so the message list scrolls to and highlights + * the message. Mirrors SampleApp's behavior. + */ +const PinnedMessage = (props: PinnedMessageItemProps) => { + const router = useRouter(); + const onPress = useCallback(() => { + const channelCid = props.channel?.cid; + if (!channelCid) return; + const targetId = props.message.parent_id ?? props.message.id; + router.replace(`/channel/${channelCid}?messageId=${targetId}`); + }, [props.channel, props.message.parent_id, props.message.id, router]); + + return ( + + + + ); +}; + +export default function ChannelPinnedMessagesScreen() { + useTheme(); + const { channel } = useContext(AppContext); + + if (!channel) { + return null; + } + + return ( + + + + + + + + + ); +} diff --git a/examples/ExpoMessaging/app/channel/[cid]/index.tsx b/examples/ExpoMessaging/app/channel/[cid]/index.tsx index 80598ee8d0..7db3afd0c3 100644 --- a/examples/ExpoMessaging/app/channel/[cid]/index.tsx +++ b/examples/ExpoMessaging/app/channel/[cid]/index.tsx @@ -1,16 +1,18 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { Pressable, StyleSheet, View } from 'react-native'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useHeaderHeight } from 'expo-router/react-navigation'; import type { Channel as StreamChatChannel } from 'stream-chat'; import { Channel, + ChannelAvatar, MessageComposer, - useChatContext, - ThreadContextValue, MessageList, + ThreadContextValue, + useChannelPreviewDisplayName, + useChatContext, } from 'stream-chat-expo'; import { AuthProgressLoader } from '../../../components/AuthProgressLoader'; @@ -22,6 +24,7 @@ export default function ChannelScreen() { const params = useLocalSearchParams(); const navigateThroughPushNotification = params.push_notification as string; const channelId = params.cid as string; + const messageId = params.messageId as string | undefined; const [channelFromParams, setChannelFromParams] = useState( undefined, ); @@ -42,6 +45,12 @@ export default function ChannelScreen() { const headerHeight = useHeaderHeight(); const channel = channelFromParams || channelContext; + const displayName = useChannelPreviewDisplayName(channel); + + const onOpenDetails = useCallback(() => { + if (!channel?.cid) return; + router.push(`/channel/${channel.cid}/details`); + }, [channel?.cid, router]); if (!channel) { return ; @@ -57,7 +66,7 @@ export default function ChannelScreen() { const params = Object.entries(shared_location) .map(([key, value]) => `${key}=${value}`) .join('&'); - router.push(`/map/${message.id}?${params}`); + router.push(`/map/${message?.id}?${params}`); } defaultHandler?.(); }; @@ -69,7 +78,27 @@ export default function ChannelScreen() { return ( ( + ({ + alignItems: 'center', + height: 40, + justifyContent: 'center', + opacity: pressed ? 0.5 : 1, + width: 40, + })} + > + + + ), + }} /> { diff --git a/examples/ExpoMessaging/components/ScreenHeader.tsx b/examples/ExpoMessaging/components/ScreenHeader.tsx new file mode 100644 index 0000000000..e332d3840b --- /dev/null +++ b/examples/ExpoMessaging/components/ScreenHeader.tsx @@ -0,0 +1,186 @@ +import React from 'react'; +import { + ColorValue, + StyleProp, + StyleSheet, + Text, + TouchableOpacity, + View, + ViewStyle, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Svg, { Path } from 'react-native-svg'; + +import { useRouter } from 'expo-router'; + +import { useTheme } from 'stream-chat-expo'; + +const ChevronLeft = ({ color, size = 20 }: { color: ColorValue; size?: number }) => ( + + + +); + +const HEADER_CONTENT_HEIGHT = 64; + +const styles = StyleSheet.create({ + backButton: { + alignItems: 'center', + height: 40, + justifyContent: 'center', + width: 40, + }, + centerContainer: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, + contentContainer: { + alignItems: 'center', + flexDirection: 'row', + padding: 8, + }, + leftContainer: { + width: 70, + }, + rightContainer: { + alignItems: 'flex-end', + width: 70, + }, + safeAreaContainer: { + borderBottomWidth: 1, + }, + subTitle: { + fontSize: 12, + }, + title: { + fontSize: 16, + fontWeight: '700', + }, +}); + +type BackButtonProps = { + onBack?: () => void; +}; + +const BackButton: React.FC = ({ onBack }) => { + const router = useRouter(); + const { + theme: { semantics }, + } = useTheme(); + + return ( + { + if (onBack) { + onBack(); + return; + } + if (router.canGoBack()) { + router.back(); + } else { + // If opened deeply (e.g., via push notification), fall back to root. + router.replace('/'); + } + }} + style={styles.backButton} + > + + + ); +}; + +type ScreenHeaderProps = { + titleText: string; + inSafeArea?: boolean; + LeftContent?: React.ElementType; + onBack?: () => void; + RightContent?: React.ElementType; + style?: StyleProp; + Subtitle?: React.ElementType; + subtitleText?: string; + Title?: React.ElementType; +}; + +/** + * ExpoMessaging variant of SampleApp's ScreenHeader. Uses Expo Router's + * `useRouter` for back navigation and the SDK's theme tokens. No unread-count + * badge or drawer dependency — kept lean for the example app. + */ +export const ScreenHeader: React.FC = ({ + inSafeArea, + LeftContent, + onBack, + RightContent = () => , + style, + Subtitle, + subtitleText, + Title, + titleText = 'Stream Chat', +}) => { + const { + theme: { semantics }, + } = useTheme(); + const insets = useSafeAreaInsets(); + + return ( + + + + {LeftContent ? : } + + + + {Title ? ( + + ) : ( + !!titleText && ( + <Text numberOfLines={1} style={[styles.title, { color: semantics.textPrimary }]}> + {titleText} + </Text> + ) + )} + </View> + {Subtitle ? ( + <Subtitle /> + ) : ( + !!subtitleText && ( + <Text numberOfLines={1} style={[styles.subTitle, { color: semantics.textSecondary }]}> + {subtitleText} + </Text> + ) + )} + </View> + <View style={styles.rightContainer}> + <RightContent /> + </View> + </View> + </View> + ); +}; diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index 1b64eba8ba..783c258ee5 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -51,7 +51,7 @@ "react-native-teleport": "^1.0.2", "react-native-web": "^0.21.0", "react-native-worklets": "0.8.3", - "stream-chat": "^9.44.2", + "stream-chat": "^9.47.0", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, 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 > <StreamChatProvider> - <AppOverlayProvider> - <UserSearchProvider> - <DrawerNavigator /> - </UserSearchProvider> - </AppOverlayProvider> + <UserSearchProvider> + <DrawerNavigator /> + </UserSearchProvider> </StreamChatProvider> </Chat> ); @@ -420,13 +415,8 @@ const HomeScreen = () => { options={{ headerShown: false }} /> <Stack.Screen - component={OneOnOneChannelDetailScreen} - name='OneOnOneChannelDetailScreen' - options={{ headerShown: false }} - /> - <Stack.Screen - component={GroupChannelDetailsScreen} - name='GroupChannelDetailsScreen' + component={ChannelDetailsScreen} + name='ChannelDetailsScreen' options={{ headerShown: false }} /> <Stack.Screen @@ -444,11 +434,6 @@ const HomeScreen = () => { name='ChannelPinnedMessagesScreen' options={{ headerShown: false }} /> - <Stack.Screen - component={SharedGroupsScreen} - name='SharedGroupsScreen' - options={{ headerShown: false }} - /> <Stack.Screen component={ThreadScreen} name='ThreadScreen' diff --git a/examples/SampleApp/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/examples/SampleApp/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 7353dbd1fd..0000000000 --- a/examples/SampleApp/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> - <background android:drawable="@color/ic_launcher_background"/> - <foreground android:drawable="@drawable/ic_launcher_foreground"/> -</adaptive-icon> \ No newline at end of file diff --git a/examples/SampleApp/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/examples/SampleApp/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index 7353dbd1fd..0000000000 --- a/examples/SampleApp/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> - <background android:drawable="@color/ic_launcher_background"/> - <foreground android:drawable="@drawable/ic_launcher_foreground"/> -</adaptive-icon> \ No newline at end of file diff --git a/examples/SampleApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/examples/SampleApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index a28e80c224..d18b465aea 100644 Binary files a/examples/SampleApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/examples/SampleApp/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/examples/SampleApp/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/examples/SampleApp/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index a28e80c224..d18b465aea 100644 Binary files a/examples/SampleApp/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/examples/SampleApp/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/examples/SampleApp/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/examples/SampleApp/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index dc38b6cd7b..55f31029e4 100644 Binary files a/examples/SampleApp/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/examples/SampleApp/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/examples/SampleApp/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/examples/SampleApp/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index dc38b6cd7b..55f31029e4 100644 Binary files a/examples/SampleApp/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/examples/SampleApp/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/examples/SampleApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/examples/SampleApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index c82d5e5964..6301d2bd96 100644 Binary files a/examples/SampleApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/examples/SampleApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/examples/SampleApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/examples/SampleApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index c82d5e5964..6301d2bd96 100644 Binary files a/examples/SampleApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/examples/SampleApp/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/examples/SampleApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/examples/SampleApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index e732868de1..84b3cff4d5 100644 Binary files a/examples/SampleApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/examples/SampleApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/examples/SampleApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/examples/SampleApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index e732868de1..84b3cff4d5 100644 Binary files a/examples/SampleApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/examples/SampleApp/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/examples/SampleApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/examples/SampleApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index a142d2b760..b8df92ac67 100644 Binary files a/examples/SampleApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/examples/SampleApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/examples/SampleApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/examples/SampleApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index a142d2b760..b8df92ac67 100644 Binary files a/examples/SampleApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/examples/SampleApp/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/examples/SampleApp/android/app/src/main/res/values/ic_launcher_background.xml b/examples/SampleApp/android/app/src/main/res/values/ic_launcher_background.xml deleted file mode 100644 index c5d819c5f7..0000000000 --- a/examples/SampleApp/android/app/src/main/res/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <color name="ic_launcher_background">#005FFF</color> -</resources> \ No newline at end of file diff --git a/examples/SampleApp/android/gradle/gradle-daemon-jvm.properties b/examples/SampleApp/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000000..6c1139ec06 --- /dev/null +++ b/examples/SampleApp/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 8b44306f37..472d5c20c4 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -184,7 +184,7 @@ PODS: - nanopb/encode (= 3.30910.0) - nanopb/decode (3.30910.0) - nanopb/encode (3.30910.0) - - NitroModules (0.35.9): + - NitroModules (0.31.3): - hermes-engine - RCTRequired - RCTTypeSafety @@ -207,7 +207,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - NitroSound (0.2.15): + - NitroSound (0.2.9): - hermes-engine - NitroModules - RCTRequired @@ -2476,7 +2476,7 @@ PODS: - ReactNativeDependencies - RNFBApp - Yoga - - RNGestureHandler (3.0.0): + - RNGestureHandler (2.31.2): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3232,8 +3232,8 @@ SPEC CHECKSUMS: libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - NitroModules: fa3f01c46ec724812c2a1a7074eeb528980f6a47 - NitroSound: 90d19ad981456113476388338d5b6b2a7d58999d + NitroModules: e2c4f7166ca860e21959433f70818e97be498b88 + NitroSound: cb3c7c6b9d9d4329cc0fed7e566bb58842a1f895 op-sqlite: 8e86ae95b132eb4364492e44e848a5272f3ca582 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 @@ -3322,7 +3322,7 @@ SPEC CHECKSUMS: RNFastImage: 14580cef91660b889645fb9e87f58a53621db993 RNFBApp: 3b942e786ca88524ba17df665a1a360fb3eee525 RNFBMessaging: b82ba0933288d710f5371f57d3115092abf64903 - RNGestureHandler: ae4b9960c2e7d0fb3991255345bf424cca8e09e4 + RNGestureHandler: a97cc64efbfcb7a53969a38310a189a3d5246c65 RNNotifee: 5e3b271e8ea7456a36eec994085543c9adca9168 RNReactNativeHapticFeedback: 9dc72312c12cb53ee240b5b7aae1e167f3d940a6 RNReanimated: 8aac6baab55e39ca4e02afd69f77fb127b26520c diff --git a/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/1024.png b/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/1024.png index 6b17f023c3..8eb45ee983 100644 Binary files a/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/1024.png and b/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/1024.png differ diff --git a/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/120.png b/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/120.png index 9a9375f30f..e1c412d02a 100644 Binary files a/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/120.png and b/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/120.png differ diff --git a/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/180.png b/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/180.png index 900a1d98c4..5aa4dc18a4 100644 Binary files a/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/180.png and b/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/180.png differ diff --git a/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/40.png b/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/40.png index f66fcb49d6..84912a9ecd 100644 Binary files a/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/40.png and b/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/40.png differ diff --git a/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/58.png b/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/58.png index 6e4e631124..fa1e4c4e8e 100644 Binary files a/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/58.png and b/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/58.png differ diff --git a/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/60.png b/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/60.png index 9c4234eb31..d41e3db03f 100644 Binary files a/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/60.png and b/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/60.png differ diff --git a/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/80.png b/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/80.png index 98e65690c8..857d7fd966 100644 Binary files a/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/80.png and b/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/80.png differ diff --git a/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/87.png b/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/87.png index 7604e6967e..73e4b0a307 100644 Binary files a/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/87.png and b/examples/SampleApp/ios/SampleApp/Images.xcassets/AppIcon.appiconset/87.png differ diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 4923df0bed..903b94629f 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -50,12 +50,12 @@ "react": "19.2.3", "react-native": "0.85.3", "react-native-blob-util": "^0.24.9", - "react-native-gesture-handler": "patch:react-native-gesture-handler@npm%3A3.0.0#~/.yarn/patches/react-native-gesture-handler-npm-3.0.0-8eca038751.patch", + "react-native-gesture-handler": "^2.31.2", "react-native-haptic-feedback": "^3.0.0", "react-native-image-picker": "^8.2.1", "react-native-maps": "^1.27.2", - "react-native-nitro-modules": "^0.35.9", - "react-native-nitro-sound": "^0.2.15", + "react-native-nitro-modules": "0.31.3", + "react-native-nitro-sound": "0.2.9", "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "^5.8.0", "react-native-screens": "^4.25.2", @@ -64,7 +64,7 @@ "react-native-teleport": "^1.1.7", "react-native-video": "^6.19.2", "react-native-worklets": "^0.8.3", - "stream-chat": "^9.44.2", + "stream-chat": "^9.47.0", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/src/components/AddMembersBottomSheet.tsx b/examples/SampleApp/src/components/AddMembersBottomSheet.tsx deleted file mode 100644 index f3a4a84759..0000000000 --- a/examples/SampleApp/src/components/AddMembersBottomSheet.tsx +++ /dev/null @@ -1,359 +0,0 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { - ActivityIndicator, - Alert, - Pressable, - StyleSheet, - Text, - TextInput, - View, -} from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; - -import type { Channel, UserResponse } from 'stream-chat'; -import { - BottomSheetModal, - Checkmark, - StreamBottomSheetModalFlatList, - UserAvatar, - useStableCallback, - useTheme, -} from 'stream-chat-react-native'; - -import { usePaginatedUsers } from '../hooks/usePaginatedUsers'; -import { CircleClose } from '../icons/CircleClose'; -import { Close } from '../icons/Close'; - -import { UserSearch } from '../icons/UserSearch'; - -type AddMembersBottomSheetProps = { - channel: Channel; - onClose: () => void; - visible: boolean; -}; - -const keyExtractor = (item: UserResponse) => item.id; - -const SelectionCircle = React.memo(({ selected }: { selected: boolean }) => { - const { - theme: { semantics }, - } = useTheme(); - - if (selected) { - return ( - <View - style={[ - selectionStyles.circle, - { backgroundColor: semantics.accentPrimary, borderColor: semantics.accentPrimary }, - ]} - > - <Checkmark height={14} width={14} stroke='white' /> - </View> - ); - } - - return <View style={[selectionStyles.circle, { borderColor: semantics.borderCoreDefault }]} />; -}); - -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 ( - <Pressable - onPress={() => toggleUser(item)} - style={({ pressed }) => [styles.userRow, pressed && { opacity: 0.7 }]} - > - <View style={styles.userRowLeading}> - <UserAvatar user={item} size='sm' showBorder /> - <Text style={[styles.userName, { color: semantics.textPrimary }]} numberOfLines={1}> - {item.name || item.id} - </Text> - </View> - <SelectionCircle selected={isSelected} /> - </Pressable> - ); - }, - [selectedUserIds, semantics.textPrimary, styles, toggleUser], - ); - - const initialLoadComplete = initialResults !== null; - - const EmptyComponent = useCallback(() => { - if (loading && !initialLoadComplete) { - return ( - <View style={styles.emptyState}> - <ActivityIndicator size='small' /> - </View> - ); - } - return ( - <View style={styles.emptyState}> - <UserSearch height={20} width={20} stroke={semantics.textSecondary} /> - <Text style={[styles.emptyText, { color: semantics.textSecondary }]}>No user found</Text> - </View> - ); - }, [loading, initialLoadComplete, semantics.textSecondary, styles]); - - return ( - <BottomSheetModal visible={visible} onClose={handleClose}> - <SafeAreaView edges={['bottom']} style={styles.safeArea}> - <View style={styles.header}> - <Pressable - onPress={handleClose} - style={[styles.iconButton, { borderColor: semantics.borderCoreDefault }]} - > - <Close height={20} width={20} pathFill={semantics.textPrimary} /> - </Pressable> - - <Text style={[styles.title, { color: semantics.textPrimary }]}>Add Members</Text> - - <Pressable - disabled={!hasSelection || adding} - onPress={handleConfirm} - style={[ - styles.confirmButton, - { - backgroundColor: hasSelection - ? semantics.accentPrimary - : semantics.backgroundUtilityDisabled, - }, - ]} - > - {adding ? ( - <ActivityIndicator color='white' size='small' /> - ) : ( - <Checkmark - height={20} - width={20} - stroke={hasSelection ? 'white' : semantics.textSecondary} - /> - )} - </Pressable> - </View> - - <View style={styles.searchContainer}> - <View - style={[ - styles.searchInput, - { - borderColor: searchFocused - ? semantics.accentPrimary - : semantics.borderCoreDefault, - }, - ]} - > - <UserSearch height={20} width={20} stroke={semantics.textSecondary} /> - <TextInput - autoCapitalize='none' - autoCorrect={false} - onBlur={handleSearchBlur} - onChangeText={onChangeSearchText} - onFocus={handleSearchFocus} - placeholder='Search' - placeholderTextColor={semantics.textSecondary} - style={[styles.searchTextInput, { color: semantics.textPrimary }]} - value={searchText} - /> - {searchText.length > 0 ? ( - <Pressable onPress={clearText}> - <CircleClose height={20} width={20} /> - </Pressable> - ) : null} - </View> - </View> - - <StreamBottomSheetModalFlatList - data={filteredResults} - keyExtractor={keyExtractor} - keyboardDismissMode='interactive' - keyboardShouldPersistTaps='handled' - ListEmptyComponent={EmptyComponent} - onEndReached={loadMore} - renderItem={renderItem} - contentContainerStyle={styles.listContent} - /> - </SafeAreaView> - </BottomSheetModal> - ); - }, -); - -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<StackNavigatorParamList, 'GroupChannelDetailsScreen'>; - 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<ChannelMemberResponse | null>(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 }) => ( - <MemberListItem - member={item} - isCurrentUser={item.user?.id === currentUserId} - isOwner={channelCreatorId === item.user?.id} - onPress={() => handleMemberPress(item)} - /> - ), - [channelCreatorId, currentUserId, handleMemberPress], - ); - - return ( - <BottomSheetModal visible={visible} onClose={stableOnClose}> - <SafeAreaView edges={['bottom']} style={styles.safeArea}> - <View style={styles.header}> - <Pressable - onPress={stableOnClose} - style={[styles.iconButton, { borderColor: semantics.borderCoreDefault }]} - > - <Close height={20} width={20} pathFill={semantics.textPrimary} /> - </Pressable> - - <Text style={[styles.title, { color: semantics.textPrimary }]}> - {`${memberCount} Members`} - </Text> - - {onAddMember ? ( - <Pressable - onPress={onAddMember} - style={[styles.iconButton, { borderColor: semantics.borderCoreDefault }]} - > - <UserAdd height={20} width={20} stroke={semantics.textPrimary} /> - </Pressable> - ) : ( - <View style={styles.iconButtonPlaceholder} /> - )} - </View> - - <StreamBottomSheetModalFlatList - data={members} - keyExtractor={keyExtractor} - renderItem={renderItem} - contentContainerStyle={styles.listContent} - /> - </SafeAreaView> - <ContactDetailBottomSheet - channel={channel} - member={selectedMember} - navigation={navigation} - onClose={closeContactDetail} - visible={!!selectedMember} - /> - </BottomSheetModal> - ); - }, -); - -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 ( - <View style={styles.container}> - {avatar} - <View style={styles.heading}> - <View style={styles.titleRow}> - <Text style={[styles.title, { color: semantics.textPrimary }]} numberOfLines={2}> - {title} - </Text> - {muted ? <ChannelPreviewMutedStatus /> : null} - </View> - {subtitle ? ( - <Text style={[styles.subtitle, { color: semantics.textSecondary }]} numberOfLines={1}> - {subtitle} - </Text> - ) : null} - </View> - </View> - ); - }, -); - -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/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<number>; - 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<ChannelMemberResponse>(channel.state.members) - .slice(0) - .reduce<string>((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<ChannelMemberResponse>(channel.state.members).filter( - (member) => member.user?.id !== clientId, - ) - : []; - - const [confirmationData, setConfirmationData] = useState<ConfirmationData | null>(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 ( - <BottomSheetModal visible={!!visible} onClose={onClose}> - <SafeAreaView edges={['bottom']}> - {channel && ( - <> - <View style={styles.detailsContainer}> - <Text numberOfLines={1} style={[styles.channelName, { color: black }]}> - {channelName} - </Text> - <Text style={[styles.channelStatus, { color: grey }]}> - {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<ChannelMemberResponse>(channel.state.members).filter( - (member) => !!member.user?.online, - ).length - } Online`} - </Text> - <FlatList - contentContainerStyle={styles.flatListContent} - data={Object.values<ChannelMemberResponse>(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 ? ( - <View style={styles.userItemContainer}> - <UserAvatar user={item} size='lg' showOnlineIndicator={item.online} /> - - <Text style={[styles.userName, { color: black }]}> - {item.name || item.id || ''} - </Text> - </View> - ) : null - } - style={styles.flatList} - /> - </View> - <Pressable onPress={viewInfo}> - <View - style={[ - styles.row, - { - borderTopColor: semantics.borderCoreDefault, - }, - ]} - > - <View style={styles.rowInner}> - <User pathFill={grey} /> - </View> - <Text style={[styles.rowText, { color: black }]}>View info</Text> - </View> - </Pressable> - <Pressable onPress={pinUnpin}> - <View - style={[ - styles.row, - { - borderTopColor: semantics.borderCoreDefault, - }, - ]} - > - <View style={styles.rowInner}> - <Pin height={24} width={24} /> - </View> - <Text style={[styles.rowText, { color: black }]}> - {membership?.pinned_at ? 'Unpin' : 'Pin'} - </Text> - </View> - </Pressable> - <Pressable onPress={archiveUnarchive}> - <View - style={[ - styles.row, - { - borderTopColor: semantics.borderCoreDefault, - }, - ]} - > - <View style={styles.rowInner}> - <Archive height={24} width={24} /> - </View> - <Text style={[styles.rowText, { color: black }]}> - {membership?.archived_at ? 'Unarchive' : 'Archive'} - </Text> - </View> - </Pressable> - - {otherMembers.length > 1 && ( - <Pressable onPress={leaveGroup}> - <View style={[styles.row, { borderTopColor: semantics.borderCoreDefault }]}> - <View style={styles.rowInner}> - <UserMinus pathFill={grey} /> - </View> - <Text style={[styles.rowText, { color: black }]}>Leave Group</Text> - </View> - </Pressable> - )} - <Pressable onPress={deleteConversation}> - <View - style={[ - styles.row, - { - borderTopColor: semantics.borderCoreDefault, - }, - ]} - > - <View style={styles.rowInner}> - <Delete height={24} width={24} stroke={accent_red} /> - </View> - <Text style={[styles.rowText, { color: accent_red }]}>Delete conversation</Text> - </View> - </Pressable> - <Pressable onPress={cancel}> - <View - style={[ - styles.lastRow, - { - borderBottomColor: semantics.borderCoreDefault, - borderTopColor: semantics.borderCoreDefault, - }, - ]} - > - <View style={styles.rowInner}> - <CircleClose pathFill={grey} /> - </View> - <Text style={[styles.rowText, { color: black }]}>Cancel</Text> - </View> - </Pressable> - </> - )} - </SafeAreaView> - <ConfirmationBottomSheet - cancelText={confirmationData?.cancelText} - confirmText={confirmationData?.confirmText} - onClose={closeConfirmation} - onConfirm={confirmationData?.onConfirm} - subtext={confirmationData?.subtext} - title={confirmationData?.title} - visible={!!confirmationData} - /> - </BottomSheetModal> - ); -}; diff --git a/examples/SampleApp/src/components/ChannelPreview.tsx b/examples/SampleApp/src/components/ChannelPreview.tsx deleted file mode 100644 index 927531b2de..0000000000 --- a/examples/SampleApp/src/components/ChannelPreview.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import { StyleSheet, View } from 'react-native'; - -import { - ChannelPreviewStatus, - ChannelPreviewStatusProps, - Pin, - useChannelMembershipState, - useTheme, -} from 'stream-chat-react-native'; - -const styles = StyleSheet.create({ - leftSwipeableButton: { - paddingLeft: 16, - paddingRight: 8, - paddingVertical: 20, - }, - rightSwipeableButton: { - paddingLeft: 8, - paddingRight: 16, - paddingVertical: 20, - }, - swipeableContainer: { - alignItems: 'center', - flexDirection: 'row', - }, - statusContainer: { - display: 'flex', - flexDirection: 'row', - }, - pinIconContainer: { - marginLeft: 8, - }, -}); - -export const CustomChannelPreviewStatus = (props: ChannelPreviewStatusProps) => { - const { channel } = props; - - const membership = useChannelMembershipState(channel); - const { - theme: { semantics }, - } = useTheme(); - - return ( - <View style={styles.statusContainer}> - <ChannelPreviewStatus {...props} /> - {membership.pinned_at && ( - <View style={styles.pinIconContainer}> - <Pin height={24} width={24} stroke={semantics.textSecondary} /> - </View> - )} - </View> - ); -}; 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 ( - <BottomSheetModal visible={visible} onClose={stableOnClose} height={SHEET_HEIGHT}> - <SafeAreaView edges={['bottom']} style={styles.safeArea}> - <View style={styles.description}> - {isLeave ? ( - <UserMinus pathFill={semantics.textSecondary} /> - ) : ( - <Delete height={20} width={20} stroke={semantics.accentError} /> - )} - <Text style={[styles.title, { color: semantics.textPrimary }]}>{title}</Text> - {subtext ? ( - <Text style={[styles.subtext, { color: semantics.textPrimary }]}>{subtext}</Text> - ) : null} - </View> - <View style={[styles.actions, { borderTopColor: semantics.borderCoreDefault }]}> - <Pressable onPress={handleCancel} style={styles.actionButton}> - <Text style={[styles.actionText, { color: semantics.textSecondary }]}> - {cancelText} - </Text> - </Pressable> - <Pressable onPress={handleConfirm} style={styles.actionButton}> - <Text style={[styles.actionText, { color: semantics.accentError }]}> - {confirmText} - </Text> - </Pressable> - </View> - </SafeAreaView> - </BottomSheetModal> - ); - }, -); - -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<StackNavigatorParamList, 'GroupChannelDetailsScreen'>; - 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 ( - <BottomSheetModal visible={visible} onClose={stableOnClose} height={SHEET_HEIGHT}> - <SafeAreaView edges={['bottom']} style={styles.safeArea}> - <View style={styles.header}> - <UserAvatar user={user} size='lg' showOnlineIndicator={user.online} showBorder /> - <View style={styles.headerText}> - <Text style={[styles.name, { color: semantics.textPrimary }]} numberOfLines={1}> - {user.name || user.id} - </Text> - {activityStatus ? ( - <Text style={[styles.status, { color: semantics.textTertiary }]} numberOfLines={1}> - {activityStatus} - </Text> - ) : null} - </View> - </View> - - <ListItem - icon={<Message height={20} width={20} fill={semantics.textPrimary} />} - label='Send Direct Message' - onPress={sendDirectMessage} - /> - <ListItem - icon={<Mute height={20} width={20} fill={semantics.textPrimary} />} - label={isMuted ? 'Unmute User' : 'Mute User'} - onPress={muteUser} - /> - <ListItem - icon={<CircleBan height={20} width={20} stroke={semantics.textPrimary} />} - label='Block User' - onPress={blockUser} - /> - </SafeAreaView> - </BottomSheetModal> - ); - }, -); - -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/EditGroupBottomSheet.tsx b/examples/SampleApp/src/components/EditGroupBottomSheet.tsx deleted file mode 100644 index ae7fb624cc..0000000000 --- a/examples/SampleApp/src/components/EditGroupBottomSheet.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { - ActivityIndicator, - Alert, - Pressable, - StyleSheet, - Text, - TextInput, - View, -} from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; - -import type { Channel } from 'stream-chat'; -import { - BottomSheetModal, - ChannelAvatar, - Checkmark, - useStableCallback, - useTheme, -} from 'stream-chat-react-native'; - -import { Close } from '../icons/Close'; - -type EditGroupBottomSheetProps = { - channel: Channel; - onClose: () => void; - visible: boolean; -}; - -export const EditGroupBottomSheet = React.memo( - ({ channel, onClose, visible }: EditGroupBottomSheetProps) => { - const { - theme: { semantics }, - } = useTheme(); - const styles = useStyles(); - - const [name, setName] = useState((channel.data?.name as string) ?? ''); - const [saving, setSaving] = useState(false); - const [inputFocused, setInputFocused] = useState(false); - - const stableOnClose = useStableCallback(onClose); - - const handleClose = useCallback(() => { - setName((channel.data?.name as string) ?? ''); - setInputFocused(false); - stableOnClose(); - }, [channel.data?.name, stableOnClose]); - - const hasChanges = name.trim() !== ((channel.data?.name as string) ?? ''); - - const handleConfirm = useCallback(async () => { - const trimmed = name.trim(); - if (!trimmed || !hasChanges) return; - - setSaving(true); - try { - await channel.updatePartial({ set: { name: trimmed } }); - setInputFocused(false); - stableOnClose(); - } catch (error) { - if (error instanceof Error) { - Alert.alert('Error', error.message); - } - } - setSaving(false); - }, [channel, hasChanges, name, stableOnClose]); - - const handleFocus = useCallback(() => setInputFocused(true), []); - const handleBlur = useCallback(() => setInputFocused(false), []); - - return ( - <BottomSheetModal visible={visible} onClose={handleClose}> - <SafeAreaView edges={['bottom']} style={styles.safeArea}> - <View style={styles.header}> - <Pressable - onPress={handleClose} - style={[styles.iconButton, { borderColor: semantics.borderCoreDefault }]} - > - <Close height={20} width={20} pathFill={semantics.textPrimary} /> - </Pressable> - - <Text style={[styles.title, { color: semantics.textPrimary }]}>Edit</Text> - - <Pressable - disabled={!hasChanges || saving} - onPress={handleConfirm} - style={[ - styles.confirmButton, - { - backgroundColor: hasChanges - ? semantics.accentPrimary - : semantics.backgroundUtilityDisabled, - }, - ]} - > - {saving ? ( - <ActivityIndicator color='white' size='small' /> - ) : ( - <Checkmark - height={20} - width={20} - stroke={hasChanges ? 'white' : semantics.textSecondary} - /> - )} - </Pressable> - </View> - - <View style={styles.body}> - <View style={styles.avatarSection}> - <ChannelAvatar channel={channel} size='2xl' /> - {/* TODO: Avatar changing will be done later */} - <Pressable - onPress={() => Alert.alert('Coming Soon', 'Will be implemented in future')} - style={styles.uploadButton} - > - <Text style={[styles.uploadLabel, { color: semantics.accentPrimary }]}>Upload</Text> - </Pressable> - </View> - - <View style={styles.inputContainer}> - <TextInput - autoCorrect={false} - onBlur={handleBlur} - onChangeText={setName} - onFocus={handleFocus} - placeholder='Channel name' - placeholderTextColor={semantics.textSecondary} - style={[ - styles.textInput, - { - borderColor: inputFocused - ? semantics.accentPrimary - : semantics.borderCoreDefault, - color: semantics.textPrimary, - }, - ]} - value={name} - /> - </View> - </View> - </SafeAreaView> - </BottomSheetModal> - ); - }, -); - -EditGroupBottomSheet.displayName = 'EditGroupBottomSheet'; - -const useStyles = () => { - return useMemo( - () => - StyleSheet.create({ - avatarSection: { - alignItems: 'center', - gap: 8, - }, - body: { - gap: 24, - paddingHorizontal: 16, - paddingTop: 24, - }, - confirmButton: { - alignItems: 'center', - borderRadius: 9999, - height: 40, - justifyContent: 'center', - width: 40, - }, - 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, - }, - inputContainer: { - minHeight: 48, - }, - safeArea: { - flex: 1, - }, - textInput: { - borderRadius: 12, - borderWidth: 1, - fontSize: 17, - lineHeight: 20, - minHeight: 48, - paddingHorizontal: 16, - paddingVertical: 12, - }, - title: { - flex: 1, - fontSize: 17, - fontWeight: '600', - lineHeight: 20, - textAlign: 'center', - }, - uploadButton: { - alignItems: 'center', - justifyContent: 'center', - minHeight: 40, - paddingHorizontal: 16, - paddingVertical: 10, - }, - uploadLabel: { - fontSize: 17, - fontWeight: '600', - 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 ( - <Pressable - disabled={!onPress} - onPress={onPress} - style={({ pressed }) => [styles.outerContainer, pressed && { opacity: 0.7 }]} - > - <View style={styles.contentContainer}> - {icon} - <Text style={[styles.label, { color: labelColor }]} numberOfLines={1}> - {label} - </Text> - {trailing ? <View style={styles.trailing}>{trailing}</View> : null} - </View> - </Pressable> - ); - }, -); - -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 ( - <Pressable - disabled={!onPress} - onPress={onPress} - style={({ pressed }) => [styles.outerContainer, pressed && { opacity: 0.7 }]} - > - <View style={styles.contentContainer}> - <View style={styles.leading}> - <UserAvatar user={user} size='md' showOnlineIndicator={user.online} showBorder /> - <View style={styles.textContainer}> - <Text style={[styles.name, { color: semantics.textPrimary }]} numberOfLines={1}> - {displayName} - </Text> - {activityStatus ? ( - <Text style={[styles.status, { color: semantics.textTertiary }]} numberOfLines={1}> - {activityStatus} - </Text> - ) : null} - </View> - </View> - {isMuted ? <Mute height={16} width={16} fill={semantics.textTertiary} /> : null} - {isOwner ? ( - <Text style={[styles.roleLabel, { color: semantics.textTertiary }]}>Admin</Text> - ) : null} - </View> - </Pressable> - ); - }, -); - -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<ViewStyle>; -}; - -export const OverlayBackdrop = (props: OverlayBackdropProps): React.ReactNode => { - const { style = {} } = props; - useTheme(); - const { overlay } = useLegacyColors(); - return <View style={[{ backgroundColor: overlay }, style]} />; -}; diff --git a/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx b/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx index eb03b185fa..8154842690 100644 --- a/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx +++ b/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx @@ -6,7 +6,6 @@ import type { ComponentOverrides } from 'stream-chat-react-native'; import { useTheme } from 'stream-chat-react-native'; import { CustomAttachmentPickerContent } from './AttachmentPickerContent'; -import { CustomChannelPreviewStatus } from './ChannelPreview'; import { FastImageAdapter } from './FastImageAdapter'; import { MessageLocation } from './LocationSharing/MessageLocation'; import type { MessageOverlayBackdropConfigItem } from './SecretMenu'; @@ -49,7 +48,6 @@ export const useSampleAppComponentOverrides = ( ImageComponent: FastImageAdapter, MessageLocation, NetworkDownIndicator: RenderNull, - ChannelPreviewStatus: CustomChannelPreviewStatus, ...(messageOverlayBackdrop === 'blurview' ? { MessageOverlayBackground: MessageOverlayBlurBackground } : {}), diff --git a/examples/SampleApp/src/components/ScreenHeader.tsx b/examples/SampleApp/src/components/ScreenHeader.tsx index 9419bff9e4..6e2f7113cc 100644 --- a/examples/SampleApp/src/components/ScreenHeader.tsx +++ b/examples/SampleApp/src/components/ScreenHeader.tsx @@ -6,18 +6,20 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { DrawerNavigationProp } from '@react-navigation/drawer'; import { CompositeNavigationProp, useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { useTheme } from 'stream-chat-react-native'; +import { ChevronLeft, useTheme } from 'stream-chat-react-native'; import { ChannelsUnreadCountBadge } from './UnreadCountBadge'; -import { GoBack } from '../icons/GoBack'; import { useLegacyColors } from '../theme/useLegacyColors'; import type { DrawerNavigatorParamList, StackNavigatorParamList } from '../types'; const styles = StyleSheet.create({ backButton: { - paddingVertical: 8, + alignItems: 'center', + height: 40, + justifyContent: 'center', + width: 40, }, backButtonUnreadCount: { left: 25, @@ -62,6 +64,9 @@ export const BackButton: React.FC<{ showUnreadCountBadge?: boolean; }> = ({ onBack, showUnreadCountBadge }) => { const navigation = useNavigation<ScreenHeaderNavigationProp>(); + const { + theme: { semantics }, + } = useTheme(); return ( <TouchableOpacity @@ -83,7 +88,7 @@ export const BackButton: React.FC<{ }} style={styles.backButton} > - <GoBack /> + <ChevronLeft height={20} stroke={semantics.textSecondary} width={20} /> {!!showUnreadCountBadge && ( <View style={styles.backButtonUnreadCount}> <ChannelsUnreadCountBadge /> @@ -106,7 +111,7 @@ type ScreenHeaderProps = { Title?: React.ElementType; }; -const HEADER_CONTENT_HEIGHT = 55; +const HEADER_CONTENT_HEIGHT = 64; export const ScreenHeader: React.FC<ScreenHeaderProps> = (props) => { const { 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<number>; - 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<ViewStyle>(() => ({ - transform: [ - { - translateY: translateY.value > 0 ? translateY.value : 0, - }, - ], - })); - - const showScreenStyle = useAnimatedStyle<ViewStyle>(() => ({ - 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<ConfirmationData | null>(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 ( - <Animated.View pointerEvents={visible ? 'auto' : 'none'} style={StyleSheet.absoluteFill}> - <GestureDetector gesture={pan}> - <Animated.View style={StyleSheet.absoluteFill}> - <GestureDetector gesture={tap}> - <Animated.View - onLayout={({ - nativeEvent: { - layout: { height }, - }, - }) => { - viewHeight.value = height; - }} - style={[styles.container, panStyle]} - > - <Animated.View - style={[styles.containerInner, { backgroundColor: white }, showScreenStyle]} - > - <SafeAreaView edges={['bottom']}> - {channel && ( - <> - <View style={styles.detailsContainer}> - <Text numberOfLines={1} style={[styles.channelName, { color: black }]}> - {member.user?.name || member.user?.id || ''} - </Text> - <Text style={[styles.channelStatus, { color: grey }]}> - {member.user?.online - ? 'Online' - : `Last Seen ${dayjs(member.user?.last_active).fromNow()}`} - </Text> - <View style={styles.userItemContainer}> - <UserAvatar - user={member.user} - size='lg' - showBorder - showOnlineIndicator={member.user?.online} - /> - </View> - </View> - <Pressable onPress={viewInfo}> - <View - style={[ - styles.row, - { - borderTopColor: semantics.borderCoreDefault, - }, - ]} - > - <View style={styles.rowInner}> - <User pathFill={grey} /> - </View> - <Text style={[styles.rowText, { color: black }]}>View info</Text> - </View> - </Pressable> - <Pressable onPress={messageUser}> - <View - style={[ - styles.row, - { - borderTopColor: semantics.borderCoreDefault, - }, - ]} - > - <View style={styles.rowInner}> - <Message pathFill={grey} /> - </View> - <Text style={[styles.rowText, { color: black }]}>Message</Text> - </View> - </Pressable> - {channelCreatorId === chatClient?.user?.id ? ( - <Pressable onPress={removeFromGroup}> - <View - style={[ - styles.row, - { - borderTopColor: semantics.borderCoreDefault, - }, - ]} - > - <View style={styles.rowInner}> - <UserMinus pathFill={accent_red} /> - </View> - <Text style={[styles.rowText, { color: accent_red }]}> - Remove From Group - </Text> - </View> - </Pressable> - ) : null} - <Pressable onPress={cancel}> - <View - style={[ - styles.lastRow, - { - borderBottomColor: semantics.borderCoreDefault, - borderTopColor: semantics.borderCoreDefault, - }, - ]} - > - <View style={styles.rowInner}> - <CircleClose pathFill={grey} /> - </View> - <Text style={[styles.rowText, { color: black }]}>Cancel</Text> - </View> - </Pressable> - </> - )} - </SafeAreaView> - </Animated.View> - </Animated.View> - </GestureDetector> - </Animated.View> - </GestureDetector> - <ConfirmationBottomSheet - cancelText={confirmationData?.cancelText} - confirmText={confirmationData?.confirmText} - onClose={closeConfirmation} - onConfirm={confirmationData?.onConfirm} - subtext={confirmationData?.subtext} - title={confirmationData?.title} - visible={!!confirmationData} - /> - </Animated.View> - ); -}; 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<React.SetStateAction<Overlay>>; -}; -export const AppOverlayContext = React.createContext<AppOverlayContextValue>( - {} as AppOverlayContextValue, -); - -export type AppOverlayProviderProps = { - value?: Partial<AppOverlayContextValue>; -}; - -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<AppOverlayContextValue>; - }>, -) => { - 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 ( - <AppOverlayContext.Provider value={overlayContext}> - <ChannelInfoOverlayProvider> - <UserInfoOverlayProvider> - {children} - <Animated.View - pointerEvents={overlay === 'none' ? 'none' : 'auto'} - style={[StyleSheet.absoluteFill, overlayStyle]} - > - <OverlayBackdrop style={[StyleSheet.absoluteFill, { height, width }]} /> - </Animated.View> - <UserInfoOverlay overlayOpacity={overlayOpacity} visible={overlay === 'userInfo'} /> - <ChannelInfoOverlay overlayOpacity={overlayOpacity} visible={overlay === 'channelInfo'} /> - </UserInfoOverlayProvider> - </ChannelInfoOverlayProvider> - </AppOverlayContext.Provider> - ); -}; 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<Pick<ChannelContextValue, 'channel'>> & { - clientId?: string; - membership?: ChannelState['membership']; - navigation?: ChannelListScreenNavigationProp; -}; - -export type ChannelInfoOverlayContextValue = { - reset: () => void; - setData: React.Dispatch<React.SetStateAction<ChannelInfoOverlayData>>; - 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 ( - <ChannelInfoOverlayContext.Provider - value={channelInfoOverlayContext as ChannelInfoOverlayContextValue} - > - {children} - </ChannelInfoOverlayContext.Provider> - ); -}; - -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<Pick<ChannelContextValue, 'channel'>> & { - member?: ChannelState['members'][0]; - navigation?: GroupChannelDetailsScreenNavigationProp; -}; - -export type UserInfoOverlayContextValue = { - reset: () => void; - setData: React.Dispatch<React.SetStateAction<UserInfoOverlayData>>; - 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 ( - <UserInfoOverlayContext.Provider value={userInfoOverlayContext as UserInfoOverlayContextValue}> - {children} - </UserInfoOverlayContext.Provider> - ); -}; - -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/usePaginatedAttachments.ts b/examples/SampleApp/src/hooks/usePaginatedAttachments.ts deleted file mode 100644 index 0ed1769df0..0000000000 --- a/examples/SampleApp/src/hooks/usePaginatedAttachments.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -import type { Channel, MessageResponse } from 'stream-chat'; - -import { useAppContext } from '../context/AppContext'; - -export const usePaginatedAttachments = (channel: Channel, attachmentType: string) => { - const { chatClient } = useAppContext(); - const offset = useRef(0); - const hasMoreResults = useRef(true); - const queryInProgress = useRef(false); - const [loading, setLoading] = useState(true); - const [messages, setMessages] = useState<MessageResponse[]>([]); - - const fetchAttachments = async () => { - if (queryInProgress.current) { - return; - } - - setLoading(true); - - try { - queryInProgress.current = true; - - offset.current = offset.current + messages.length; - - if (!hasMoreResults.current) { - queryInProgress.current = false; - setLoading(false); - return; - } - - // TODO: Use this when support for attachment_type is ready. - const res = await chatClient?.search( - { - cid: { $in: [channel.cid] }, - }, - { 'attachments.type': { $in: [attachmentType] } }, - { - limit: 10, - offset: offset.current, - }, - ); - - const newMessages = res?.results.map((r) => r.message); - - if (!newMessages) { - queryInProgress.current = false; - setLoading(false); - return; - } - - setMessages((existingMessages) => existingMessages.concat(newMessages)); - - if (newMessages.length < 10) { - hasMoreResults.current = false; - } - } catch (e) { - console.warn('An error has occurred while fetching attachments: ', e); - // do nothing; - } - queryInProgress.current = false; - setLoading(false); - }; - - const loadMore = () => { - fetchAttachments(); - }; - - useEffect(() => { - fetchAttachments(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return { - loading, - loadMore, - messages, - }; -}; 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<IconProps> = ({ height = 512, width = 512 }) => { - const { grey } = useLegacyColors(); - - return ( - <Svg height={height} viewBox={'0 0 512 512'} width={width}> - <Path - d='M32 32l448 0c17.7 0 32 14.3 32 32l0 32c0 17.7-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96L0 64C0 46.3 14.3 32 32 32zm0 128l448 0 0 256c0 35.3-28.7 64-64 64L96 480c-35.3 0-64-28.7-64-64l0-256zm128 80c0 8.8 7.2 16 16 16l160 0c8.8 0 16-7.2 16-16s-7.2-16-16-16l-160 0c-8.8 0-16 7.2-16 16z' - fill={grey} - /> - </Svg> - ); -}; diff --git a/examples/SampleApp/src/icons/GoBack.tsx b/examples/SampleApp/src/icons/GoBack.tsx deleted file mode 100644 index f3c35d7c10..0000000000 --- a/examples/SampleApp/src/icons/GoBack.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import { I18nManager } from 'react-native'; -import Svg, { G, Path } from 'react-native-svg'; - -import { useLegacyColors } from '../theme/useLegacyColors'; -import { IconProps } from '../utils/base'; - -export const GoBack: React.FC<IconProps> = ({ height = 24, width = 24 }) => { - const { black } = useLegacyColors(); - - return ( - <Svg fill='none' height={height} viewBox='0 0 24 24' width={width}> - <G transform={I18nManager.isRTL ? 'matrix(-1 0 0 1 24 0)' : undefined}> - <Path - clipRule='evenodd' - d='M15.694 18.6943C16.102 18.2867 16.102 17.6259 15.694 17.2184L10.4699 12L15.694 6.78165C16.102 6.37408 16.102 5.71326 15.694 5.30568C15.2859 4.89811 14.6244 4.8981 14.2164 5.30568L8.30602 11.2096C8.08861 11.4267 7.98704 11.7158 8.00132 12.0002C7.98713 12.2844 8.0887 12.5733 8.30603 12.7904L14.2164 18.6943C14.6244 19.1019 15.2859 19.1019 15.694 18.6943Z' - fill={black} - fillRule='evenodd' - /> - </G> - </Svg> - ); -}; 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 ( + <Svg fill='none' height={height ?? size} viewBox='0 0 20 20' width={width ?? size} {...rest}> + <G transform={I18nManager.isRTL ? 'matrix(-1 0 0 1 20 0)' : undefined}> + <Path + d='M6.24431 16.4932C7.81972 17.405 9.67297 17.7127 11.4585 17.359C13.2441 17.0053 14.84 16.0143 15.9489 14.5708C17.0578 13.1273 17.6038 11.3298 17.4852 9.51341C17.3667 7.69704 16.5916 5.98577 15.3045 4.69866C14.0174 3.41156 12.3061 2.63646 10.4897 2.51789C8.67333 2.39932 6.87582 2.94537 5.43231 4.05422C3.9888 5.16308 2.99781 6.75906 2.64412 8.54461C2.29042 10.3302 2.59815 12.1834 3.50993 13.7588L2.53259 16.6768C2.49586 16.7869 2.49053 16.9051 2.5172 17.0181C2.54386 17.131 2.60146 17.2344 2.68355 17.3165C2.76563 17.3985 2.86895 17.4561 2.98194 17.4828C3.09492 17.5095 3.21309 17.5041 3.32321 17.4674L6.24431 16.4932Z' + stroke={color} + strokeLinecap='round' + strokeLinejoin='round' + strokeWidth={1.5} + /> + </G> + </Svg> + ); +}; 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) => ( - <RootSvg {...props}> - <RootPath d='M12 11a4 4 0 100-8 4 4 0 000 8zm0-2a2 2 0 100-4 2 2 0 000 4z' {...props} /> - <RootPath - d='M12 12c-5.531 0-8 3.632-8 6a1 1 0 11-2 0c0-3.632 3.531-8 10-8 1.995 0 3.714.412 5.14 1.1a1 1 0 11-.868 1.8c-1.137-.547-2.556-.9-4.272-.9zM22 16v2h-6v-2h6z' - {...props} - /> - </RootSvg> -); diff --git a/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx b/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx new file mode 100644 index 0000000000..013dfaf9bb --- /dev/null +++ b/examples/SampleApp/src/screens/ChannelDetailsScreen.tsx @@ -0,0 +1,124 @@ +import React, { useCallback, useState } from 'react'; + +import type { RouteProp } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +import { + ChannelDetails, + GetChannelDetailsNavigationItems, + GetChannelMemberActionItems, + ChannelAddMembersModal, + ChannelAllMembersModal, + ChannelDetailsContextProvider, + ChannelDetailsNavigationSectionType, +} from 'stream-chat-react-native'; + +import { SendDirectMessage } from '../icons/SendDirectMessage'; + +import type { StackNavigatorParamList } from '../types'; + +type ChannelDetailsScreenRouteProp = RouteProp<StackNavigatorParamList, 'ChannelDetailsScreen'>; + +type ChannelDetailsScreenNavigationProp = NativeStackNavigationProp< + StackNavigatorParamList, + 'ChannelDetailsScreen' +>; + +type Props = { + navigation: ChannelDetailsScreenNavigationProp; + route: ChannelDetailsScreenRouteProp; +}; + +const navigationItems: { + [key in ChannelDetailsNavigationSectionType]: + | 'ChannelPinnedMessagesScreen' + | 'ChannelImagesScreen' + | 'ChannelFilesScreen'; +} = { + 'pinned-messages': 'ChannelPinnedMessagesScreen', + 'photos-and-videos': 'ChannelImagesScreen', + files: 'ChannelFilesScreen', +}; + +export const ChannelDetailsScreen: React.FC<Props> = ({ + navigation, + route: { + params: { channel }, + }, +}) => { + const onBack = useCallback(() => navigation.goBack(), [navigation]); + const getNavigationItems = useCallback<GetChannelDetailsNavigationItems>( + ({ defaultItems }) => + defaultItems.map((item) => ({ + ...item, + ...(navigationItems[item.section] + ? { onPress: () => navigation.navigate(navigationItems[item.section], { channel }) } + : {}), + })), + [navigation, channel], + ); + const popToRoot = useCallback( + () => + navigation.reset({ + index: 0, + routes: [{ name: 'MessagingScreen' }], + }), + [navigation], + ); + const [isAddMembersVisible, setAddMembersVisible] = useState(false); + const handleAddMembersClose = useCallback(() => setAddMembersVisible(false), []); + const handleAddMembersPress = useCallback(() => { + setAllMembersVisible(false); + setAddMembersVisible(true); + }, []); + const [isAllMembersVisible, setAllMembersVisible] = useState(false); + const handleAllMembersClose = useCallback(() => setAllMembersVisible(false), []); + const handleAllMembersPress = useCallback(() => setAllMembersVisible(true), []); + + const getChannelMemberActionItems = useCallback<GetChannelMemberActionItems>( + ({ context, defaultItems }) => { + // Don't offer sending a direct message to yourself. + if (context.isCurrentUser) { + return defaultItems; + } + const user = context.member.user; + return [ + { + action: () => { + setAllMembersVisible(false); + navigation.navigate('NewDirectMessagingScreen', { initialUser: user }); + return Promise.resolve(); + }, + Icon: SendDirectMessage, + id: 'sendDirectMessage', + label: context.t('Send Direct Message'), + type: 'standard', + }, + ...defaultItems, + ]; + }, + [navigation], + ); + + return ( + <> + <ChannelDetails + channel={channel} + getChannelMemberActionItems={getChannelMemberActionItems} + getNavigationItems={getNavigationItems} + onBack={onBack} + onChannelDismiss={popToRoot} + // Handler view all members modal so we can close it after navigation is triggered by our custom action + onViewAllMembersPress={handleAllMembersPress} + /> + <ChannelDetailsContextProvider value={{ channel, getChannelMemberActionItems }}> + <ChannelAllMembersModal + onClose={handleAllMembersClose} + visible={isAllMembersVisible} + onAddMembersPress={handleAddMembersPress} + /> + <ChannelAddMembersModal onClose={handleAddMembersClose} visible={isAddMembersVisible} /> + </ChannelDetailsContextProvider> + </> + ); +}; diff --git a/examples/SampleApp/src/screens/ChannelFilesScreen.tsx b/examples/SampleApp/src/screens/ChannelFilesScreen.tsx index 07189ea96f..02a265acf3 100644 --- a/examples/SampleApp/src/screens/ChannelFilesScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelFilesScreen.tsx @@ -1,73 +1,21 @@ -import React, { useEffect, useState } from 'react'; -import { Alert, SectionList, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; - -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import React from 'react'; +import { StyleSheet, View } from 'react-native'; import type { RouteProp } from '@react-navigation/native'; -import Dayjs from 'dayjs'; -import type { Attachment } from 'stream-chat'; import { - FileIcon, - getFileSizeDisplayText, - ThemeProvider, + ChannelDetailsContextProvider, + FileAttachmentList, useTheme, } from 'stream-chat-react-native'; import { ScreenHeader } from '../components/ScreenHeader'; -import { usePaginatedAttachments } from '../hooks/usePaginatedAttachments'; -import { File } from '../icons/File'; -import { useLegacyColors } from '../theme/useLegacyColors'; import type { StackNavigatorParamList } from '../types'; const styles = StyleSheet.create({ - container: { - alignItems: 'center', - borderRadius: 12, - flexDirection: 'row', - paddingHorizontal: 16, - paddingVertical: 12, - }, - details: { - flex: 1, - paddingLeft: 16, - }, - emptyContainer: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - paddingHorizontal: 40, - }, flex: { flex: 1, }, - noFiles: { - fontSize: 16, - paddingBottom: 8, - }, - noFilesDetails: { - fontSize: 14, - textAlign: 'center', - }, - sectionContainer: { - paddingBottom: 8, - paddingHorizontal: 16, - paddingTop: 16, - }, - sectionContentContainer: { - flexGrow: 1, - }, - sectionTitle: { - fontSize: 14, - }, - size: { - fontSize: 12, - }, - title: { - fontSize: 14, - fontWeight: '700', - paddingBottom: 2, - }, }); type ChannelFilesScreenRouteProp = RouteProp<StackNavigatorParamList, 'ChannelFilesScreen'>; @@ -81,138 +29,14 @@ export const ChannelFilesScreen: React.FC<ChannelFilesScreenProps> = ({ params: { channel }, }, }) => { - const { loading, loadMore, messages } = usePaginatedAttachments(channel, 'file'); - const insets = useSafeAreaInsets(); - const { - theme: { semantics }, - } = useTheme(); - const { black, grey, white_snow } = useLegacyColors(); - - const [sections, setSections] = useState< - Array<{ - data: Attachment[]; - title: string; - }> - >([]); - - useEffect(() => { - const newSections: Record< - string, - { - data: Attachment[]; - title: string; - } - > = {}; - - messages.forEach((message) => { - const month = Dayjs(message.created_at).format('MMM YYYY'); - - if (!newSections[month]) { - newSections[month] = { - data: [], - title: month, - }; - } - - message.attachments?.forEach((a) => { - if (a.type !== 'file') { - return; - } - - newSections[month].data.push(a); - }); - }); - - setSections(Object.values(newSections)); - }, [messages]); + useTheme(); return ( - <View - style={[ - styles.flex, - { - backgroundColor: white_snow, - paddingBottom: insets.bottom, - }, - ]} - > + <View style={[styles.flex]}> <ScreenHeader titleText='Files' /> - <ThemeProvider> - {(sections.length > 0 || !loading) && ( - <SectionList<Attachment> - contentContainerStyle={styles.sectionContentContainer} - ListEmptyComponent={EmptyListComponent} - onEndReached={loadMore} - renderItem={({ index, item: attachment, section }) => ( - <TouchableOpacity - key={`${attachment.asset_url}${attachment.image_url}${attachment.og_scrape_url}${attachment.thumb_url}${attachment.type}`} - onPress={() => { - Alert.alert('Not implemented.'); - }} - style={{ - borderBottomColor: semantics.borderCoreDefault, - borderBottomWidth: index === section.data.length - 1 ? 0 : 1, - }} - > - <View style={[styles.container, { backgroundColor: white_snow }]}> - <FileIcon mimeType={attachment.mime_type} /> - <View style={styles.details}> - <Text - numberOfLines={1} - style={[ - styles.title, - { - color: black, - }, - ]} - > - {attachment.title} - </Text> - <Text - style={[ - styles.size, - { - color: grey, - }, - ]} - > - {getFileSizeDisplayText(attachment.file_size)} - </Text> - </View> - </View> - </TouchableOpacity> - )} - renderSectionHeader={({ section: { title } }) => ( - <View - style={[ - styles.sectionContainer, - { - backgroundColor: white_snow, - }, - ]} - > - <Text style={[styles.sectionTitle, { color: black }]}>{title}</Text> - </View> - )} - sections={sections} - stickySectionHeadersEnabled - /> - )} - </ThemeProvider> - </View> - ); -}; - -const EmptyListComponent = () => { - useTheme(); - const { black, grey, grey_gainsboro } = useLegacyColors(); - return ( - <View style={styles.emptyContainer}> - <File fill={grey_gainsboro} scale={6} /> - <Text style={[styles.noFiles, { color: black }]}>No files</Text> - <Text style={[styles.noFilesDetails, { color: grey }]}> - Files sent on this chat will appear here. - </Text> + <ChannelDetailsContextProvider value={{ channel }}> + <FileAttachmentList /> + </ChannelDetailsContextProvider> </View> ); }; diff --git a/examples/SampleApp/src/screens/ChannelImagesScreen.tsx b/examples/SampleApp/src/screens/ChannelImagesScreen.tsx index 1bdc76aa1e..372aa523bf 100644 --- a/examples/SampleApp/src/screens/ChannelImagesScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelImagesScreen.tsx @@ -1,60 +1,15 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { - Dimensions, - FlatList, - Image, - StyleSheet, - Text, - TouchableOpacity, - View, - ViewToken, -} from 'react-native'; - -import { SafeAreaView } from 'react-native-safe-area-context'; +import React from 'react'; +import { StyleSheet, View } from 'react-native'; import type { RouteProp } from '@react-navigation/native'; -import Dayjs from 'dayjs'; -import { - DateHeader, - useImageGalleryContext, - useOverlayContext, - useTheme, - ImageGalleryState, - useStateStore, -} from 'stream-chat-react-native'; +import { ChannelDetailsContextProvider, MediaList } from 'stream-chat-react-native'; import { ScreenHeader } from '../components/ScreenHeader'; -import { usePaginatedAttachments } from '../hooks/usePaginatedAttachments'; -import { Picture } from '../icons/Picture'; -import { useLegacyColors } from '../theme/useLegacyColors'; import type { StackNavigatorParamList } from '../types'; -const screen = Dimensions.get('screen').width; - const styles = StyleSheet.create({ - contentContainer: { flexGrow: 1 }, - emptyContainer: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - paddingHorizontal: 40, - }, flex: { flex: 1 }, - noMedia: { - fontSize: 16, - paddingBottom: 8, - }, - noMediaDetails: { - fontSize: 14, - textAlign: 'center', - }, - stickyHeader: { - left: 0, - position: 'absolute', - right: 0, - top: 8, // DateHeader already has marginTop 8 - }, }); type ChannelImagesScreenRouteProp = RouteProp<StackNavigatorParamList, 'ChannelImagesScreen'>; @@ -63,126 +18,17 @@ export type ChannelImagesScreenProps = { route: ChannelImagesScreenRouteProp; }; -const selector = (state: ImageGalleryState) => ({ - assets: state.assets, -}); - export const ChannelImagesScreen: React.FC<ChannelImagesScreenProps> = ({ route: { params: { channel }, }, }) => { - const { imageGalleryStateStore } = useImageGalleryContext(); - const { assets } = useStateStore(imageGalleryStateStore.state, selector); - const { setOverlay } = useOverlayContext(); - const { loading, loadMore, messages } = usePaginatedAttachments(channel, 'image'); - useTheme(); - const { white } = useLegacyColors(); - - const [stickyHeaderDate, setStickyHeaderDate] = useState( - Dayjs(messages?.[0]?.created_at).format('MMM YYYY'), - ); - const stickyHeaderDateRef = useRef(''); - - const updateStickyDate = useRef(({ viewableItems }: { viewableItems: ViewToken[] }) => { - if (viewableItems?.length) { - const lastItem = viewableItems[0]; - - const created_at = lastItem?.item?.created_at; - - if ( - created_at && - !lastItem.item.deleted_at && - Dayjs(created_at).format('MMM YYYY') !== stickyHeaderDateRef.current - ) { - stickyHeaderDateRef.current = Dayjs(created_at).format('MMM YYYY'); - const isCurrentYear = new Date(created_at).getFullYear() === new Date().getFullYear(); - setStickyHeaderDate( - isCurrentYear ? Dayjs(created_at).format('MMM') : Dayjs(created_at).format('MMM YYYY'), - ); - } - } - }); - - const messagesWithImages = messages - .map((message) => ({ ...message, groupStyles: [], readBy: false })) - .filter((message) => { - if (!message.deleted_at && message.attachments) { - return message.attachments.some( - (attachment) => - attachment.type === 'image' && - !attachment.title_link && - !attachment.og_scrape_url && - (attachment.image_url || attachment.thumb_url), - ); - } - return false; - }); - - useEffect(() => { - imageGalleryStateStore.openImageGallery({ messages: messagesWithImages }); - return () => imageGalleryStateStore.clear(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [imageGalleryStateStore, messagesWithImages.length]); - - return ( - <SafeAreaView style={[styles.flex, { backgroundColor: white }]}> - <ScreenHeader inSafeArea titleText='Photos and Videos' /> - <View style={styles.flex}> - <FlatList - contentContainerStyle={styles.contentContainer} - data={assets} - keyExtractor={(item, index) => `${item.id}-${index}`} - ListEmptyComponent={EmptyListComponent} - numColumns={3} - onEndReached={loadMore} - onViewableItemsChanged={updateStickyDate.current} - refreshing={loading} - renderItem={({ item }) => ( - <TouchableOpacity - onPress={() => { - imageGalleryStateStore.openImageGallery({ - messages: messagesWithImages, - selectedAttachmentUrl: item.uri, - }); - setOverlay('gallery'); - }} - > - <Image - source={{ uri: item.uri }} - style={{ - height: screen / 3, - margin: 1, - width: screen / 3 - 2, - }} - /> - </TouchableOpacity> - )} - style={styles.flex} - viewabilityConfig={{ - viewAreaCoveragePercentThreshold: 50, - }} - /> - {assets.length > 0 ? ( - <View style={styles.stickyHeader}> - <DateHeader dateString={stickyHeaderDate} /> - </View> - ) : null} - </View> - </SafeAreaView> - ); -}; - -const EmptyListComponent = () => { - useTheme(); - const { black, grey, grey_gainsboro } = useLegacyColors(); return ( - <View style={styles.emptyContainer}> - <Picture fill={grey_gainsboro} scale={6} /> - <Text style={[styles.noMedia, { color: black }]}>No media</Text> - <Text style={[styles.noMediaDetails, { color: grey }]}> - Photos or video sent in this chat will appear here - </Text> + <View style={[styles.flex]}> + <ScreenHeader titleText='Photos and Videos' /> + <ChannelDetailsContextProvider value={{ channel }}> + <MediaList /> + </ChannelDetailsContextProvider> </View> ); }; 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/ChannelPinnedMessagesScreen.tsx b/examples/SampleApp/src/screens/ChannelPinnedMessagesScreen.tsx index 878429956d..f34846c966 100644 --- a/examples/SampleApp/src/screens/ChannelPinnedMessagesScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelPinnedMessagesScreen.tsx @@ -1,16 +1,18 @@ -import React from 'react'; -import { StyleSheet, Text, View } from 'react-native'; +import React, { useCallback } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useNavigation, type RouteProp } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { + useTheme, + PinnedMessageList, + ChannelDetailsContextProvider, + PinnedMessageItemProps, + PinnedMessageItem, + WithComponents, +} from 'stream-chat-react-native'; -import type { RouteProp } from '@react-navigation/native'; -import { useTheme } from 'stream-chat-react-native'; - -import { MessageSearchList } from '../components/MessageSearch/MessageSearchList'; import { ScreenHeader } from '../components/ScreenHeader'; -import { usePaginatedPinnedMessages } from '../hooks/usePaginatedPinnedMessages'; -import { Message } from '../icons/Message'; -import { useLegacyColors } from '../theme/useLegacyColors'; import type { StackNavigatorParamList } from '../types'; @@ -69,50 +71,46 @@ type ChannelPinnedMessagesScreenRouteProp = RouteProp< 'ChannelPinnedMessagesScreen' >; +type ChannelPinnedMessagesScreenNavigationProp = NativeStackNavigationProp< + StackNavigatorParamList, + 'ChannelPinnedMessagesScreen' +>; + export type ChannelPinnedMessagesScreenProps = { route: ChannelPinnedMessagesScreenRouteProp; }; +const PinnedMessage = (props: PinnedMessageItemProps) => { + const navigation = useNavigation<ChannelPinnedMessagesScreenNavigationProp>(); + + const onPress = useCallback(() => { + navigation.navigate('ChannelScreen', { + channel: props.channel, + messageId: props.message.parent_id ?? props.message.id, + }); + }, [props.channel, navigation, props.message.parent_id, props.message.id]); + + return ( + <Pressable onPress={onPress}> + <PinnedMessageItem {...props} /> + </Pressable> + ); +}; + export const ChannelPinnedMessagesScreen: React.FC<ChannelPinnedMessagesScreenProps> = ({ route: { params: { channel }, }, }) => { useTheme(); - const { white_snow } = useLegacyColors(); - const { loading, loadMore, messages } = usePaginatedPinnedMessages(channel); - const insets = useSafeAreaInsets(); return ( - <View - style={[ - styles.flex, - { - backgroundColor: white_snow, - paddingBottom: insets.bottom, - }, - ]} - > + <View style={[styles.flex]}> <ScreenHeader titleText='Pinned Messages' /> - <MessageSearchList - EmptySearchIndicator={EmptyListComponent} - loading={loading} - loadMore={loadMore} - messages={messages} - /> - </View> - ); -}; - -const EmptyListComponent = () => { - useTheme(); - const { black, grey, grey_gainsboro } = useLegacyColors(); - return ( - <View style={styles.emptyContainer}> - <Message fill={grey_gainsboro} height={110} width={130} /> - <Text style={[styles.noFiles, { color: black }]}>No pinned messages</Text> - <Text style={[styles.noFilesDetails, { color: grey }]}> - Long-press an important message and choose Pin to conversation. - </Text> + <ChannelDetailsContextProvider value={{ channel }}> + <WithComponents overrides={{ PinnedMessageItem: PinnedMessage }}> + <PinnedMessageList /> + </WithComponents> + </ChannelDetailsContextProvider> </View> ); }; diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx index 9712fd4bc8..01d4a7fa86 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -59,11 +59,6 @@ const ChannelHeader: React.FC<ChannelHeaderProps> = ({ channel }) => { const { chatClient } = useAppContext(); const navigation = useNavigation<ChannelScreenNavigationProp>(); - 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<ChannelHeaderProps> = ({ 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; @@ -99,12 +88,17 @@ const ChannelHeader: React.FC<ChannelHeaderProps> = ({ channel }) => { // eslint-disable-next-line react/no-unstable-nested-components RightContent={() => ( <Pressable + accessibilityRole='button' onPress={onRightContentPress} style={({ pressed }) => ({ + alignItems: 'center', + height: 48, + justifyContent: 'center', opacity: pressed ? 0.5 : 1, + width: 48, })} > - <ChannelAvatar channel={channel} size='xl' /> + <ChannelAvatar channel={channel} size='lg' /> </Pressable> )} showUnreadCountBadge 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<StackNavigatorParamList, 'GroupChannelDetailsScreen'>; - -type GroupChannelDetailsProps = { - route: GroupChannelDetailsRouteProp; -}; - -type GroupChannelDetailsScreenNavigationProp = NativeStackNavigationProp< - StackNavigatorParamList, - 'GroupChannelDetailsScreen' ->; - -export const GroupChannelDetailsScreen: React.FC<GroupChannelDetailsProps> = ({ - route: { - params: { channel }, - }, -}) => { - const { chatClient } = useAppContext(); - const navigation = useNavigation<GroupChannelDetailsScreenNavigationProp>(); - 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<ChannelMemberResponse | null>(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( - () => ( - <Pressable - onPress={openEditSheet} - style={[styles.outlineButton, { borderColor: semantics.borderCoreDefault }]} - > - <Text style={[styles.outlineButtonLabel, { color: semantics.textPrimary }]}>Edit</Text> - </Pressable> - ), - [openEditSheet, semantics.borderCoreDefault, semantics.textPrimary], - ); - - if (!channel) { - return null; - } - - const chevronRight = <GoForward height={20} width={20} stroke={semantics.textSecondary} />; - - return ( - <SafeAreaView style={[styles.container, { backgroundColor: semantics.backgroundCoreApp }]}> - <ScreenHeader inSafeArea titleText='Group Info' RightContent={() => rightContent} /> - <ScrollView contentContainerStyle={styles.scrollContent} style={styles.container}> - <ChannelDetailProfileSection - avatar={<ChannelAvatar channel={channel} size='2xl' />} - muted={channelMuted} - title={displayName} - subtitle={memberStatusText} - /> - - <SectionCard> - <ListItem - icon={<Pin height={20} width={20} stroke={semantics.textPrimary} />} - label='Pinned Messages' - trailing={chevronRight} - onPress={navigateToPinnedMessages} - /> - <ListItem - icon={<Picture height={20} width={20} fill={semantics.textPrimary} />} - label='Photos & Videos' - trailing={chevronRight} - onPress={navigateToImages} - /> - <ListItem - icon={<File height={20} width={20} stroke={semantics.textPrimary} />} - label='Files' - trailing={chevronRight} - onPress={navigateToFiles} - /> - </SectionCard> - - <SectionCard style={styles.membersCard}> - <View style={styles.sectionHeader}> - <Text style={[styles.sectionHeaderTitle, { color: semantics.textPrimary }]}> - {`${memberCount} members`} - </Text> - {isCreator ? ( - <Pressable - onPress={openAddMembersSheet} - style={[styles.outlineButtonSm, { borderColor: semantics.borderCoreDefault }]} - > - <Text style={[styles.outlineButtonLabel, { color: semantics.textPrimary }]}> - Add - </Text> - </Pressable> - ) : null} - </View> - <View style={styles.memberList}> - {visibleMembers.map((member) => { - if (!member.user?.id) { - return null; - } - return ( - <MemberListItem - key={member.user.id} - member={member} - isCurrentUser={member.user.id === chatClient?.user?.id} - isOwner={channelCreatorId === member.user.id} - onPress={() => handleMemberPress(member)} - /> - ); - })} - </View> - {hasMoreMembers ? ( - <View style={[styles.sectionFooter, { borderTopColor: semantics.borderCoreDefault }]}> - <Pressable onPress={openAllMembers} style={styles.viewAllButton}> - <Text style={[styles.viewAllLabel, { color: semantics.textPrimary }]}> - View all - </Text> - </Pressable> - </View> - ) : null} - </SectionCard> - - <SectionCard> - <ListItem - icon={<Mute height={20} width={20} fill={semantics.textSecondary} />} - label='Mute Group' - trailing={ - <Switch - onValueChange={handleMuteToggle} - style={rtlMirrorSwitchStyle} - trackColor={{ - false: semantics.controlToggleSwitchBg, - true: semantics.accentPrimary, - }} - value={muted ?? false} - /> - } - /> - <ListItem - icon={<LeaveGroup height={20} width={20} stroke={semantics.accentError} />} - label='Leave Group' - destructive - onPress={openLeaveGroupConfirmationSheet} - /> - </SectionCard> - </ScrollView> - <AllMembersBottomSheet - channel={channel} - channelCreatorId={channelCreatorId} - currentUserId={chatClient?.user?.id} - navigation={navigation} - onAddMember={isCreator ? openAddMembersFromAllMembers : undefined} - onClose={closeAllMembers} - visible={allMembersVisible} - /> - <AddMembersBottomSheet - channel={channel} - onClose={closeAddMembers} - visible={addMembersVisible} - /> - <ConfirmationBottomSheet - confirmText='LEAVE' - onClose={closeConfirmation} - onConfirm={leaveGroup} - subtext={`Are you sure you want to leave the group ${displayName || ''}?`} - title='Leave group' - visible={confirmationVisible} - /> - <ContactDetailBottomSheet - channel={channel} - member={selectedMember} - navigation={navigation} - onClose={closeContactDetail} - visible={selectedMember !== null} - /> - <EditGroupBottomSheet channel={channel} onClose={closeEditSheet} visible={editVisible} /> - </SafeAreaView> - ); -}; - -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<StackNavigatorParamList, 'NewDirectMessagingScreen'>; }; export const NewDirectMessagingScreen: React.FC<NewDirectMessagingScreenProps> = ({ navigation, + route, }) => { const { theme: { semantics }, @@ -137,12 +140,24 @@ export const NewDirectMessagingScreen: React.FC<NewDirectMessagingScreenProps> = const searchInputRef = useRef<TextInput>(null); const currentChannel = useRef<StreamChatChannel>(undefined); const isDraft = useRef(true); + const initialUserIdRef = useRef<string>(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<NewDirectMessagingScreenProps> = 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<Props> = ({ - 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 = <GoForward height={20} width={20} stroke={semantics.textSecondary} />; - - return ( - <SafeAreaView style={[styles.container, { backgroundColor: semantics.backgroundCoreApp }]}> - <ScreenHeader inSafeArea titleText='Contact Info' /> - <ScrollView contentContainerStyle={styles.scrollContent} style={styles.container}> - <ChannelDetailProfileSection - avatar={<ChannelAvatar channel={channel} size='2xl' />} - muted={userMuted} - title={user.name || user.id} - subtitle={activityStatus} - /> - - <SectionCard> - <ListItem - icon={<Pin height={20} width={20} stroke={semantics.textSecondary} />} - label='Pinned Messages' - trailing={chevronRight} - onPress={navigateToPinnedMessages} - /> - <ListItem - icon={<Picture height={20} width={20} fill={semantics.textSecondary} />} - label='Photos & Videos' - trailing={chevronRight} - onPress={navigateToImages} - /> - <ListItem - icon={<File height={20} width={20} stroke={semantics.textSecondary} />} - label='Files' - trailing={chevronRight} - onPress={navigateToFiles} - /> - </SectionCard> - - <SectionCard> - <ListItem - icon={<Mute height={20} width={20} fill={semantics.textSecondary} />} - label='Mute User' - trailing={ - <Switch - onValueChange={handleMuteToggle} - style={rtlMirrorSwitchStyle} - trackColor={{ - false: semantics.controlToggleSwitchBg, - true: semantics.accentPrimary, - }} - value={muted ?? false} - /> - } - /> - <ListItem - icon={<CircleBan height={20} width={20} stroke={semantics.textSecondary} />} - label='Block User' - onPress={openBlockUserConfirmationSheet} - /> - <ListItem - icon={ - <Delete - height={20} - width={20} - fill={semantics.accentError} - stroke={semantics.accentError} - /> - } - label='Delete Conversation' - destructive - onPress={openDeleteConversationConfirmationSheet} - /> - </SectionCard> - </ScrollView> - <ConfirmationBottomSheet - confirmText='DELETE' - onClose={closeConfirmation} - onConfirm={deleteConversation} - subtext='Are you sure you want to delete this conversation?' - title='Delete Conversation' - visible={confirmationVisible} - /> - <ConfirmationBottomSheet - confirmText='BLOCK' - onClose={closeBlockUserConfirmation} - onConfirm={handleBlockUser} - subtext='Are you sure you want to block this user?' - title='Block User' - visible={blockUserConfirmationVisible} - /> - </SafeAreaView> - ); -}; - -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<CustomPreviewProps> = ({ channel }) => { - const { chatClient } = useAppContext(); - const name = useChannelPreviewDisplayName(channel, 30); - const navigation = useNavigation<NavigationProp<StackNavigatorParamList, 'SharedGroupsScreen'>>(); - useTheme(); - const { black, grey, grey_whisper, white_snow } = useLegacyColors(); - - const displayAvatar = getChannelPreviewDisplayAvatar(channel, chatClient); - - const placeholder = useMemo(() => { - if (displayAvatar?.name) { - return <Text style={{ color: '#003179' }}>{getInitialsFromName(displayAvatar?.name)}</Text>; - } else { - return <Text style={{ color: '#003179' }}>?</Text>; - } - }, [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 ( - <TouchableOpacity - onPress={switchToChannel} - style={[ - styles.previewContainer, - { - backgroundColor: white_snow, - borderBottomColor: grey_whisper, - }, - ]} - > - <View style={styles.groupContainer}> - {displayAvatar.images ? ( - <GroupAvatar images={displayAvatar.images} names={displayAvatar.names} size={40} /> - ) : ( - <Avatar imageUrl={displayAvatar.image} placeholder={placeholder} size={'lg'} /> - )} - <Text style={[styles.nameText, { color: black }]}>{name}</Text> - </View> - <Text - style={{ - color: grey, - }} - > - {Object.keys(channel.state.members).length} Members - </Text> - </TouchableOpacity> - ); -}; - -const EmptyListComponent = () => { - useTheme(); - const { black, grey, grey_gainsboro } = useLegacyColors(); - - return ( - <View style={styles.emptyListContainer}> - <Contacts fill={grey_gainsboro} scale={6} /> - <Text style={[styles.emptyListTitle, { color: black }]}>No shared groups</Text> - <Text style={[styles.emptyListSubtitle, { color: grey }]}> - Groups shared with user will appear here - </Text> - </View> - ); -}; - -// 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 <EmptyListComponent />; - } - - return null; -}; - -type SharedGroupsScreenRouteProp = RouteProp<StackNavigatorParamList, 'SharedGroupsScreen'>; - -type SharedGroupsScreenProps = { - route: SharedGroupsScreenRouteProp; -}; - -export const SharedGroupsScreen: React.FC<SharedGroupsScreenProps> = ({ - route: { - params: { user }, - }, -}) => { - const { chatClient } = useAppContext(); - - if (!chatClient?.user) { - return null; - } - - return ( - <View style={styles.container}> - <ScreenHeader titleText='Shared Groups' /> - <ChannelList - filters={{ - $and: [{ members: { $in: [chatClient?.user?.id] } }, { members: { $in: [user.id] } }], - }} - options={{ - watch: false, - }} - sort={{ - last_updated: -1, - }} - /> - </View> - ); -}; 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/examples/TypeScriptMessaging/package.json b/examples/TypeScriptMessaging/package.json index d2127f0cb9..ba24fef7a6 100644 --- a/examples/TypeScriptMessaging/package.json +++ b/examples/TypeScriptMessaging/package.json @@ -33,7 +33,7 @@ "react-native-svg": "^15.12.0", "react-native-video": "^6.16.1", "react-native-worklets": "^0.4.1", - "stream-chat": "^9.44.2", + "stream-chat": "^9.47.0", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/package/expo-package/src/optionalDependencies/Video.tsx b/package/expo-package/src/optionalDependencies/Video.tsx index fc705be101..16e42f0c01 100644 --- a/package/expo-package/src/optionalDependencies/Video.tsx +++ b/package/expo-package/src/optionalDependencies/Video.tsx @@ -23,6 +23,23 @@ const useVideoPlayer = videoPackage?.useVideoPlayer; let Video = null; +/** + * Tuned for chat gallery clips. expo-video's defaults are way too + * generous (Android: 20s read ahead, no byte cap and ExoPlayer + * picks based on bitrate). These values cap each player instance at roughly + * 5-6 MB while still giving a comfortable margin against network stalls. + * Integrators with different needs can replace this wrapper via + * `registerNativeHandlers({ Video: ... })`. + * + * Per expo-video docs the whole object must be assigned at once and setting + * individual properties on `player.bufferOptions` is not supported. + */ +const BUFFER_OPTIONS = { + maxBufferBytes: 6_000_000, // Android only, ~6 MB hard cap + minBufferForPlayback: 1, // Android only, seconds before playback can start + preferredForwardBufferDuration: 10, // both platforms, seconds +}; + // expo-video if (videoPackage) { Video = ({ @@ -38,6 +55,7 @@ if (videoPackage) { }) => { const player = useVideoPlayer(uri, (player) => { player.timeUpdateEventInterval = 0.5; + player.bufferOptions = BUFFER_OPTIONS; videoRef.current = player; }); diff --git a/package/jest-setup.tsx b/package/jest-setup.tsx index c67d1a9226..02fb61eedb 100644 --- a/package/jest-setup.tsx +++ b/package/jest-setup.tsx @@ -55,6 +55,10 @@ jest.mock('react-native-reanimated', () => { isSharedValue: (value: unknown): boolean => typeof value === 'object' && value !== null && 'value' in value, isWorkletFunction: () => false, + // Reanimated's official mock implements makeMutable as an identity function + // (returns the raw init value instead of a { value } wrapper), which breaks + // any test that reads `.value` on a SharedValue. Replace with a real wrapper. + makeMutable: <T,>(init: T) => ({ value: init }), NativeEventsManager: class { attachEvents() {} detachEvents() {} diff --git a/package/native-package/src/optionalDependencies/AudioVideo.ts b/package/native-package/src/optionalDependencies/AudioVideo.ts index 3c4367cec2..16af4baac7 100644 --- a/package/native-package/src/optionalDependencies/AudioVideo.ts +++ b/package/native-package/src/optionalDependencies/AudioVideo.ts @@ -14,10 +14,17 @@ let AudioVideoComponent: }; style: StyleProp<ViewStyle>; audioOnly?: boolean; + bufferConfig?: { + bufferForPlaybackAfterRebufferMs?: number; + bufferForPlaybackMs?: number; + maxBufferMs?: number; + minBufferMs?: number; + }; ignoreSilentSwitch?: 'ignore' | 'obey'; repeat?: boolean; }> | undefined; + try { const videoPackage = require('react-native-video'); AudioVideoComponent = videoPackage.default; diff --git a/package/native-package/src/optionalDependencies/Video.tsx b/package/native-package/src/optionalDependencies/Video.tsx index a9d728d135..0ef165f487 100644 --- a/package/native-package/src/optionalDependencies/Video.tsx +++ b/package/native-package/src/optionalDependencies/Video.tsx @@ -2,6 +2,22 @@ import React from 'react'; import AudioVideoPlayer from './AudioVideo'; +/** + * Tuned for chat gallery clips. ExoPlayer's defaults are way too generous and + * buffer up to 50s of read ahead, which translates to ~30 MB of + * Java heap source bytes per instance - way too much when several Video elements + * are mounted in the gallery's slide window. These values cap each instance + * at roughly 5-10 MB while still giving a comfortable margin against network + * stalls. Integrators with different needs can replace this wrapper via + * `registerNativeHandlers({ Video: ... })`. + */ +const BUFFER_CONFIG = { + bufferForPlaybackAfterRebufferMs: 2000, + bufferForPlaybackMs: 1000, + maxBufferMs: 10000, + minBufferMs: 5000, +}; + export const Video = AudioVideoPlayer ? ({ onBuffer, @@ -17,6 +33,7 @@ export const Video = AudioVideoPlayer rate, }) => ( <AudioVideoPlayer + bufferConfig={BUFFER_CONFIG} ignoreSilentSwitch={'ignore'} onBuffer={onBuffer} onEnd={onEnd} diff --git a/package/package.json b/package/package.json index f2a21b097a..2376804f6d 100644 --- a/package/package.json +++ b/package/package.json @@ -78,7 +78,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.44.2", + "stream-chat": "^9.47.0", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { diff --git a/package/src/__tests__/offline-support/offline-feature.tsx b/package/src/__tests__/offline-support/offline-feature.tsx index 0270c67d7c..4851e25661 100644 --- a/package/src/__tests__/offline-support/offline-feature.tsx +++ b/package/src/__tests__/offline-support/offline-feature.tsx @@ -1576,22 +1576,16 @@ export const Generic = () => { await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy()); const targetChannel = channels[getRandomInt(0, channels.length - 1)]; - const oldMemberCount = targetChannel.channel.member_count; const newMember = generateMember(); act(() => dispatchMemberAddedEvent(chatClient, newMember, targetChannel.channel)); await waitFor(async () => { const membersRows = await BetterSqlite.selectFromTable('members'); - const channelRows = await BetterSqlite.selectFromTable('channels'); const matchingMembersRows = membersRows.filter( (m) => m.cid === targetChannel.channel.cid && m.userId === newMember.user_id, ); - const targetChannelFromDb = channelRows.filter( - (c) => c.cid === targetChannel.channel.cid, - )[0]; expect(matchingMembersRows.length).toBe(1); - expect(targetChannelFromDb.memberCount).toBe(oldMemberCount + 1); }); }); @@ -1605,21 +1599,15 @@ export const Generic = () => { const targetChannel = channels[getRandomInt(0, channels.length - 1)]; const targetMember = targetChannel.members[getRandomInt(0, targetChannel.members.length - 1)]; - const oldMemberCount = targetChannel.channel.member_count; act(() => dispatchMemberRemovedEvent(chatClient, targetMember, targetChannel.channel)); await waitFor(async () => { const membersRows = await BetterSqlite.selectFromTable('members'); - const channelRows = await BetterSqlite.selectFromTable('channels'); const matchingMembersRows = membersRows.filter( (m) => m.cid === targetChannel.channel.cid && m.userId === targetMember.user_id, ); - const targetChannelFromDb = channelRows.filter( - (c) => c.cid === targetChannel.channel.cid, - )[0]; expect(matchingMembersRows.length).toBe(0); - expect(targetChannelFromDb.memberCount).toBe(oldMemberCount - 1); }); }); diff --git a/package/src/components/Attachment/Attachment.tsx b/package/src/components/Attachment/Attachment.tsx index 3ea96757ba..5cf58f9444 100644 --- a/package/src/components/Attachment/Attachment.tsx +++ b/package/src/components/Attachment/Attachment.tsx @@ -224,16 +224,21 @@ const useAudioAttachmentStyles = () => { const messageHasSingleAttachment = message.attachments?.length === 1; const messageHasCaption = !!message.text?.trim(); const messageIsQuotedReply = !!(message.quoted_message || message.quoted_message_id); + const shouldRemoveAudioAttachmentPadding = + messageIsQuotedReply && messageHasSingleAttachment && !messageHasCaption; const showBackgroundTransparent = - messageHasOnlySingleAttachment || - (messageIsQuotedReply && messageHasSingleAttachment && !messageHasCaption); + messageHasOnlySingleAttachment || shouldRemoveAudioAttachmentPadding; return useMemo(() => { return StyleSheet.create({ container: { - paddingVertical: primitives.spacingXs, - paddingLeft: primitives.spacingXs, - paddingRight: primitives.spacingSm, + paddingVertical: shouldRemoveAudioAttachmentPadding + ? primitives.spacingXxs + : primitives.spacingXs, + paddingLeft: shouldRemoveAudioAttachmentPadding ? 0 : primitives.spacingXs, + paddingRight: shouldRemoveAudioAttachmentPadding + ? primitives.spacingXxs + : primitives.spacingSm, borderWidth: 0, backgroundColor: showBackgroundTransparent ? 'transparent' @@ -255,6 +260,10 @@ const useAudioAttachmentStyles = () => { color: semantics.chatTextIncoming, fontWeight: primitives.typographyFontWeightSemiBold, }, + leftContainer: { + paddingVertical: shouldRemoveAudioAttachmentPadding ? 0 : primitives.spacingXxs, + paddingHorizontal: primitives.spacingXxs, + }, }); - }, [semantics, isMyMessage, showBackgroundTransparent]); + }, [shouldRemoveAudioAttachmentPadding, showBackgroundTransparent, isMyMessage, semantics]); }; diff --git a/package/src/components/Attachment/Audio/AudioAttachment.tsx b/package/src/components/Attachment/Audio/AudioAttachment.tsx index 111b93e1b8..b1eec79636 100644 --- a/package/src/components/Attachment/Audio/AudioAttachment.tsx +++ b/package/src/components/Attachment/Audio/AudioAttachment.tsx @@ -78,6 +78,7 @@ export type AudioAttachmentProps = { playPauseButton?: StyleProp<ViewStyle>; speedSettingsButton?: StyleProp<ViewStyle>; durationText?: StyleProp<TextStyle>; + leftContainer?: StyleProp<TextStyle>; }; }; @@ -215,7 +216,7 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { style={[styles.container, container, containerStyle, stylesProps?.container]} testID={testID} > - <View style={[styles.leftContainer, leftContainer]}> + <View style={[styles.leftContainer, stylesProps?.leftContainer, leftContainer]}> <PlayPauseButton isPlaying={isPlaying} accessibilityLabel='Play Pause Button' @@ -341,9 +342,7 @@ const useStyles = () => { fontWeight: primitives.typographyFontWeightSemiBold, lineHeight: primitives.typographyLineHeightTight, }, - leftContainer: { - padding: primitives.spacingXxs, - }, + leftContainer: {}, progressControlContainer: { flex: 1, }, diff --git a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx index 8b1ea6f679..764ee5851d 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteInput.tsx @@ -165,8 +165,20 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) } }, [text]); + const nativeInputRef = useRef<RNTextInput | null>(null); + const clearState = useCallback(() => { setLocalText(''); + // iOS UITextView caches its intrinsicContentSize while focused, so a + // controlled `value` change to '' after a multiline send doesn't shrink + // the input back to single line height and UIKit keeps rendering at the + // previously cached focused size until blur. Not particularly sure which + // RN version regressed this, but 0.85.3 for sure has the bug. Forcebly + // setting its native prop forces UITextView to reconcile its content size + // and update accordingly. + if (Platform.OS === 'ios') { + nativeInputRef.current?.setNativeProps({ text: '' }); + } }, []); const restoreState = useStableCallback((restoredText: string) => { @@ -175,6 +187,7 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) const setExtendedInputRef = useCallback( (ref: RNTextInput | null) => { + nativeInputRef.current = ref; if (!ref) { setRef(setInputBoxRef, null); return; diff --git a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionHeader.tsx b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionHeader.tsx index 7c117f325b..a912d5c57c 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionHeader.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionHeader.tsx @@ -27,6 +27,7 @@ export const CommandsHeader: React.FC<AutoCompleteSuggestionHeaderProps> = () => return ( <View style={[styles.container, container]}> <Text + accessibilityRole='header' style={[styles.title, { color: semantics.textTertiary }, title]} testID='commands-header-title' > @@ -52,7 +53,7 @@ export const EmojiHeader: React.FC<AutoCompleteSuggestionHeaderProps> = ({ query return ( <View style={[styles.container, container]}> <Smile pathFill={semantics.accentPrimary} /> - <Text style={[styles.title, title]} testID='emojis-header-title'> + <Text accessibilityRole='header' style={[styles.title, title]} testID='emojis-header-title'> {`Emoji matching "${queryText}"`} </Text> </View> diff --git a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx index 03b9d1954d..65d8ecdbcb 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx @@ -1,46 +1,48 @@ import React, { useCallback, useMemo } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; -import type { CommandSuggestion, TextComposerSuggestion, UserSuggestion } from 'stream-chat'; +import type { CommandSuggestion, MentionSuggestion, TextComposerSuggestion } from 'stream-chat'; import { AutoCompleteSuggestionCommandIcon } from './AutoCompleteSuggestionCommandIcon'; - +import { + MentionBroadcastItem, + MentionRoleItem, + MentionUserGroupItem, + MentionUserItem, +} from './mentionItems'; + +import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useIsCommandDisabled } from '../../contexts/messageInputContext/hooks/useIsCommandDisabled'; import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { primitives } from '../../theme'; import type { Emoji } from '../../types/types'; -import { UserAvatar } from '../ui/Avatar/UserAvatar'; - export type AutoCompleteSuggestionItemProps = { itemProps: TextComposerSuggestion; triggerType?: string; }; -export const MentionSuggestionItem = (item: UserSuggestion) => { - const { id, name, online } = item; - const { - theme: { - messageComposer: { - suggestions: { - mention: { column, container: mentionContainer, name: nameStyle }, - }, - }, - }, - } = useTheme(); - const styles = useStyles(); - - return ( - <View style={[styles.container, mentionContainer]}> - <UserAvatar user={item} size='md' showOnlineIndicator={online} /> - <View style={[styles.column, column]}> - <Text style={[styles.name, nameStyle]} testID='mentions-item-name'> - {name || id} - </Text> - </View> - </View> - ); +/** + * Default `@`-trigger row dispatcher. Routes a `MentionSuggestion` to the + * per type component. Each per type component is its own export and can be + * composed by integrators who override this dispatcher via + * `ComponentsContext.MentionSuggestionItem`. + */ +export const MentionSuggestionItem = (item: MentionSuggestion) => { + switch (item.mentionType) { + case 'user': + return <MentionUserItem entity={item} />; + case 'channel': + case 'here': + return <MentionBroadcastItem entity={item} />; + case 'role': + return <MentionRoleItem entity={item} />; + case 'user_group': + return <MentionUserGroupItem entity={item} />; + default: + return null; + } }; export const EmojiSuggestionItem = (item: Emoji) => { @@ -114,9 +116,13 @@ const SuggestionItem = ({ item: TextComposerSuggestion; triggerType?: string; }) => { + // Resolve via context so integrators can swap the mention dispatcher alone + // (e.g. to render a custom @channel row) without re-implementing the + // emoji/command branches of AutoCompleteSuggestionItem. + const { MentionSuggestionItem } = useComponentsContext(); switch (triggerType) { case 'mention': - return <MentionSuggestionItem {...(item as UserSuggestion)} />; + return <MentionSuggestionItem {...(item as MentionSuggestion)} />; case 'emoji': return <EmojiSuggestionItem {...(item as Emoji)} />; case 'command': @@ -147,6 +153,7 @@ const UnMemoizedAutoCompleteSuggestionItem = ({ return ( <Pressable + accessibilityRole='button' onPress={handlePress} style={({ pressed }) => [{ opacity: pressed ? 0.8 : 1 }, itemStyle]} testID='suggestion-item' @@ -194,11 +201,6 @@ const useStyles = () => { fontSize: primitives.typographyFontSizeMd, color: semantics.textTertiary, }, - column: { - flex: 1, - justifyContent: 'space-evenly', - paddingLeft: 8, - }, container: { alignItems: 'center', flexDirection: 'row', @@ -211,16 +213,6 @@ const useStyles = () => { paddingHorizontal: primitives.spacingSm, paddingVertical: primitives.spacingXs, }, - name: { - fontSize: primitives.typographyFontSizeMd, - lineHeight: primitives.typographyLineHeightNormal, - color: semantics.textPrimary, - paddingBottom: 2, - }, - tag: { - fontSize: 12, - fontWeight: '600', - }, text: { fontSize: primitives.typographyFontSizeMd, fontWeight: primitives.typographyFontWeightRegular, diff --git a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionList.tsx b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionList.tsx index 0f490ae913..af4900de14 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionList.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionList.tsx @@ -7,13 +7,17 @@ import Animated, { LinearTransition, ZoomIn, ZoomOut } from 'react-native-reanim import { SearchSourceState, TextComposerState, TextComposerSuggestion } from 'stream-chat'; +import { useA11yLabel } from '../../a11y/hooks/useA11yLabel'; +import { useAnnounceOnShow } from '../../a11y/hooks/useAnnounceOnShow'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useStableCallback } from '../../hooks'; import { useStateStore } from '../../hooks/useStateStore'; +import { primitives } from '../../theme'; +import { ClippingFadeBottom } from '../UIComponents/ClippingFadeBottom'; -export const DEFAULT_LIST_HEIGHT = 208; +export const DEFAULT_LIST_HEIGHT = 240; export type AutoCompleteSuggestionListProps = Record<string, never>; @@ -48,6 +52,7 @@ export const AutoCompleteSuggestionList = () => { const { theme: { + semantics, messageComposer: { container: { maxHeight }, }, @@ -72,12 +77,25 @@ export const AutoCompleteSuggestionList = () => { const loadMore = useStableCallback(() => suggestions?.searchSource.search()); + // Polite announcement when the suggestion list appears. Different label per + // trigger type so the user knows whether they're looking at mentions, + // commands, or emoji without having to swipe in to find out. + const announceLabelKey = + triggerType === 'command' + ? 'a11y/Command suggestions available' + : triggerType === 'emoji' + ? 'a11y/Emoji suggestions available' + : 'a11y/Mention suggestions available'; + const announceLabel = useA11yLabel(announceLabelKey); + useAnnounceOnShow(!!(showList && triggerType), announceLabel); + if (!showList || !triggerType) { return null; } return ( <Animated.View + accessibilityRole='menu' entering={ZoomIn.duration(200)} exiting={ZoomOut.duration(200)} layout={LinearTransition.duration(200)} @@ -92,8 +110,10 @@ export const AutoCompleteSuggestionList = () => { onEndReachedThreshold={0.1} renderItem={renderItem} style={[styles.flatlist, { maxHeight }]} + contentContainerStyle={styles.flatlistContentContainer} testID={'auto-complete-suggestion-list'} /> + <ClippingFadeBottom backgroundColor={String(semantics.backgroundCoreElevation1)} /> </Animated.View> ); }; @@ -103,7 +123,7 @@ const useStyles = () => { theme: { semantics, messageComposer: { - suggestionsListContainer: { flatlist }, + suggestionsListContainer: { flatlist, flatlistContentContainer }, }, }, } = useTheme(); @@ -116,6 +136,11 @@ const useStyles = () => { borderTopWidth: 1, borderColor: semantics.borderCoreDefault, }, + flatlistContentContainer: { + paddingVertical: primitives.spacingXs, + backgroundColor: 'transparent', + ...flatlistContentContainer, + }, flatlist: { backgroundColor: semantics.backgroundCoreElevation1, shadowColor: semantics.textOnAccent, @@ -131,7 +156,7 @@ const useStyles = () => { ...flatlist, }, }), - [semantics, flatlist], + [semantics, flatlist, flatlistContentContainer], ); }; diff --git a/package/src/components/AutoCompleteInput/mentionItems/EnhancedMentionContent.tsx b/package/src/components/AutoCompleteInput/mentionItems/EnhancedMentionContent.tsx new file mode 100644 index 0000000000..77aa84ae5e --- /dev/null +++ b/package/src/components/AutoCompleteInput/mentionItems/EnhancedMentionContent.tsx @@ -0,0 +1,71 @@ +import React, { ReactNode, useMemo } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../../theme'; + +export type EnhancedMentionContentProps = { + title: ReactNode; + subtitle?: ReactNode; + testID?: string; +}; + +/** + * Title + optional subtitle pair used by every non-user mention row + * (channel / here / role / user_group). Override styling via + * `theme.messageComposer.suggestions.mention.enhancedMention{Container,Title,Subtitle}`. + */ +export const EnhancedMentionContent = ({ + subtitle, + testID, + title, +}: EnhancedMentionContentProps) => { + const { + theme: { + semantics, + messageComposer: { + suggestions: { + mention: { enhancedMentionContainer, enhancedMentionSubtitle, enhancedMentionTitle }, + }, + }, + }, + } = useTheme(); + const styles = useStyles(); + + return ( + <View style={[styles.container, enhancedMentionContainer]}> + <Text + style={[styles.title, { color: semantics.textPrimary }, enhancedMentionTitle]} + testID={testID} + > + {title} + </Text> + {subtitle ? ( + <Text + style={[styles.subtitle, { color: semantics.textSecondary }, enhancedMentionSubtitle]} + > + {subtitle} + </Text> + ) : null} + </View> + ); +}; + +const useStyles = () => + useMemo( + () => + StyleSheet.create({ + container: { + gap: primitives.spacingXxxs, + }, + subtitle: { + fontSize: primitives.typographyFontSizeXs, + lineHeight: primitives.typographyLineHeightTight, + }, + title: { + fontSize: primitives.typographyFontSizeMd, + lineHeight: primitives.typographyLineHeightNormal, + }, + }), + [], + ); diff --git a/package/src/components/AutoCompleteInput/mentionItems/EnhancedMentionIcon.tsx b/package/src/components/AutoCompleteInput/mentionItems/EnhancedMentionIcon.tsx new file mode 100644 index 0000000000..abc3b14a1f --- /dev/null +++ b/package/src/components/AutoCompleteInput/mentionItems/EnhancedMentionIcon.tsx @@ -0,0 +1,71 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import type { IconProps } from '../../../icons/utils/base'; +import { primitives } from '../../../theme'; + +export type EnhancedMentionIconProps = { + /** + * Any icon component from `package/src/icons` (or a custom one matching the + * same `IconProps` shape). The wrapper standardizes size + color and wraps + * the icon in a circular chip — per-type mention items don't have to know + * about any of that. + */ + Icon: React.ComponentType<IconProps>; + /** + * Icon size in px. Defaults to 16. The surrounding chip scales with this. + */ + size?: IconProps['size']; + /** + * Stroke / fill color. Defaults to `semantics.textSecondary`. + */ + color?: IconProps['pathFill']; +}; + +/** + * Universal wrapper for non-user mention-row icons. Renders the supplied + * `Icon` inside a circular chip. Override chip styling via + * `theme.messageComposer.suggestions.mention.enhancedMentionIcon`. + */ +export const EnhancedMentionIcon = ({ color, Icon, size = 32 }: EnhancedMentionIconProps) => { + const { + theme: { + semantics, + messageComposer: { + suggestions: { + mention: { enhancedMentionIcon }, + }, + }, + }, + } = useTheme(); + const styles = useStyles(size); + + return ( + <View style={[styles.chip, enhancedMentionIcon]}> + <Icon pathFill={color ?? semantics.textPrimary} size={size / 2} /> + </View> + ); +}; + +const useStyles = (chipSize: number) => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + chip: { + alignItems: 'center', + backgroundColor: semantics.backgroundCoreSurfaceSubtle, + borderColor: semantics.borderCoreSubtle, + borderRadius: primitives.radiusMax, + borderWidth: 1, + height: chipSize, + justifyContent: 'center', + width: chipSize, + }, + }), + [chipSize, semantics.backgroundCoreSurfaceSubtle, semantics.borderCoreSubtle], + ); +}; diff --git a/package/src/components/AutoCompleteInput/mentionItems/MentionBroadcastItem.tsx b/package/src/components/AutoCompleteInput/mentionItems/MentionBroadcastItem.tsx new file mode 100644 index 0000000000..5b90332e61 --- /dev/null +++ b/package/src/components/AutoCompleteInput/mentionItems/MentionBroadcastItem.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +import type { ChannelMentionSuggestion, HereMentionSuggestion } from 'stream-chat'; + +import { EnhancedMentionContent } from './EnhancedMentionContent'; +import { EnhancedMentionIcon } from './EnhancedMentionIcon'; +import { MentionItem } from './MentionItem'; + +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { Megaphone } from '../../../icons/megaphone'; + +export type MentionBroadcastItemProps = { + entity: ChannelMentionSuggestion | HereMentionSuggestion; +}; + +// @channel and @here are literal SDK command keywords (matching mentioned_channel +// and mentioned_here on the wire). The title is not localized; only the +// description below it is. +const TITLE = { channel: '@channel', here: '@here' } as const; +const SUBTITLE_KEY = { + channel: 'mention/Channel Description', + here: 'mention/Here Description', +} as const; + +export const MentionBroadcastItem = ({ entity }: MentionBroadcastItemProps) => { + const { t } = useTranslationContext(); + return ( + <MentionItem leading={<EnhancedMentionIcon Icon={Megaphone} />}> + <EnhancedMentionContent + subtitle={t(SUBTITLE_KEY[entity.mentionType])} + testID='mentions-item-name' + title={TITLE[entity.mentionType]} + /> + </MentionItem> + ); +}; diff --git a/package/src/components/AutoCompleteInput/mentionItems/MentionItem.tsx b/package/src/components/AutoCompleteInput/mentionItems/MentionItem.tsx new file mode 100644 index 0000000000..e720b51f36 --- /dev/null +++ b/package/src/components/AutoCompleteInput/mentionItems/MentionItem.tsx @@ -0,0 +1,59 @@ +import React, { PropsWithChildren, ReactNode, useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../../theme'; + +export type MentionItemProps = PropsWithChildren<{ + /** + * Leading visual rendered to the left of the row. UserAvatar for user + * mentions, an `EnhancedMentionIcon` for the rest. + */ + leading?: ReactNode; + testID?: string; +}>; + +/** + * Layout primitive for every mention-suggestion row: `[leading | content]`. + * The per-type content (tokenized user name, or `EnhancedMentionContent` for + * channel/here/role/user_group) is passed as children. Container and column + * styles come from `theme.messageComposer.suggestions.mention`. + */ +export const MentionItem = ({ children, leading, testID }: MentionItemProps) => { + const { + theme: { + messageComposer: { + suggestions: { + mention: { column, container }, + }, + }, + }, + } = useTheme(); + const styles = useStyles(); + + return ( + <View style={[styles.container, container]} testID={testID}> + {leading} + <View style={[styles.column, column]}>{children}</View> + </View> + ); +}; + +const useStyles = () => + useMemo( + () => + StyleSheet.create({ + column: { + flex: 1, + justifyContent: 'space-evenly', + }, + container: { + alignItems: 'center', + flexDirection: 'row', + paddingHorizontal: primitives.spacingXs, + paddingVertical: primitives.spacingXs, + gap: primitives.spacingSm, + }, + }), + [], + ); diff --git a/package/src/components/AutoCompleteInput/mentionItems/MentionRoleItem.tsx b/package/src/components/AutoCompleteInput/mentionItems/MentionRoleItem.tsx new file mode 100644 index 0000000000..55f0c686cc --- /dev/null +++ b/package/src/components/AutoCompleteInput/mentionItems/MentionRoleItem.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import type { RoleMentionSuggestion } from 'stream-chat'; + +import { EnhancedMentionContent } from './EnhancedMentionContent'; +import { EnhancedMentionIcon } from './EnhancedMentionIcon'; +import { MentionItem } from './MentionItem'; + +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { Shield } from '../../../icons/shield'; + +export type MentionRoleItemProps = { + entity: RoleMentionSuggestion; +}; + +export const MentionRoleItem = ({ entity }: MentionRoleItemProps) => { + const { t } = useTranslationContext(); + return ( + <MentionItem leading={<EnhancedMentionIcon Icon={Shield} />}> + <EnhancedMentionContent + subtitle={t('Notify all {{ role }} members', { role: entity.name })} + testID='mentions-item-name' + title={`@${entity.name}`} + /> + </MentionItem> + ); +}; diff --git a/package/src/components/AutoCompleteInput/mentionItems/MentionUserGroupItem.tsx b/package/src/components/AutoCompleteInput/mentionItems/MentionUserGroupItem.tsx new file mode 100644 index 0000000000..67f1152a74 --- /dev/null +++ b/package/src/components/AutoCompleteInput/mentionItems/MentionUserGroupItem.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import type { UserGroupMentionSuggestion } from 'stream-chat'; + +import { EnhancedMentionContent } from './EnhancedMentionContent'; +import { EnhancedMentionIcon } from './EnhancedMentionIcon'; +import { MentionItem } from './MentionItem'; + +import { PeopleIcon } from '../../../icons/users'; + +export type MentionUserGroupItemProps = { + entity: UserGroupMentionSuggestion; +}; + +export const MentionUserGroupItem = ({ entity }: MentionUserGroupItemProps) => ( + <MentionItem leading={<EnhancedMentionIcon Icon={PeopleIcon} />}> + <EnhancedMentionContent + subtitle={entity.description} + testID='mentions-item-name' + title={`@${entity.name}`} + /> + </MentionItem> +); diff --git a/package/src/components/AutoCompleteInput/mentionItems/MentionUserItem.tsx b/package/src/components/AutoCompleteInput/mentionItems/MentionUserItem.tsx new file mode 100644 index 0000000000..6c862659fe --- /dev/null +++ b/package/src/components/AutoCompleteInput/mentionItems/MentionUserItem.tsx @@ -0,0 +1,55 @@ +import React, { useMemo } from 'react'; +import { View } from 'react-native'; + +import type { UserSuggestion } from 'stream-chat'; + +import { MentionItem } from './MentionItem'; +import { TokenizedSuggestionParts } from './TokenizedSuggestionParts'; + +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../../theme'; +import { UserAvatar } from '../../ui/Avatar/UserAvatar'; + +export type MentionUserItemProps = { + entity: UserSuggestion; +}; + +export const MentionUserItem = ({ entity }: MentionUserItemProps) => { + const styles = useStyles(); + + return ( + <MentionItem + leading={ + <View importantForAccessibility='no-hide-descendants'> + <UserAvatar showOnlineIndicator={!!entity.online} size='md' user={entity} /> + </View> + } + > + <TokenizedSuggestionParts + fallback={entity.name || entity.id} + matchStyle={styles.match} + style={styles.name} + testID='mentions-item-name' + tokenizedDisplayName={entity.tokenizedDisplayName} + /> + </MentionItem> + ); +}; + +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => ({ + match: { fontWeight: primitives.typographyFontWeightBold }, + name: { + color: semantics.textPrimary, + fontSize: primitives.typographyFontSizeMd, + lineHeight: primitives.typographyLineHeightNormal, + paddingBottom: 2, + }, + }), + [semantics.textPrimary], + ); +}; diff --git a/package/src/components/AutoCompleteInput/mentionItems/TokenizedSuggestionParts.tsx b/package/src/components/AutoCompleteInput/mentionItems/TokenizedSuggestionParts.tsx new file mode 100644 index 0000000000..a61c84d516 --- /dev/null +++ b/package/src/components/AutoCompleteInput/mentionItems/TokenizedSuggestionParts.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { StyleProp, Text, TextStyle } from 'react-native'; + +import type { TokenizationPayload } from 'stream-chat'; + +export type TokenizedSuggestionPartsProps = { + /** + * Token + parts payload produced by the stream-chat-js text composer search + * source. When the consumer matches against a display name the source splits + * the name into substrings around the matched token; we render each part and + * bold whichever part case-insensitively equals the token. + */ + tokenizedDisplayName?: TokenizationPayload['tokenizedDisplayName']; + /** + * Fallback string rendered when the tokenized payload is absent (or empty). + */ + fallback?: string; + style?: StyleProp<TextStyle>; + matchStyle?: StyleProp<TextStyle>; + testID?: string; +}; + +const partMatchesToken = (part: string, token: string) => + token.length > 0 && part.toLowerCase() === token.toLowerCase(); + +export const TokenizedSuggestionParts = ({ + fallback, + matchStyle, + style, + tokenizedDisplayName, + testID, +}: TokenizedSuggestionPartsProps) => { + if (!tokenizedDisplayName || tokenizedDisplayName.parts.length === 0) { + if (!fallback) return null; + return ( + <Text style={style} testID={testID}> + {fallback} + </Text> + ); + } + + const { parts, token } = tokenizedDisplayName; + return ( + <Text style={style} testID={testID}> + {parts.map((part, index) => + partMatchesToken(part, token) ? ( + <Text key={index} style={matchStyle}> + {part} + </Text> + ) : ( + part + ), + )} + </Text> + ); +}; diff --git a/package/src/components/AutoCompleteInput/mentionItems/__tests__/MentionItems.test.tsx b/package/src/components/AutoCompleteInput/mentionItems/__tests__/MentionItems.test.tsx new file mode 100644 index 0000000000..049b453003 --- /dev/null +++ b/package/src/components/AutoCompleteInput/mentionItems/__tests__/MentionItems.test.tsx @@ -0,0 +1,129 @@ +import React from 'react'; + +import { cleanup, render } from '@testing-library/react-native'; + +// UserAvatar pulls in ComponentsContext defaults which transitively load +// stream-chat-js's CJS dist; that fails to resolve @babel/runtime when the +// SDK is consumed from a workspace symlink during tests. The avatar itself +// isn't what we assert on here, so substitute a no-op. +jest.mock('../../../ui/Avatar/UserAvatar', () => ({ + UserAvatar: () => null, +})); + +// Same reason — useMessageComposer (used by AutoCompleteSuggestionItem) pulls +// stream-chat-js's CJS dist at module load. The dispatcher we're testing +// doesn't use these hooks itself, so stub them. +jest.mock('../../../../contexts/messageInputContext/hooks/useMessageComposer', () => ({ + useMessageComposer: () => ({ textComposer: { handleSelect: () => {} } }), +})); +jest.mock('../../../../contexts/messageInputContext/hooks/useIsCommandDisabled', () => ({ + useIsCommandDisabled: () => false, +})); + +import type { + ChannelMentionSuggestion, + HereMentionSuggestion, + RoleMentionSuggestion, + UserGroupMentionSuggestion, + UserSuggestion, +} from 'stream-chat'; + +import { ThemeProvider } from '../../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../../contexts/themeContext/utils/theme'; +import { MentionSuggestionItem } from '../../AutoCompleteSuggestionItem'; + +const wrap = (ui: React.ReactElement) => + render(<ThemeProvider theme={defaultTheme}>{ui}</ThemeProvider>); + +const userEntity: UserSuggestion = { + id: 'u1', + mentionType: 'user', + name: 'Alice', + tokenizedDisplayName: { parts: ['Alice'], token: '' }, +} as unknown as UserSuggestion; + +const channelEntity: ChannelMentionSuggestion = { + id: 'channel', + mentionType: 'channel', + name: 'channel', + tokenizedDisplayName: { parts: ['channel'], token: '' }, +} as unknown as ChannelMentionSuggestion; + +const hereEntity: HereMentionSuggestion = { + id: 'here', + mentionType: 'here', + name: 'here', + tokenizedDisplayName: { parts: ['here'], token: '' }, +} as unknown as HereMentionSuggestion; + +const roleEntity: RoleMentionSuggestion = { + id: 'admin', + mentionType: 'role', + name: 'admin', + tokenizedDisplayName: { parts: ['admin'], token: '' }, +} as unknown as RoleMentionSuggestion; + +const groupEntity: UserGroupMentionSuggestion = { + description: 'Engineering org', + id: 'eng', + memberCount: 42, + mentionType: 'user_group', + name: 'engineering', + tokenizedDisplayName: { parts: ['engineering'], token: '' }, +} as unknown as UserGroupMentionSuggestion; + +describe('MentionSuggestionItem', () => { + afterEach(() => { + cleanup(); + }); + + it('renders a user row with the display name', () => { + const { getByText } = wrap(<MentionSuggestionItem {...userEntity} />); + expect(getByText('Alice')).toBeTruthy(); + }); + + it('renders a broadcast row for @channel with description subtitle', () => { + const { getByText } = wrap(<MentionSuggestionItem {...channelEntity} />); + expect(getByText('@channel')).toBeTruthy(); + expect(getByText('mention/Channel Description')).toBeTruthy(); + }); + + it('renders a broadcast row for @here with description subtitle', () => { + const { getByText } = wrap(<MentionSuggestionItem {...hereEntity} />); + expect(getByText('@here')).toBeTruthy(); + expect(getByText('mention/Here Description')).toBeTruthy(); + }); + + it('renders a role row with the role name and the notify subtitle', () => { + const { getByText } = wrap(<MentionSuggestionItem {...roleEntity} />); + expect(getByText('@admin')).toBeTruthy(); + // The test translation context echoes the i18n key; the {{ role }} + // interpolation is left as-is, which is enough to assert the right key + // was selected with the right argument. + expect(getByText(/Notify all .* members/)).toBeTruthy(); + }); + + it('renders a user group row with name + description', () => { + const { getByText } = wrap(<MentionSuggestionItem {...groupEntity} />); + expect(getByText('@engineering')).toBeTruthy(); + expect(getByText('Engineering org')).toBeTruthy(); + }); + + it('omits the subtitle slot when a user group has no description', () => { + const { queryByText } = wrap( + <MentionSuggestionItem + {...({ ...groupEntity, description: undefined } as UserGroupMentionSuggestion)} + />, + ); + expect(queryByText('Engineering org')).toBeNull(); + }); + + it('renders nothing for an unknown mention type', () => { + const { toJSON } = wrap( + <MentionSuggestionItem + {...({ id: 'x', mentionType: 'unknown' } as unknown as ChannelMentionSuggestion)} + />, + ); + expect(toJSON()).toBeNull(); + }); +}); diff --git a/package/src/components/AutoCompleteInput/mentionItems/__tests__/TokenizedSuggestionParts.test.tsx b/package/src/components/AutoCompleteInput/mentionItems/__tests__/TokenizedSuggestionParts.test.tsx new file mode 100644 index 0000000000..ad4efab5a2 --- /dev/null +++ b/package/src/components/AutoCompleteInput/mentionItems/__tests__/TokenizedSuggestionParts.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; + +import { cleanup, render } from '@testing-library/react-native'; + +import { TokenizedSuggestionParts } from '../TokenizedSuggestionParts'; + +describe('TokenizedSuggestionParts', () => { + afterEach(() => { + cleanup(); + }); + + it('renders the fallback when no tokenized payload is provided', () => { + const { getByText } = render(<TokenizedSuggestionParts fallback='Jane Doe' />); + expect(getByText('Jane Doe')).toBeTruthy(); + }); + + it('renders nothing when neither tokenized payload nor fallback is provided', () => { + const { toJSON } = render(<TokenizedSuggestionParts />); + expect(toJSON()).toBeNull(); + }); + + it('renders all parts when the tokenized payload is present', () => { + const { queryByText } = render( + <TokenizedSuggestionParts tokenizedDisplayName={{ parts: ['Al', 'i', 'ce'], token: 'i' }} />, + ); + // The full name still reads through because RN concatenates nested Text children. + expect(queryByText('Alice')).toBeTruthy(); + }); + + it('wraps the matched part in a separate Text node so it can be styled', () => { + const matchStyle = { fontWeight: 'bold' as const }; + const { UNSAFE_root } = render( + <TokenizedSuggestionParts + matchStyle={matchStyle} + tokenizedDisplayName={{ parts: ['Al', 'ice', 'son'], token: 'ice' }} + />, + ); + // The matched substring is rendered inside a nested Text — the only one + // carrying our matchStyle — so the count of styled descendants equals the + // number of matching parts (case-insensitive). + const matchedNodes = UNSAFE_root.findAll( + (node) => + typeof node.type !== 'string' && + Array.isArray(node.props?.style) === false && + node.props?.style === matchStyle, + ); + expect(matchedNodes.length).toBe(1); + }); + + it('matches case-insensitively', () => { + const matchStyle = { fontWeight: 'bold' as const }; + const { UNSAFE_root } = render( + <TokenizedSuggestionParts + matchStyle={matchStyle} + tokenizedDisplayName={{ parts: ['Channel'], token: 'channel' }} + />, + ); + const matchedNodes = UNSAFE_root.findAll( + (node) => typeof node.type !== 'string' && node.props?.style === matchStyle, + ); + expect(matchedNodes.length).toBe(1); + }); +}); diff --git a/package/src/components/AutoCompleteInput/mentionItems/index.ts b/package/src/components/AutoCompleteInput/mentionItems/index.ts new file mode 100644 index 0000000000..3041603672 --- /dev/null +++ b/package/src/components/AutoCompleteInput/mentionItems/index.ts @@ -0,0 +1,16 @@ +export { EnhancedMentionContent } from './EnhancedMentionContent'; +export type { EnhancedMentionContentProps } from './EnhancedMentionContent'; +export { EnhancedMentionIcon } from './EnhancedMentionIcon'; +export type { EnhancedMentionIconProps } from './EnhancedMentionIcon'; +export { MentionBroadcastItem } from './MentionBroadcastItem'; +export type { MentionBroadcastItemProps } from './MentionBroadcastItem'; +export { MentionItem } from './MentionItem'; +export type { MentionItemProps } from './MentionItem'; +export { MentionRoleItem } from './MentionRoleItem'; +export type { MentionRoleItemProps } from './MentionRoleItem'; +export { MentionUserGroupItem } from './MentionUserGroupItem'; +export type { MentionUserGroupItemProps } from './MentionUserGroupItem'; +export { MentionUserItem } from './MentionUserItem'; +export type { MentionUserItemProps } from './MentionUserItem'; +export { TokenizedSuggestionParts } from './TokenizedSuggestionParts'; +export type { TokenizedSuggestionPartsProps } from './TokenizedSuggestionParts'; diff --git a/package/src/components/ChannelDetails/ChannelDetails.tsx b/package/src/components/ChannelDetails/ChannelDetails.tsx new file mode 100644 index 0000000000..c14c3f4f51 --- /dev/null +++ b/package/src/components/ChannelDetails/ChannelDetails.tsx @@ -0,0 +1,238 @@ +import React, { useMemo } from 'react'; +import { ScrollView, StyleSheet, View } from 'react-native'; + +import type { Channel, ChannelMemberResponse } from 'stream-chat'; + +import type { GetChannelDetailsNavigationItems } from './hooks/useChannelDetailsNavigationItems'; + +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 ChannelDetailsProps = { + 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; + /** + * Customize the navigation rows rendered in the channel details navigation section. + * + * Receives the built-in `defaultItems` (and a `context`) and returns the rows to render. + * Map over `defaultItems` to override a row's `onPress` (e.g. to push your own screen) or + * to add/remove rows. Any row whose `onPress` you leave untouched keeps its built-in + * behavior (opening the built-in modal), including sections added in future SDK versions. + */ + getNavigationItems?: GetChannelDetailsNavigationItems; + /** + * 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 ChannelDetailsContent = () => { + const { channel } = useChannelDetailsContext(); + const { + theme: { + channelDetails: { container: containerOverride, scrollContent: scrollContentOverride }, + semantics, + }, + } = useTheme(); + const { + ChannelDetailsActionsSection, + ChannelDetailsEditButton, + ChannelDetailsMemberSection, + ChannelDetailsNavigationSection, + ChannelDetailsProfile, + ChannelDetailsNavHeader, + } = useComponentsContext(); + const isDirect = useIsDirectChat(channel); + const styles = useStyles(); + + return ( + <View + style={[ + styles.container, + { backgroundColor: semantics.backgroundCoreApp }, + containerOverride, + ]} + > + <ChannelDetailsNavHeader action={<ChannelDetailsEditButton />} /> + <ScrollView contentContainerStyle={[styles.scrollContent, scrollContentOverride]}> + <ChannelDetailsProfile /> + <ChannelDetailsNavigationSection /> + {isDirect ? null : <ChannelDetailsMemberSection />} + <ChannelDetailsActionsSection /> + </ScrollView> + </View> + ); +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelDetails = ({ + channel, + compressImageQuality, + doFileUploadRequest, + getChannelActionItems, + getChannelMemberActionItems, + getMemberRoleLabel, + getNavigationItems, + onAddMembersPress, + onBack, + onChannelDismiss, + onEditChannelPress, + onMemberPress, + onViewAllMembersPress, +}: ChannelDetailsProps) => { + const { ChannelDetailsContent: ChannelDetailsContentOverride } = useComponentsContext(); + const value = useMemo<ChannelDetailsContextValue>( + () => ({ + channel, + compressImageQuality, + doFileUploadRequest, + getChannelActionItems, + getChannelMemberActionItems, + getMemberRoleLabel, + getNavigationItems, + onAddMembersPress, + onBack, + onChannelDismiss, + onEditChannelPress, + onMemberPress, + onViewAllMembersPress, + }), + [ + channel, + compressImageQuality, + doFileUploadRequest, + getChannelActionItems, + getChannelMemberActionItems, + getMemberRoleLabel, + getNavigationItems, + onAddMembersPress, + onBack, + onChannelDismiss, + onEditChannelPress, + onMemberPress, + onViewAllMembersPress, + ], + ); + const Content = ChannelDetailsContentOverride ?? ChannelDetailsContent; + const notificationHostId = channel?.cid ? `channel-details:${channel.cid}` : undefined; + + return ( + <ChannelDetailsContextProvider value={value}> + {notificationHostId ? ( + <NotificationTargetProvider hostId={notificationHostId} panel='channel-details'> + <Content /> + <NotificationList /> + </NotificationTargetProvider> + ) : ( + <Content /> + )} + </ChannelDetailsContextProvider> + ); +}; + +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/ChannelDetails/__tests__/ChannelDetails.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetails.test.tsx new file mode 100644 index 0000000000..903b88a9ef --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetails.test.tsx @@ -0,0 +1,203 @@ +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 { ChannelDetails } from '../ChannelDetails'; + +const Providers = ({ children }: PropsWithChildren) => ( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChatContext.Provider + value={{ client: { notifications: new NotificationManager(), userID: 'me' } } as never} + > + {children} + </ChatContext.Provider> + </TranslationProvider> + </ThemeProvider> +); + +const HeaderProbe = () => <Text testID='probe-header'>HEADER</Text>; +const ProfileProbe = () => <Text testID='probe-profile'>PROFILE</Text>; +const NavigationProbe = () => <Text testID='probe-navigation'>NAVIGATION</Text>; +const MemberProbe = () => <Text testID='probe-member'>MEMBER</Text>; +const ActionsProbe = () => <Text testID='probe-actions'>ACTIONS</Text>; + +const SECTION_OVERRIDES = { + ChannelDetailsActionsSection: ActionsProbe, + ChannelDetailsMemberSection: MemberProbe, + ChannelDetailsNavigationSection: NavigationProbe, + ChannelDetailsProfile: ProfileProbe, + ChannelDetailsNavHeader: HeaderProbe, +}; + +const channel = { + cid: 'messaging:test', + id: 'test', + on: jest.fn(() => ({ unsubscribe: jest.fn() })), +} as unknown as Channel; + +const buildChannel = (capabilities: string[] = []) => + ({ + cid: 'messaging:test', + data: { own_capabilities: capabilities }, + id: 'test', + on: jest.fn(() => ({ unsubscribe: jest.fn() })), + state: { members: {} }, + }) as unknown as Channel; + +const renderContent = () => + render( + <Providers> + <WithComponents overrides={SECTION_OVERRIDES}> + <ChannelDetails channel={channel} /> + </WithComponents> + </Providers>, + ); + +describe('ChannelDetailsContent', () => { + 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(); + }); + + it('displays the edit button in the header for a group channel with the update-channel capability', () => { + useIsDirectChatSpy.mockReturnValue(false); + render( + <Providers> + <WithComponents + overrides={{ + ChannelDetailsActionsSection: ActionsProbe, + ChannelDetailsMemberSection: MemberProbe, + ChannelDetailsNavigationSection: NavigationProbe, + ChannelDetailsProfile: ProfileProbe, + }} + > + <ChannelDetails channel={buildChannel(['update-channel'])} /> + </WithComponents> + </Providers>, + ); + + expect(screen.getByTestId('channel-details-edit-button')).toBeTruthy(); + }); + }); +}); + +describe('ChannelDetails', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('context provisioning', () => { + it('exposes channel and callbacks via ChannelDetailsContext', () => { + const onChannelDismiss = jest.fn(); + const onBack = jest.fn(); + let captured: ReturnType<typeof useChannelDetailsContext> | undefined; + const ContextProbe = () => { + captured = useChannelDetailsContext(); + return null; + }; + + render( + <Providers> + <WithComponents + overrides={{ + ...SECTION_OVERRIDES, + ChannelDetailsContent: ContextProbe, + }} + > + <ChannelDetails channel={channel} onBack={onBack} onChannelDismiss={onChannelDismiss} /> + </WithComponents> + </Providers>, + ); + + expect(captured).toBeDefined(); + expect(captured?.channel).toBe(channel); + expect(captured?.onChannelDismiss).toBe(onChannelDismiss); + expect(captured?.onBack).toBe(onBack); + }); + }); + + describe('ChannelDetailsContent override', () => { + it('renders the override instead of the default content', () => { + const Override = () => <Text testID='custom-content'>CUSTOM</Text>; + render( + <Providers> + <WithComponents + overrides={{ + ...SECTION_OVERRIDES, + ChannelDetailsContent: Override, + }} + > + <ChannelDetails channel={channel} /> + </WithComponents> + </Providers>, + ); + + 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 ChannelDetailsContent 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( + <Providers> + <WithComponents overrides={SECTION_OVERRIDES}> + <ChannelDetails channel={channel} /> + </WithComponents> + </Providers>, + ); + expect(screen.getByTestId('probe-header')).toBeTruthy(); + expect(screen.getByTestId('probe-actions')).toBeTruthy(); + }); + }); +}); diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsActionItem.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsActionItem.test.tsx new file mode 100644 index 0000000000..e3593f13f1 --- /dev/null +++ b/package/src/components/ChannelDetails/__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, [IconProps]>(() => null); + +const renderItem = (props: Partial<React.ComponentProps<typeof ChannelDetailsActionItem>> = {}) => + render( + <ThemeProvider theme={defaultTheme}> + <ChannelDetailsActionItem Icon={TestIcon} label='Pinned Messages' {...props} /> + </ThemeProvider>, + ); + +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: <Text testID='trailing'>5</Text> }); + 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( + <ThemeProvider theme={defaultTheme}> + <ChannelDetailsActionItem Icon={TestIcon} destructive label='Pinned Messages' /> + </ThemeProvider>, + ); + 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: <Text testID='trailing'>5</Text> }); + // 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/ChannelDetails/__tests__/ChannelDetailsActionsSection.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsActionsSection.test.tsx new file mode 100644 index 0000000000..e99679f057 --- /dev/null +++ b/package/src/components/ChannelDetails/__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> = {}): 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 ( + <> + <Text testID={props.testID} onPress={props.onPress}> + {props.label} + </Text> + {props.trailing} + </> + ); +}; + +const sectionElement = () => ( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChatContext.Provider + value={ + { + client: { + mutedUsers: [], + on: () => ({ unsubscribe: () => undefined }), + userID: 'me', + }, + } as never + } + > + <ChannelDetailsContextProvider value={{ channel }}> + <WithComponents overrides={{ ChannelDetailsActionItem: ActionItemProbe }}> + <ChannelDetailsActionsSection /> + </WithComponents> + </ChannelDetailsContextProvider> + </ChatContext.Provider> + </TranslationProvider> + </ThemeProvider> +); + +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/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx new file mode 100644 index 0000000000..4b9c0594d2 --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx @@ -0,0 +1,127 @@ +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 { ChannelDetailsEditButton } from '../components/ChannelDetailsEditButton'; + +jest.mock('../../../hooks/actions/useChannelActions'); +const mockedUseChannelActions = jest.mocked(useChannelActions); + +const EditDetailsProbe = () => ( + <View testID='channel-edit-details-probe'> + <Text>edit-details</Text> + </View> +); + +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) => ( + <ThemeProvider theme={defaultTheme}> + <AccessibilityProvider value={{ enabled: true }}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChatContext.Provider + value={{ client: { notifications: new NotificationManager(), userID: 'me' } } as never} + > + {children} + </ChatContext.Provider> + </TranslationProvider> + </AccessibilityProvider> + </ThemeProvider> +); + +const renderEditButton = ({ + channel, + onEditChannelPress, +}: { + channel: Channel; + onEditChannelPress?: () => void; +}) => + render( + <Providers> + <WithComponents overrides={{ ChannelEditDetails: EditDetailsProbe }}> + <ChannelDetailsContextProvider value={{ channel, onEditChannelPress }}> + <ChannelDetailsEditButton /> + </ChannelDetailsContextProvider> + </WithComponents> + </Providers>, + ); + +describe('ChannelDetailsEditButton', () => { + beforeEach(() => { + jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(false); + mockedUseChannelActions.mockReturnValue({ + updateImage: jest.fn(), + updateName: jest.fn(), + } as unknown as ReturnType<typeof useChannelActions>); + }); + + afterEach(() => { + jest.restoreAllMocks(); + mockedUseChannelActions.mockReset(); + }); + + it('does not render the Edit button when the user lacks the update-channel capability', () => { + renderEditButton({ channel: buildChannel([]) }); + + expect(screen.queryByTestId('channel-details-edit-button')).toBeNull(); + }); + + it('renders the Edit button when the user has the update-channel capability', () => { + renderEditButton({ 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); + + renderEditButton({ 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(); + renderEditButton({ 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', () => { + renderEditButton({ 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(); + }); +}); diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsMemberSection.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsMemberSection.test.tsx new file mode 100644 index 0000000000..21a6132daf --- /dev/null +++ b/package/src/components/ChannelDetails/__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 = () => <Text testID='member-list-probe'>full-member-list</Text>; + +const AddMembersProbe = () => <Text testID='add-members-probe'>add-members</Text>; + +const memberItemProbeCalls: ChannelMemberItemProps[] = []; +const MemberItemProbe = (props: ChannelMemberItemProps) => { + memberItemProbeCalls.push(props); + return <Text testID={`member-item-${props.member.user?.id}`}>{props.member.user?.name}</Text>; +}; + +const MemberActionsSheetProbe = ({ member }: ChannelMemberActionsSheetProps) => ( + <Text testID='member-actions-sheet-probe'>{member.user?.id ?? ''}</Text> +); + +const buildChannel = ( + members: ChannelMemberResponse[], + memberCount?: number, + overrides?: Partial<Channel>, +): 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<OwnCapabilitiesContextValue>, +): Channel => { + if (!overrides) return channel; + const ownCapabilities = Object.entries(overrides) + .filter(([, enabled]) => enabled) + .map(([key]) => allOwnCapabilities[key as OwnCapability]); + (channel as { data?: Record<string, unknown> }).data = { + ...((channel as { data?: Record<string, unknown> }).data ?? {}), + own_capabilities: ownCapabilities, + }; + return channel; +}; + +const renderSection = ({ + capabilities, + channel, + onAddMembersPress, + onMemberPress, + onViewAllMembersPress, +}: { + channel: Channel; + capabilities?: Partial<OwnCapabilitiesContextValue>; + onAddMembersPress?: () => void; + onMemberPress?: (member: ChannelMemberResponse) => void; + onViewAllMembersPress?: () => void; +}) => + render( + <ThemeProvider theme={defaultTheme}> + <AccessibilityProvider value={{ enabled: true }}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChatContext.Provider + value={ + { + client: { + mutedUsers: [], + notifications: new NotificationManager(), + on: () => ({ unsubscribe: () => undefined }), + userID: 'me', + }, + } as never + } + > + <ChannelDetailsContextProvider + value={{ + channel: applyCapabilities(channel, capabilities), + onAddMembersPress, + onMemberPress, + onViewAllMembersPress, + }} + > + <WithComponents + overrides={{ + ChannelAddMembers: AddMembersProbe, + ChannelMemberActionsSheet: MemberActionsSheetProbe, + ChannelMemberItem: MemberItemProbe, + ChannelMemberList: MemberListProbe, + }} + > + <ChannelDetailsMemberSection /> + </WithComponents> + </ChannelDetailsContextProvider> + </ChatContext.Provider> + </TranslationProvider> + </AccessibilityProvider> + </ThemeProvider>, + ); + +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<typeof useChannelActions>); + }); + + 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/ChannelDetails/__tests__/ChannelDetailsNavHeader.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavHeader.test.tsx new file mode 100644 index 0000000000..3cf340855d --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavHeader.test.tsx @@ -0,0 +1,111 @@ +import React, { PropsWithChildren } from 'react'; +import { Text, View } from 'react-native'; + +import { 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 { 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 { ChannelDetailsNavHeader } from '../components/ChannelDetailsNavHeader'; + +const ActionProbe = () => ( + <View testID='channel-details-action-probe'> + <Text>action</Text> + </View> +); + +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) => ( + <ThemeProvider theme={defaultTheme}> + <AccessibilityProvider value={{ enabled: true }}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChatContext.Provider + value={{ client: { notifications: new NotificationManager(), userID: 'me' } } as never} + > + {children} + </ChatContext.Provider> + </TranslationProvider> + </AccessibilityProvider> + </ThemeProvider> +); + +const renderHeader = ({ + action, + channel, + onBack, +}: { + action?: React.ReactNode; + channel: Channel; + onBack?: () => void; +}) => + render( + <Providers> + <ChannelDetailsContextProvider value={{ channel, onBack }}> + <ChannelDetailsNavHeader action={action} /> + </ChannelDetailsContextProvider> + </Providers>, + ); + +describe('ChannelDetailsNavHeader', () => { + beforeEach(() => { + jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(false); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders the action node passed via the action slot', () => { + renderHeader({ action: <ActionProbe />, channel: buildChannel([]) }); + + expect(screen.getByTestId('channel-details-action-probe')).toBeTruthy(); + }); + + it('resolves the group info title for a non-direct channel', () => { + renderHeader({ channel: buildChannel([]) }); + + expect(screen.getByText('Group Info')).toBeTruthy(); + }); + + it('resolves the contact info title for a direct (1:1) channel', () => { + jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(true); + + renderHeader({ channel: buildChannel([]) }); + + expect(screen.getByText('Contact Info')).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( + <Providers> + <ChannelDetailsContextProvider value={{ channel: buildChannel([]), onBack: jest.fn() }}> + <ChannelDetailsNavHeader /> + </ChannelDetailsContextProvider> + </Providers>, + ); + + expect(screen.getByTestId('channel-details-back-button')).toBeTruthy(); + }); +}); diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavigationSection.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavigationSection.test.tsx new file mode 100644 index 0000000000..8c34e3735c --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavigationSection.test.tsx @@ -0,0 +1,300 @@ +import React from 'react'; +import { Modal } from 'react-native'; + +import type { SharedValue } from 'react-native-reanimated'; + +import { act, fireEvent, render } from '@testing-library/react-native'; + +import { + ChannelDetailsContextProvider, + type ChannelDetailsContextValue, +} from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { + type Overlay, + OverlayContext, + type OverlayContextValue, +} from '../../../contexts/overlayContext/OverlayContext'; +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 pinnedListProbe: object[] = []; + +jest.mock('../components/navigation-section/PinnedMessageList', () => { + const ReactLib = require('react'); + const { Text: RNText } = require('react-native'); + return { + PinnedMessageList: (props: object) => { + pinnedListProbe.push(props); + return ReactLib.createElement(RNText, { testID: 'pinned-message-list' }, 'list'); + }, + }; +}); + +jest.mock('../components/navigation-section/MediaList', () => { + const ReactLib = require('react'); + const { Text: RNText } = require('react-native'); + return { + MediaList: () => ReactLib.createElement(RNText, { testID: 'media-list' }, 'media'), + }; +}); + +jest.mock('../../ImageGallery/ImageGallery', () => { + const ReactLib = require('react'); + const { Text: RNText } = require('react-native'); + return { + ImageGallery: () => ReactLib.createElement(RNText, { testID: 'image-gallery' }, 'gallery'), + }; +}); + +const renderSection = ( + contextValue: Partial<ChannelDetailsContextValue> = {}, + overlay: Overlay = 'none', +) => { + const overlayContextValue: OverlayContextValue = { + overlay, + overlayOpacity: { value: overlay === 'none' ? 0 : 1 } as SharedValue<number>, + setOverlay: jest.fn(), + }; + return render( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <OverlayContext.Provider value={overlayContextValue}> + <ChannelDetailsContextProvider value={contextValue as ChannelDetailsContextValue}> + <ChannelDetailsNavigationSection /> + </ChannelDetailsContextProvider> + </OverlayContext.Provider> + </TranslationProvider> + </ThemeProvider>, + ); +}; + +describe('ChannelDetailsNavigationSection', () => { + beforeEach(() => { + probeCalls.length = 0; + pinnedListProbe.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', () => { + renderSection(); + + expect(probeCalls).toHaveLength(3); + probeCalls.forEach((props) => { + expect(props.Icon).toBeTruthy(); + expect(props.trailing).toBeTruthy(); + }); + }); + + 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); + }); + + describe('without a getNavigationItems prop (default mode)', () => { + it('makes every row interactive', () => { + renderSection(); + + const [pinned, photos, files] = probeCalls; + expect(pinned.onPress).toBeDefined(); + expect(photos.onPress).toBeDefined(); + expect(files.onPress).toBeDefined(); + }); + + it('renders a single modal that is hidden with no content until a section is selected', () => { + const { UNSAFE_getByType, queryByTestId } = renderSection(); + + expect(UNSAFE_getByType(Modal).props.visible).toBe(false); + expect(queryByTestId('pinned-message-list')).toBeNull(); + }); + + it('opens the modal with the pinned messages content when the pinned messages row is pressed', () => { + const { UNSAFE_getByType, getByTestId } = renderSection(); + + fireEvent.press(getByTestId('channel-details-pinned-messages')); + + expect(UNSAFE_getByType(Modal).props.visible).toBe(true); + expect(getByTestId('pinned-message-list')).toBeTruthy(); + }); + + it('opens an empty modal (no pinned list) for sections without a built-in screen', () => { + const { UNSAFE_getByType, getByTestId, queryByTestId } = renderSection(); + + fireEvent.press(getByTestId('channel-details-photos-and-videos')); + + expect(UNSAFE_getByType(Modal).props.visible).toBe(true); + expect(queryByTestId('pinned-message-list')).toBeNull(); + }); + + it('closes the modal and clears its content when the modal requests it', () => { + const { UNSAFE_getByType, getByTestId, queryByTestId } = renderSection(); + + fireEvent.press(getByTestId('channel-details-pinned-messages')); + expect(UNSAFE_getByType(Modal).props.visible).toBe(true); + + act(() => { + UNSAFE_getByType(Modal).props.onRequestClose(); + }); + + expect(UNSAFE_getByType(Modal).props.visible).toBe(false); + expect(queryByTestId('pinned-message-list')).toBeNull(); + }); + }); + + describe('image gallery overlay', () => { + it('renders the gallery above the media list when the overlay is set to "gallery"', () => { + const { getByTestId } = renderSection({}, 'gallery'); + + fireEvent.press(getByTestId('channel-details-photos-and-videos')); + + expect(getByTestId('media-list')).toBeTruthy(); + expect(getByTestId('image-gallery')).toBeTruthy(); + }); + + it('does not render the gallery while the overlay is "none"', () => { + const { getByTestId, queryByTestId } = renderSection({}, 'none'); + + fireEvent.press(getByTestId('channel-details-photos-and-videos')); + + expect(getByTestId('media-list')).toBeTruthy(); + expect(queryByTestId('image-gallery')).toBeNull(); + }); + + it('does not render the gallery for non-media sections even when the overlay is "gallery"', () => { + const { getByTestId, queryByTestId } = renderSection({}, 'gallery'); + + fireEvent.press(getByTestId('channel-details-pinned-messages')); + expect(queryByTestId('image-gallery')).toBeNull(); + + fireEvent.press(getByTestId('channel-details-files')); + expect(queryByTestId('image-gallery')).toBeNull(); + }); + }); + + describe('with a getNavigationItems prop', () => { + it('receives the built-in default items (section, label, Icon) and a context', () => { + const getNavigationItems = jest.fn(({ defaultItems }) => defaultItems); + renderSection({ getNavigationItems }); + + expect(getNavigationItems).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ t: expect.any(Function) }), + defaultItems: [ + expect.objectContaining({ + Icon: expect.any(Function), + label: 'Pinned Messages', + section: 'pinned-messages', + }), + expect.objectContaining({ label: 'Photos & Videos', section: 'photos-and-videos' }), + expect.objectContaining({ label: 'Files', section: 'files' }), + ], + }), + ); + // Default items carry no onPress; the section component supplies the open-modal behavior. + const { defaultItems } = getNavigationItems.mock.calls[0][0]; + expect( + defaultItems.every((item: { onPress?: () => void }) => item.onPress === undefined), + ).toBe(true); + }); + + it('renders exactly the items the customizer returns', () => { + const getNavigationItems = ({ defaultItems }: { defaultItems: { section: string }[] }) => + defaultItems.filter((item) => item.section === 'pinned-messages'); + const { getByTestId, queryByTestId } = renderSection({ + getNavigationItems: getNavigationItems as never, + }); + + expect(getByTestId('channel-details-pinned-messages')).toBeTruthy(); + expect(queryByTestId('channel-details-photos-and-videos')).toBeNull(); + expect(queryByTestId('channel-details-files')).toBeNull(); + }); + + it('runs a custom onPress instead of opening the built-in modal', () => { + const customOnPress = jest.fn(); + const getNavigationItems = ({ defaultItems }: { defaultItems: { onPress: () => void }[] }) => + defaultItems.map((item) => ({ ...item, onPress: customOnPress })); + const { UNSAFE_getByType, getByTestId } = renderSection({ + getNavigationItems: getNavigationItems as never, + }); + + fireEvent.press(getByTestId('channel-details-pinned-messages')); + + expect(customOnPress).toHaveBeenCalledTimes(1); + expect(UNSAFE_getByType(Modal).props.visible).toBe(false); + }); + + it('still opens the built-in modal when a row keeps its default onPress', () => { + const getNavigationItems = ({ defaultItems }: { defaultItems: unknown[] }) => defaultItems; + const { UNSAFE_getByType, getByTestId } = renderSection({ + getNavigationItems: getNavigationItems as never, + }); + + fireEvent.press(getByTestId('channel-details-pinned-messages')); + + expect(UNSAFE_getByType(Modal).props.visible).toBe(true); + expect(getByTestId('pinned-message-list')).toBeTruthy(); + }); + + it('renders consumer-added rows with custom section identifiers', () => { + const customOnPress = jest.fn(); + const getNavigationItems = ({ defaultItems }: { defaultItems: unknown[] }) => [ + ...defaultItems, + { + Icon: () => null, + label: 'My Custom Row', + onPress: customOnPress, + section: 'my-custom-section', + }, + ]; + const { getByTestId } = renderSection({ getNavigationItems: getNavigationItems as never }); + + const customRow = getByTestId('channel-details-my-custom-section'); + expect(customRow).toBeTruthy(); + + fireEvent.press(customRow); + expect(customOnPress).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsProfile.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsProfile.test.tsx new file mode 100644 index 0000000000..f6a468b5ee --- /dev/null +++ b/package/src/components/ChannelDetails/__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( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChatProvider value={{ client: { userID: OWN_USER_ID } } as never}> + <ChannelDetailsContextProvider value={{ channel }}> + <ChannelDetailsProfile /> + </ChannelDetailsContextProvider> + </ChatProvider> + </TranslationProvider> + </ThemeProvider>, + ); + +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/ChannelDetails/__tests__/ChannelEditDetails.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelEditDetails.test.tsx new file mode 100644 index 0000000000..33fc51da7e --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/ChannelEditDetails.test.tsx @@ -0,0 +1,316 @@ +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 { + ChannelEditDetailsContext, + useChannelEditDetailsContext, +} from '../../../contexts/channelEditDetailsContext/ChannelEditDetailsContext'; +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 { EditChannelDetailsStore } from '../../../state-store/edit-channel-details-store'; +import { ChannelEditDetails } from '../components/ChannelEditDetails'; +import type { ChannelEditImageSheetProps } from '../components/ChannelEditImageSheet'; + +type SheetProbeRecord = ChannelEditImageSheetProps; +const sheetCalls: SheetProbeRecord[] = []; + +// The real sheet drives the store directly; the probe mirrors that by setting +// the pending action via the context-resolved store. +const SheetProbe = (props: ChannelEditImageSheetProps) => { + const { store } = useChannelEditDetailsContext(); + sheetCalls.push(props); + if (!props.visible) return null; + return ( + <> + <Pressable onPress={props.onClose} testID='sheet-probe-close'> + <Text>close</Text> + </Pressable> + <Pressable onPress={() => store.setPendingAction('camera')} testID='sheet-probe-camera'> + <Text>camera</Text> + </Pressable> + <Pressable onPress={() => store.setPendingAction('library')} testID='sheet-probe-library'> + <Text>library</Text> + </Pressable> + <Pressable onPress={() => store.setPendingAction('reset')} testID='sheet-probe-reset'> + <Text>reset</Text> + </Pressable> + </> + ); +}; + +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 }: { channel: Channel }) => { + const store = new EditChannelDetailsStore(channel); + const utils = render( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChatContext.Provider + value={ + { client: { on: () => ({ unsubscribe: () => undefined }), userID: 'me' } } as never + } + > + <ChannelDetailsContextProvider value={{ channel }}> + <ChannelEditDetailsContext.Provider value={{ store }}> + <WithComponents overrides={{ ChannelEditImageSheet: SheetProbe }}> + <ChannelEditDetails /> + </WithComponents> + </ChannelEditDetailsContext.Provider> + </ChannelDetailsContextProvider> + </ChatContext.Provider> + </TranslationProvider> + </ThemeProvider>, + ); + return { ...utils, store }; +}; + +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('renders the context-resolved ChannelEditName', () => { + const NameProbe = () => <Text testID='name-probe'>name</Text>; + + render( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChatContext.Provider + value={ + { client: { on: () => ({ unsubscribe: () => undefined }), userID: 'me' } } as never + } + > + <ChannelDetailsContextProvider value={{ channel: buildChannel() }}> + <ChannelEditDetailsContext.Provider + value={{ store: new EditChannelDetailsStore(buildChannel()) }} + > + <WithComponents + overrides={{ ChannelEditImageSheet: SheetProbe, ChannelEditName: NameProbe }} + > + <ChannelEditDetails /> + </WithComponents> + </ChannelEditDetailsContext.Provider> + </ChannelDetailsContextProvider> + </ChatContext.Provider> + </TranslationProvider> + </ThemeProvider>, + ); + + expect(screen.getByTestId('name-probe')).toBeTruthy(); + }); + + 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('stores a camera-captured file after the sheet closes', async () => { + const file = generateFileReference(); + jest.spyOn(NativeHandlers, 'takePhoto').mockResolvedValue(file); + + const { store } = renderComponent({ channel: buildChannel() }); + 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)); + await waitFor(() => expect(store.state.getLatestValue().updatedImage).toBe(file)); + }); + + it('stores a picked gallery file after the sheet closes', async () => { + const file = generateFileReference(); + jest + .spyOn(NativeHandlers, 'pickImage') + .mockResolvedValue({ assets: [file], cancelled: false }); + + const { store } = renderComponent({ channel: buildChannel() }); + 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)); + await waitFor(() => + expect(store.state.getLatestValue().updatedImage).toEqual( + 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 store an image when the camera flow is cancelled', async () => { + jest.spyOn(NativeHandlers, 'takePhoto').mockResolvedValue({ cancelled: true } as never); + + const { store } = renderComponent({ channel: buildChannel() }); + 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(store.state.getLatestValue().updatedImage).toBeUndefined(); + }); + + it('resets the image in the store after the sheet closes when Reset is pressed', async () => { + const { store } = renderComponent({ + channel: buildChannel({ image: '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(store.state.getLatestValue().updatedImage).toBeNull()); + }); + }); + + 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 () => { + renderComponent({ + channel: buildChannel({ image: 'https://example.com/live.png' }), + }); + 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(avatarImageUri()).toBeUndefined()); + }); + }); +}); diff --git a/package/src/components/ChannelDetails/__tests__/ChannelEditDetailsModal.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelEditDetailsModal.test.tsx new file mode 100644 index 0000000000..b6abdb5ce2 --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/ChannelEditDetailsModal.test.tsx @@ -0,0 +1,249 @@ +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 { useChannelEditDetailsContext } from '../../../contexts/channelEditDetailsContext/ChannelEditDetailsContext'; +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 { ChannelEditDetailsModal } from '../components/ChannelEditDetailsModal'; + +jest.mock('../../../hooks/actions/useChannelActions'); +const mockedUseChannelActions = jest.mocked(useChannelActions); + +// Stands in for ChannelEditDetails: drives the channel name through the store +// exposed by ChannelEditDetailsContext, the same way the real component does. +const EditDetailsProbe = () => { + const { store } = useChannelEditDetailsContext(); + return ( + <View> + <TextInput + onChangeText={(name) => store.setCurrentName(name)} + placeholder='channel-name' + testID='channel-edit-name-input' + /> + <Pressable onPress={() => store.setCurrentName('Different')} testID='probe-set-name'> + <Text>set</Text> + </Pressable> + <Pressable onPress={() => store.setCurrentName('')} testID='probe-clear-name'> + <Text>clear</Text> + </Pressable> + <Pressable onPress={() => store.setCurrentName(' ')} testID='probe-whitespace-name'> + <Text>whitespace</Text> + </Pressable> + </View> + ); +}; + +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( + <ThemeProvider theme={defaultTheme}> + <AccessibilityProvider value={{ enabled: true }}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChatContext.Provider + value={{ client: { notifications: new NotificationManager(), userID: 'me' } } as never} + > + <ChannelDetailsContextProvider value={{ channel }}> + <WithComponents overrides={{ ChannelEditDetails: EditDetailsProbe }}> + <ChannelEditDetailsModal onClose={onClose} visible={visible} /> + </WithComponents> + </ChannelDetailsContextProvider> + </ChatContext.Provider> + </TranslationProvider> + </AccessibilityProvider> + </ThemeProvider>, + ); + +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<typeof useChannelActions>); + }); + + 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('enables confirm when the value differs from the initial name only by whitespace', () => { + 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: false }); + }); + + it('passes the raw (untrimmed) 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<void>((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/ChannelDetails/__tests__/ChannelEditImageSheet.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelEditImageSheet.test.tsx new file mode 100644 index 0000000000..79105ff5af --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/ChannelEditImageSheet.test.tsx @@ -0,0 +1,186 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +import { ChannelEditDetailsContext } from '../../../contexts/channelEditDetailsContext/ChannelEditDetailsContext'; +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 { EditChannelDetailsStore } from '../../../state-store/edit-channel-details-store'; +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 ( + <BottomSheetProvider + value={ + { + close: runClose, + currentSnapIndex: { value: 0 }, + dismiss: runClose, + topSnapIndex: { value: 0 }, + } as never + } + > + {children} + </BottomSheetProvider> + ); + }, + }; +}); + +type Probe = ChannelDetailsActionItemProps & { testID?: string }; + +const probeCalls: Probe[] = []; +const ActionItemProbe = (props: Probe) => { + probeCalls.push(props); + return ( + <Text onPress={props.onPress} testID={props.testID}> + {props.label} + </Text> + ); +}; + +const buildChannel = (overrides?: { image?: string }): Channel => + ({ + cid: 'messaging:test', + data: { + ...(overrides && 'image' in overrides ? { image: overrides.image } : {}), + }, + on: () => ({ unsubscribe: () => undefined }), + state: { members: {} }, + }) as unknown as Channel; + +const renderSheet = ({ + channel = buildChannel(), + onClose = jest.fn(), + visible = true, +}: { + channel?: Channel; + onClose?: () => void; + visible?: boolean; +} = {}) => { + const store = new EditChannelDetailsStore(channel); + const utils = render( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChannelEditDetailsContext.Provider value={{ store }}> + <WithComponents overrides={{ ChannelDetailsActionItem: ActionItemProbe }}> + <ChannelEditImageSheet onClose={onClose} visible={visible} /> + </WithComponents> + </ChannelEditDetailsContext.Provider> + </TranslationProvider> + </ThemeProvider>, + ); + return { ...utils, store }; +}; + +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 there is no image to reset', () => { + renderSheet({ channel: buildChannel() }); + + 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 the channel has an image', () => { + renderSheet({ channel: buildChannel({ image: 'https://example.com/live.png' }) }); + + 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 sets the camera pending action when Take Photo is pressed', () => { + const onClose = jest.fn(); + const { store } = renderSheet({ onClose }); + + fireEvent.press(screen.getByTestId('channel-edit-picture-take-photo')); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(store.state.getLatestValue().pendingAction).toBe('camera'); + }); + + it('closes the sheet and sets the library pending action when Choose Image is pressed', () => { + const onClose = jest.fn(); + const { store } = renderSheet({ onClose }); + + fireEvent.press(screen.getByTestId('channel-edit-picture-choose-image')); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(store.state.getLatestValue().pendingAction).toBe('library'); + }); + + it('closes the sheet and sets the reset pending action when Reset Picture is pressed', () => { + const onClose = jest.fn(); + const { store } = renderSheet({ + channel: buildChannel({ image: 'https://example.com/live.png' }), + onClose, + }); + + fireEvent.press(screen.getByTestId('channel-edit-picture-reset')); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(store.state.getLatestValue().pendingAction).toBe('reset'); + }); + + 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/ChannelDetails/__tests__/ChannelEditName.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelEditName.test.tsx new file mode 100644 index 0000000000..c1194cd6d1 --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/ChannelEditName.test.tsx @@ -0,0 +1,89 @@ +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +import { ChannelEditDetailsContext } from '../../../contexts/channelEditDetailsContext/ChannelEditDetailsContext'; +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 { EditChannelDetailsStore } from '../../../state-store/edit-channel-details-store'; +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 }: { channel: Channel }) => { + const store = new EditChannelDetailsStore(channel); + const utils = render( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChatContext.Provider + value={ + { client: { on: () => ({ unsubscribe: () => undefined }), userID: 'me' } } as never + } + > + <ChannelEditDetailsContext.Provider value={{ store }}> + <ChannelEditName /> + </ChannelEditDetailsContext.Provider> + </ChatContext.Provider> + </TranslationProvider> + </ThemeProvider>, + ); + return { ...utils, store }; +}; + +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('writes the typed value to the store', () => { + const { store } = renderComponent({ channel: buildChannel({ name: 'Original' }) }); + + fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), 'Renamed'); + + expect(store.state.getLatestValue().currentName).toBe('Renamed'); + expect(screen.getByTestId('channel-edit-name-input').props.value).toBe('Renamed'); + }); + + it('writes an empty string to the store when the user clears the input', () => { + const { store } = renderComponent({ channel: buildChannel({ name: 'Original' }) }); + + fireEvent.changeText(screen.getByTestId('channel-edit-name-input'), ''); + + expect(store.state.getLatestValue().currentName).toBe(''); + }); + + it('leaves currentName at the initial name on mount', () => { + const { store } = renderComponent({ channel: buildChannel({ name: 'Original' }) }); + + expect(store.state.getLatestValue().currentName).toBe('Original'); + }); +}); diff --git a/package/src/components/ChannelDetails/__tests__/members/AddMemberSearchResultItem.test.tsx b/package/src/components/ChannelDetails/__tests__/members/AddMemberSearchResultItem.test.tsx new file mode 100644 index 0000000000..8d63604cfa --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/members/AddMemberSearchResultItem.test.tsx @@ -0,0 +1,121 @@ +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; +import type { UserResponse } from 'stream-chat'; + +import { ChannelAddMembersContext } from '../../../../contexts/channelAddMembersContext/ChannelAddMembersContext'; +import { ThemeProvider } from '../../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../../contexts/translationContext/TranslationContext'; +import { useIsChannelMember } from '../../../../hooks/useIsChannelMember'; +import { generateUser } from '../../../../mock-builders/generator/user'; +import { SelectionStore } from '../../../../state-store/selection-store'; +import { AddMemberSearchResultItem } from '../../components/members/AddMemberSearchResultItem'; + +jest.mock('../../../../hooks/useIsChannelMember', () => ({ + useIsChannelMember: jest.fn(), +})); + +const mockedUseIsChannelMember = useIsChannelMember as jest.MockedFunction< + typeof useIsChannelMember +>; + +type RenderRowOptions = { + isAlreadyMember?: boolean; + onPress?: (user: UserResponse) => void; + selected?: boolean; + user: UserResponse; +}; + +const renderRow = ({ + isAlreadyMember = false, + onPress = jest.fn(), + selected = false, + user, +}: RenderRowOptions) => { + mockedUseIsChannelMember.mockReturnValue(isAlreadyMember); + const selectionStore = new SelectionStore(); + if (selected) { + selectionStore.select(user.id); + } + return render( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string, options?: Record<string, unknown>) => { + 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', + }} + > + <ChannelAddMembersContext.Provider value={{ selectionStore } as never}> + <AddMemberSearchResultItem onPress={onPress} user={user} /> + </ChannelAddMembersContext.Provider> + </TranslationProvider> + </ThemeProvider>, + ); +}; + +describe('AddMemberSearchResultItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders a selectable row with unselected accessibility state by default', () => { + const user = generateUser({ id: 'u-1', name: 'Alice' }); + renderRow({ 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 the user is selected in the store', () => { + const user = generateUser({ id: 'u-1', name: 'Alice' }); + renderRow({ 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({ onPress, user }); + + fireEvent.press(screen.getByTestId('channel-add-members-row-u-1')); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress).toHaveBeenCalledWith(user); + }); + + it('falls back to the user id when no name is set', () => { + const user = generateUser({ id: 'u-no-name', name: undefined }); + renderRow({ user }); + + expect(screen.getByLabelText('a11y/Select u-no-name')).toBeTruthy(); + expect(screen.getByText('u-no-name')).toBeTruthy(); + }); + + describe('when the user is already a member', () => { + it('renders the disabled variant with a member label and no button role', () => { + const user = generateUser({ id: 'u-2', name: 'Bob' }); + renderRow({ isAlreadyMember: true, 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/ChannelDetails/__tests__/members/ChannelAddMembers.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembers.test.tsx new file mode 100644 index 0000000000..2111d7ad08 --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembers.test.tsx @@ -0,0 +1,227 @@ +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { StateStore } from 'stream-chat'; +import type { SearchSourceState, UserResponse } from 'stream-chat'; + +import { ChannelAddMembersContext } from '../../../../contexts/channelAddMembersContext/ChannelAddMembersContext'; +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 { SelectionStore } from '../../../../state-store/selection-store'; +import type { AddMemberSearchResultItemProps } from '../../components/members/AddMemberSearchResultItem'; +import { ChannelAddMembers } from '../../components/members/ChannelAddMembers'; + +const mockRowProbe: AddMemberSearchResultItemProps[] = []; + +const mockAddNotification = jest.fn(); + +jest.mock('../../../Notifications/hooks/useNotificationApi', () => ({ + useNotificationApi: () => ({ addNotification: mockAddNotification }), +})); + +jest.mock('../../../UIComponents/SearchInput', () => { + const ReactLib = require('react'); + const { Text } = require('react-native'); + return { + SearchInput: ({ onChangeText }: { onChangeText: (t: string) => void }) => + ReactLib.createElement( + ReactLib.Fragment, + null, + ReactLib.createElement( + Text, + { onPress: () => onChangeText('query'), testID: 'search-change' }, + 'change', + ), + ReactLib.createElement( + Text, + { onPress: () => onChangeText(''), 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, + ); + }, + }; +}); + +type FakeSearchSource = { + resetState: jest.Mock; + search: jest.Mock; + state: StateStore< + Pick<SearchSourceState<UserResponse>, 'hasNext' | 'isLoading' | 'items' | 'lastQueryError'> + >; +}; + +const makeSearchSource = ( + overrides: Partial<{ + hasNext: boolean; + isLoading: boolean; + items: UserResponse[]; + lastQueryError: Error; + }> = {}, +): FakeSearchSource => ({ + resetState: jest.fn(), + search: jest.fn(), + state: new StateStore({ + hasNext: overrides.hasNext ?? false, + isLoading: overrides.isLoading ?? false, + items: overrides.items, + ...(overrides.lastQueryError ? { lastQueryError: overrides.lastQueryError } : {}), + }), +}); + +const tree = ( + searchSource: FakeSearchSource, + selectionStore: SelectionStore, + props: { additionalFlatListProps?: object } = {}, +) => ( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChannelAddMembersContext.Provider value={{ searchSource, selectionStore } as never}> + <ChannelAddMembers additionalFlatListProps={props.additionalFlatListProps as never} /> + </ChannelAddMembersContext.Provider> + </TranslationProvider> + </ThemeProvider> +); + +describe('ChannelAddMembers', () => { + beforeEach(() => { + mockRowProbe.length = 0; + }); + + afterEach(() => jest.clearAllMocks()); + + it('calls search with an empty string when the component is created', () => { + const searchSource = makeSearchSource(); + render(tree(searchSource, new SelectionStore())); + + expect(searchSource.search).toHaveBeenCalledTimes(1); + expect(searchSource.search).toHaveBeenCalledWith(''); + }); + + it('wires the search input to the search source callbacks', () => { + const searchSource = makeSearchSource(); + render(tree(searchSource, new SelectionStore())); + + fireEvent.press(screen.getByTestId('search-change')); + expect(searchSource.search).toHaveBeenCalledWith('query'); + + fireEvent.press(screen.getByTestId('search-clear')); + expect(searchSource.search).toHaveBeenCalledWith(''); + }); + + it('renders a row per result and selects the user on press', () => { + const userA = generateUser({ id: 'u-1' }); + const userB = generateUser({ id: 'u-2' }); + const selectionStore = new SelectionStore(); + + render(tree(makeSearchSource({ items: [userA, userB] }), selectionStore)); + + expect(mockRowProbe).toHaveLength(2); + expect(mockRowProbe.map((p) => p.user.id)).toEqual(['u-1', 'u-2']); + + fireEvent.press(screen.getByTestId('add-member-row-u-1')); + expect(selectionStore.state.getLatestValue().selectedIds.has('u-1')).toBe(true); + }); + + it('shows the loading skeleton while loading and the empty state when no results', () => { + const { rerender } = render(tree(makeSearchSource({ isLoading: true }), new SelectionStore())); + expect(screen.getByTestId('user-list-loading-skeleton')).toBeTruthy(); + + rerender(tree(makeSearchSource({ isLoading: false, items: [] }), new SelectionStore())); + + 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', () => { + render( + tree( + makeSearchSource({ isLoading: true, items: [generateUser({ id: 'alice' })] }), + new SelectionStore(), + ), + ); + expect(screen.UNSAFE_getByType(require('react-native').ActivityIndicator)).toBeTruthy(); + }); + + it('loads more via the search source when the list end is reached and there is a next page', () => { + const searchSource = makeSearchSource({ + hasNext: true, + items: [generateUser({ id: 'alice' })], + }); + render(tree(searchSource, new SelectionStore())); + searchSource.search.mockClear(); + + const list = screen.getByTestId('channel-add-members-list'); + expect(list.props.onEndReachedThreshold).toBe(0.2); + list.props.onEndReached(); + expect(searchSource.search).toHaveBeenCalledTimes(1); + }); + + it('does not load more when there is no next page', () => { + const searchSource = makeSearchSource({ + hasNext: false, + items: [generateUser({ id: 'alice' })], + }); + render(tree(searchSource, new SelectionStore())); + searchSource.search.mockClear(); + + screen.getByTestId('channel-add-members-list').props.onEndReached(); + expect(searchSource.search).not.toHaveBeenCalled(); + }); + + it('dispatches an error notification when the user search fails', () => { + const lastQueryError = new Error('boom'); + render(tree(makeSearchSource({ lastQueryError }), new SelectionStore())); + + expect(mockAddNotification).toHaveBeenCalledTimes(1); + expect(mockAddNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to load users', + options: expect.objectContaining({ + severity: 'error', + type: 'api:channel:query-users:failed', + }), + origin: expect.objectContaining({ emitter: 'AddChannelMembers' }), + }), + ); + }); + + it('does not dispatch an error notification when the search succeeds', () => { + render(tree(makeSearchSource({ items: [generateUser({ id: 'u-1' })] }), new SelectionStore())); + + expect(mockAddNotification).not.toHaveBeenCalled(); + }); + + it('forwards additionalFlatListProps to the underlying list', () => { + render( + tree(makeSearchSource(), new SelectionStore(), { + 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(); + }); +}); diff --git a/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersModal.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersModal.test.tsx new file mode 100644 index 0000000000..27b0c338fd --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersModal.test.tsx @@ -0,0 +1,216 @@ +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 } from 'stream-chat'; + +import { AccessibilityProvider } from '../../../../contexts/accessibilityContext/AccessibilityContext'; +import { useChannelAddMembersContext } from '../../../../contexts/channelAddMembersContext/ChannelAddMembersContext'; +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 { ChannelAddMembersModal } from '../../components/members/ChannelAddMembersModal'; + +jest.mock('../../../../hooks/actions/useChannelActions'); +const mockedUseChannelActions = jest.mocked(useChannelActions); + +// Stands in for the real ChannelAddMembers list: drives the shared selection store +// directly instead of running the search source / list. +const AddMembersProbe = () => { + const { selectionStore } = useChannelAddMembersContext(); + return ( + <> + <Text testID='add-members-probe'>add-members</Text> + <Pressable onPress={() => selectionStore.select('picked-1')} testID='probe-select-one'> + <Text>select one</Text> + </Pressable> + <Pressable onPress={() => selectionStore.deselect('picked-1')} testID='probe-clear-selection'> + <Text>clear</Text> + </Pressable> + </> + ); +}; + +const buildChannel = ( + members: ChannelMemberResponse[], + memberCount?: number, + overrides?: Partial<Channel>, +): 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<OwnCapabilitiesContextValue>, +): Channel => { + if (!overrides) return channel; + const ownCapabilities = Object.entries(overrides) + .filter(([, enabled]) => enabled) + .map(([key]) => allOwnCapabilities[key as OwnCapability]); + (channel as { data?: Record<string, unknown> }).data = { + ...((channel as { data?: Record<string, unknown> }).data ?? {}), + own_capabilities: ownCapabilities, + }; + return channel; +}; + +const renderModal = ({ + capabilities, + channel, + onClose = jest.fn(), + visible = true, +}: { + channel: Channel; + capabilities?: Partial<OwnCapabilitiesContextValue>; + onClose?: () => void; + visible?: boolean; +}) => + render( + <ThemeProvider theme={defaultTheme}> + <AccessibilityProvider value={{ enabled: true }}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChatContext.Provider + value={{ client: { notifications: new NotificationManager(), userID: 'me' } } as never} + > + <ChannelDetailsContextProvider + value={{ channel: applyCapabilities(channel, capabilities) }} + > + <WithComponents overrides={{ ChannelAddMembers: AddMembersProbe }}> + <ChannelAddMembersModal onClose={onClose} visible={visible} /> + </WithComponents> + </ChannelDetailsContextProvider> + </ChatContext.Provider> + </TranslationProvider> + </AccessibilityProvider> + </ThemeProvider>, + ); + +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<typeof useChannelActions>); + }); + + afterEach(() => { + jest.restoreAllMocks(); + mockedUseChannelActions.mockReset(); + }); + + it('enables the confirm button only while 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('calls onClose when closed via the X', () => { + const channel = buildChannel(makeMembers(3), 3); + const onClose = jest.fn(); + + renderModal({ channel, onClose }); + + fireEvent.press(screen.getByLabelText('a11y/Close')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + 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/ChannelDetails/__tests__/members/ChannelAllMembersModal.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelAllMembersModal.test.tsx new file mode 100644 index 0000000000..f29accd61f --- /dev/null +++ b/package/src/components/ChannelDetails/__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 = () => <Text testID='member-list-probe'>full-member-list</Text>; + +const buildChannel = ( + members: ChannelMemberResponse[], + memberCount?: number, + overrides?: Partial<Channel>, +): 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<OwnCapabilitiesContextValue>, +): Channel => { + if (!overrides) return channel; + const ownCapabilities = Object.entries(overrides) + .filter(([, enabled]) => enabled) + .map(([key]) => allOwnCapabilities[key as OwnCapability]); + (channel as { data?: Record<string, unknown> }).data = { + ...((channel as { data?: Record<string, unknown> }).data ?? {}), + own_capabilities: ownCapabilities, + }; + return channel; +}; + +const renderModal = ({ + capabilities, + channel, + onAddMembersPress = jest.fn(), + onClose = jest.fn(), + visible = true, +}: { + channel: Channel; + capabilities?: Partial<OwnCapabilitiesContextValue>; + onAddMembersPress?: () => void; + onClose?: () => void; + visible?: boolean; +}) => + render( + <ThemeProvider theme={defaultTheme}> + <AccessibilityProvider value={{ enabled: true }}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChatContext.Provider + value={{ client: { notifications: new NotificationManager(), userID: 'me' } } as never} + > + <ChannelDetailsContextProvider + value={{ channel: applyCapabilities(channel, capabilities) }} + > + <WithComponents overrides={{ ChannelMemberList: MemberListProbe }}> + <ChannelAllMembersModal + onAddMembersPress={onAddMembersPress} + onClose={onClose} + visible={visible} + /> + </WithComponents> + </ChannelDetailsContextProvider> + </ChatContext.Provider> + </TranslationProvider> + </AccessibilityProvider> + </ThemeProvider>, + ); + +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/ChannelDetails/__tests__/members/ChannelMemberActionsSheet.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberActionsSheet.test.tsx new file mode 100644 index 0000000000..53b567a4d7 --- /dev/null +++ b/package/src/components/ChannelDetails/__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> = {}): 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 ( + <Text onPress={props.onPress} testID={props.testID}> + {props.label} + </Text> + ); +}; + +const renderSheet = ({ + onClose = jest.fn(), + visible = true, +}: { onClose?: () => void; visible?: boolean } = {}) => + render( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChatContext.Provider + value={ + { + client: { + mutedUsers: [], + on: () => ({ unsubscribe: () => undefined }), + userID: 'me', + }, + } as never + } + > + <ChannelDetailsContextProvider value={{ channel }}> + <WithComponents overrides={{ ChannelDetailsActionItem: ActionItemProbe }}> + <ChannelMemberActionsSheet member={member} onClose={onClose} visible={visible} /> + </WithComponents> + </ChannelDetailsContextProvider> + </ChatContext.Provider> + </TranslationProvider> + </ThemeProvider>, + ); + +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( + <ChatContext.Provider + value={ + { + client: { userID: 'me', mutedUsers: [], on: () => ({ unsubscribe: () => undefined }) }, + } as never + } + > + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChannelDetailsContextProvider value={{ channel, getChannelMemberActionItems }}> + <WithComponents overrides={{ ChannelDetailsActionItem: ActionItemProbe }}> + <ChannelMemberActionsSheet member={member} onClose={jest.fn()} visible /> + </WithComponents> + </ChannelDetailsContextProvider> + </TranslationProvider> + </ThemeProvider> + , + </ChatContext.Provider>, + ); + + 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/ChannelDetails/__tests__/members/ChannelMemberItem.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberItem.test.tsx new file mode 100644 index 0000000000..73e5c0161b --- /dev/null +++ b/package/src/components/ChannelDetails/__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 '../../ChannelDetails'; +import { ChannelMemberItem } from '../../components/members/ChannelMemberItem'; + +Dayjs.extend(relativeTime); + +const memberFor = (overrides: Partial<NonNullable<ChannelMemberResponse['user']>> = {}) => + 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<typeof ChannelMemberItem> & { + channel?: Channel; + currentUserId?: string; + getMemberRoleLabel?: GetMemberRoleLabel; + mutedUsers?: Array<{ target: { id: string }; user: { id: string } }>; +}) => + render( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string, options?: Record<string, unknown>) => { + 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', + }} + > + <ChatContext.Provider + value={ + { + client: { + mutedUsers, + on: () => ({ unsubscribe: () => undefined }), + userID: currentUserId, + }, + } as never + } + > + <ChannelDetailsContextProvider value={{ channel, getMemberRoleLabel }}> + <ChannelMemberItem {...props} /> + </ChannelDetailsContextProvider> + </ChatContext.Provider> + </TranslationProvider> + </ThemeProvider>, + ); + +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/ChannelDetails/__tests__/members/ChannelMemberList.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberList.test.tsx new file mode 100644 index 0000000000..41a073c100 --- /dev/null +++ b/package/src/components/ChannelDetails/__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<ChannelMemberResponse>; + +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<typeof useChannelAllMembers>; + +const baseHookResult = (): HookResult => ({ + hasMore: false, + loading: false, + loadMore: jest.fn(), + results: [], +}); + +const mockHook = (overrides: Partial<HookResult> = {}) => { + const value = { ...baseHookResult(), ...overrides }; + (useChannelAllMembers as jest.Mock).mockReturnValue(value); + return value; +}; + +const itemProbeCalls: ChannelMemberItemProps[] = []; +const MemberListItemProbe = (props: ChannelMemberItemProps) => { + itemProbeCalls.push(props); + return <Text testID={`member-${props.member.user?.id}`}>{props.member.user?.name}</Text>; +}; + +const sheetProbeCalls: ChannelMemberActionsSheetProps[] = []; +const MemberActionsSheetProbe = (props: ChannelMemberActionsSheetProps) => { + sheetProbeCalls.push(props); + return <Text testID='member-actions-sheet-probe'>{props.member.user?.id ?? ''}</Text>; +}; + +const renderList = ({ + additionalFlatListProps, + currentUserId, + onMemberPress, +}: { + additionalFlatListProps?: Partial<FlatListProps>; + currentUserId?: string; + onMemberPress?: (member: ChannelMemberResponse) => void; +} = {}) => + render( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChatContext.Provider + value={ + { + client: { + mutedUsers: [], + on: () => ({ unsubscribe: () => undefined }), + userID: currentUserId, + }, + } as never + } + > + <ChannelDetailsContextProvider value={{ channel, onMemberPress }}> + <WithComponents + overrides={{ + ChannelMemberActionsSheet: MemberActionsSheetProbe, + ChannelMemberItem: MemberListItemProbe, + }} + > + <ChannelMemberList additionalFlatListProps={additionalFlatListProps} /> + </WithComponents> + </ChannelDetailsContextProvider> + </ChatContext.Provider> + </TranslationProvider> + </ThemeProvider>, + ); + +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/ChannelDetails/__tests__/members/useChannelAllMembers.test.tsx b/package/src/components/ChannelDetails/__tests__/members/useChannelAllMembers.test.tsx new file mode 100644 index 0000000000..81921cc1d3 --- /dev/null +++ b/package/src/components/ChannelDetails/__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 }) => ( + <TranslationProvider + value={{ t, tDateTimeParser: ((input: unknown) => input) as never, userLanguage: 'en' }} + > + {children} + </TranslationProvider> +); + +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/ChannelDetails/__tests__/members/useMemberRoleLabel.test.tsx b/package/src/components/ChannelDetails/__tests__/members/useMemberRoleLabel.test.tsx new file mode 100644 index 0000000000..ebc8978519 --- /dev/null +++ b/package/src/components/ChannelDetails/__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 '../../ChannelDetails'; +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<NonNullable<ChannelMemberResponse['user']>>; + 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 }) => ( + <TranslationProvider + value={{ t, tDateTimeParser: ((input: unknown) => input) as never, userLanguage: 'en' }} + > + <ChannelDetailsContextProvider value={{ channel, getMemberRoleLabel }}> + {children} + </ChannelDetailsContextProvider> + </TranslationProvider> + ), + }); + +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/ChannelDetails/__tests__/useChannelDetailsActionItems.test.tsx b/package/src/components/ChannelDetails/__tests__/useChannelDetailsActionItems.test.tsx new file mode 100644 index 0000000000..d02f34db0a --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/useChannelDetailsActionItems.test.tsx @@ -0,0 +1,177 @@ +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>): 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<channelDetailsContextModule.ChannelDetailsContextValue> = {}, +) => { + 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, + surface: 'details', + }); + }); + + 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, surface: 'details' }); + }); + + 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<void>((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/ChannelDetails/__tests__/useChannelDetailsMemberStatusText.test.tsx b/package/src/components/ChannelDetails/__tests__/useChannelDetailsMemberStatusText.test.tsx new file mode 100644 index 0000000000..af8e3314f1 --- /dev/null +++ b/package/src/components/ChannelDetails/__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<string, unknown>) => { + 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 }) => ( + <TranslationProvider + value={{ t, tDateTimeParser: ((input: unknown) => input) as never, userLanguage: 'en' }} + > + {children} + </TranslationProvider> + ); + 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/ChannelDetails/__tests__/useChannelDetailsMembersPreview.test.tsx b/package/src/components/ChannelDetails/__tests__/useChannelDetailsMembersPreview.test.tsx new file mode 100644 index 0000000000..8d89e5435c --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/useChannelDetailsMembersPreview.test.tsx @@ -0,0 +1,76 @@ +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('preserves the order of members from channel state', () => { + 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(['c', 'a', 'b']); + }); + + 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/ChannelDetails/__tests__/useEditChannelImage.test.tsx b/package/src/components/ChannelDetails/__tests__/useEditChannelImage.test.tsx new file mode 100644 index 0000000000..d58c0e43ad --- /dev/null +++ b/package/src/components/ChannelDetails/__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) => ( + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChannelDetailsContextProvider value={{ channel: buildChannel(), compressImageQuality }}> + {children} + </ChannelDetailsContextProvider> + </TranslationProvider> + ); + 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/ChannelDetails/__tests__/useFileAttachmentListSections.test.tsx b/package/src/components/ChannelDetails/__tests__/useFileAttachmentListSections.test.tsx new file mode 100644 index 0000000000..e793c2c7de --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/useFileAttachmentListSections.test.tsx @@ -0,0 +1,159 @@ +import React, { type PropsWithChildren } from 'react'; + +import { renderHook } from '@testing-library/react-native'; + +import type { Attachment, MessageResponse } from 'stream-chat'; + +import { + TranslationProvider, + type TranslationContextValue, +} from '../../../contexts/translationContext/TranslationContext'; +import { + generateAudioAttachment, + generateFileAttachment, + generateImageAttachment, +} from '../../../mock-builders/generator/attachment'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { Streami18n } from '../../../utils/i18n/Streami18n'; +import { useFileAttachmentListSections } from '../hooks/useFileAttachmentListSections'; + +let translators: TranslationContextValue; + +beforeAll(async () => { + const i18nInstance = new Streami18n(); + translators = (await i18nInstance.getTranslators()) as unknown as TranslationContextValue; +}); + +const wrapper = ({ children }: PropsWithChildren) => ( + <TranslationProvider value={translators}>{children}</TranslationProvider> +); + +const messageAt = ( + id: string, + createdAt: string, + attachments: Attachment[] = [generateFileAttachment()], +): MessageResponse => + generateMessage({ + attachments: attachments as never, + created_at: new Date(createdAt), + id, + }) as unknown as MessageResponse; + +describe('useFileAttachmentListSections', () => { + it('returns an empty array when there are no messages', () => { + const { result } = renderHook(() => useFileAttachmentListSections(undefined), { wrapper }); + expect(result.current).toEqual([]); + + const { result: emptyResult } = renderHook(() => useFileAttachmentListSections([]), { + wrapper, + }); + expect(emptyResult.current).toEqual([]); + }); + + it('gathers only file and audio attachments, skipping images', () => { + const message = messageAt('m-1', '2026-03-15T00:00:00.000Z', [ + generateFileAttachment({ title: 'a-file.pdf' }), + generateImageAttachment({ title: 'a-photo.png' }), + generateAudioAttachment({ title: 'a-clip.mp3' }), + ]); + + const { result } = renderHook(() => useFileAttachmentListSections([message]), { wrapper }); + + expect(result.current).toHaveLength(1); + expect(result.current[0].data.map((tile) => tile.attachment.title)).toEqual([ + 'a-file.pdf', + 'a-clip.mp3', + ]); + expect(result.current[0].data.every((tile) => tile.message === message)).toBe(true); + }); + + it('skips OG/scraped link-preview attachments', () => { + const message = messageAt('m-og', '2026-03-15T00:00:00.000Z', [ + generateFileAttachment({ title: 'a-file.pdf' }), + generateFileAttachment({ + og_scrape_url: 'https://example.com', + title: 'link-preview', + title_link: 'https://example.com', + }), + ]); + + const { result } = renderHook(() => useFileAttachmentListSections([message]), { wrapper }); + + expect(result.current[0].data.map((tile) => tile.attachment.title)).toEqual(['a-file.pdf']); + }); + + it('produces no section for a message without file or audio attachments', () => { + const message = messageAt('m-1', '2026-03-15T00:00:00.000Z', [generateImageAttachment()]); + + const { result } = renderHook(() => useFileAttachmentListSections([message]), { wrapper }); + + expect(result.current).toEqual([]); + }); + + it('groups messages into month sections in newest-first order', () => { + const february = messageAt('m-feb', '2026-02-10T00:00:00.000Z'); + const march = messageAt('m-mar', '2026-03-15T00:00:00.000Z'); + + // The search source returns messages newest-first; the hook only groups consecutive months. + const { result } = renderHook(() => useFileAttachmentListSections([march, february]), { + wrapper, + }); + + expect(result.current.map((section) => section.title)).toEqual(['March 2026', 'February 2026']); + expect(result.current[0].data.map((tile) => tile.message.id)).toEqual(['m-mar']); + expect(result.current[1].data.map((tile) => tile.message.id)).toEqual(['m-feb']); + }); + + it('keeps attachments from the same month under a single section', () => { + const early = messageAt('m-1', '2026-03-02T00:00:00.000Z'); + const late = messageAt('m-2', '2026-03-28T00:00:00.000Z'); + + // Provided newest-first, as the search source returns them. + const { result } = renderHook(() => useFileAttachmentListSections([late, early]), { wrapper }); + + expect(result.current).toHaveLength(1); + expect(result.current[0].title).toBe('March 2026'); + expect(result.current[0].data.map((tile) => tile.message.id)).toEqual(['m-2', 'm-1']); + }); + + it('emits one tile per file attachment within a message', () => { + const message = messageAt('m-1', '2026-03-15T00:00:00.000Z', [ + generateFileAttachment({ title: 'one.pdf' }), + generateFileAttachment({ title: 'two.pdf' }), + ]); + + const { result } = renderHook(() => useFileAttachmentListSections([message]), { wrapper }); + + expect(result.current[0].data.map((tile) => tile.attachment.title)).toEqual([ + 'one.pdf', + 'two.pdf', + ]); + }); + + it('formats the section title via the timestamp/FileAttachmentListSection translation key', () => { + const customTranslators = { + t: jest.fn((key: string) => + key === 'timestamp/FileAttachmentListSection' ? 'CUSTOM TITLE' : key, + ), + tDateTimeParser: translators.tDateTimeParser, + userLanguage: 'en', + } as unknown as TranslationContextValue; + + const customWrapper = ({ children }: PropsWithChildren) => ( + <TranslationProvider value={customTranslators}>{children}</TranslationProvider> + ); + + const { result } = renderHook( + () => useFileAttachmentListSections([messageAt('m-1', '2026-03-15T00:00:00.000Z')]), + { wrapper: customWrapper }, + ); + + expect(result.current[0].title).toBe('CUSTOM TITLE'); + // The MMMM YYYY format lives in the translation string itself, so the hook only forwards + // the timestamp to `t` — it does not pass a `format` option. + expect(customTranslators.t).toHaveBeenCalledWith( + 'timestamp/FileAttachmentListSection', + expect.objectContaining({ timestamp: expect.any(Date) }), + ); + }); +}); diff --git a/package/src/components/ChannelDetails/__tests__/useMediaList.test.tsx b/package/src/components/ChannelDetails/__tests__/useMediaList.test.tsx new file mode 100644 index 0000000000..16de9861f1 --- /dev/null +++ b/package/src/components/ChannelDetails/__tests__/useMediaList.test.tsx @@ -0,0 +1,53 @@ +import { renderHook } from '@testing-library/react-native'; + +import type { MessageResponse } from 'stream-chat'; + +import { + generateImageAttachment, + generateVideoAttachment, +} from '../../../mock-builders/generator/attachment'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { useMediaList } from '../hooks/useMediaList'; + +const messageWithAttachments = (id: string, attachments: unknown[]): MessageResponse => + generateMessage({ attachments: attachments as never, id }) as unknown as MessageResponse; + +describe('useMediaList', () => { + it('returns an empty array when there are no messages', () => { + const { result } = renderHook(() => useMediaList(undefined)); + expect(result.current).toEqual([]); + + const { result: emptyResult } = renderHook(() => useMediaList([])); + expect(emptyResult.current).toEqual([]); + }); + + it('returns one tile per image/video attachment and skips non-media and scraped attachments', () => { + const messageA = messageWithAttachments('m-1', [ + generateImageAttachment(), + generateVideoAttachment(), + ]); + const messageB = messageWithAttachments('m-2', [ + // excluded: image used as a link preview / og scrape + generateImageAttachment({ og_scrape_url: 'https://example.com' }), + generateImageAttachment({ title_link: 'https://example.com' }), + // excluded: not media + { type: 'file' }, + // included + generateImageAttachment(), + ]); + + const { result } = renderHook(() => useMediaList([messageA, messageB])); + + expect(result.current.map((tile) => `${tile.message.id}-${tile.attachment.type}`)).toEqual([ + 'm-1-image', + 'm-1-video', + 'm-2-image', + ]); + }); + + it('skips messages without attachments', () => { + const message = generateMessage({ id: 'm-1' }) as unknown as MessageResponse; + const { result } = renderHook(() => useMediaList([message])); + expect(result.current).toEqual([]); + }); +}); diff --git a/package/src/components/ChannelDetails/__tests__/useUserActivityStatus.test.tsx b/package/src/components/ChannelDetails/__tests__/useUserActivityStatus.test.tsx new file mode 100644 index 0000000000..83c2a4e3b7 --- /dev/null +++ b/package/src/components/ChannelDetails/__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) => ( + <TranslationProvider value={translators}>{children}</TranslationProvider> +); + +const userFor = (overrides: Partial<UserResponse> = {}): 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/ChannelDetails/components/ChannelDetailsActionItem.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsActionItem.tsx new file mode 100644 index 0000000000..19f84a55c4 --- /dev/null +++ b/package/src/components/ChannelDetails/components/ChannelDetailsActionItem.tsx @@ -0,0 +1,134 @@ +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<IconProps>; + 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: { + channelDetails: { + 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 = ( + <View style={[styles.contentContainer, containerOverride]}> + <View style={[styles.iconWrapper, iconWrapperOverride]}> + <Icon height={20} stroke={iconColor} width={20} /> + </View> + <Text + numberOfLines={1} + style={[ + styles.label, + { color: labelColor }, + labelOverride, + destructive ? destructiveLabelOverride : null, + ]} + > + {label} + </Text> + {trailing ? <View style={styles.trailing}>{trailing}</View> : null} + </View> + ); + + if (!onPress) { + return ( + <View style={styles.row} testID={testID}> + {content} + </View> + ); + } + + return ( + <Pressable + accessibilityLabel={label} + accessibilityRole='button' + onPress={onPress} + style={({ pressed }) => [ + styles.row, + pressed + ? { + backgroundColor: semantics.backgroundUtilityPressed, + borderRadius: primitives.radiusLg, + } + : null, + ]} + testID={testID} + > + {content} + </Pressable> + ); +}; + +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/ChannelDetails/components/ChannelDetailsActionsSection.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsActionsSection.tsx new file mode 100644 index 0000000000..392be45dae --- /dev/null +++ b/package/src/components/ChannelDetails/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 ( + <ChannelDetailsActionItem + destructive={item.type === 'destructive'} + Icon={item.Icon} + label={item.label} + testID={testID} + trailing={ + <Switch + {...switchColors} + onValueChange={handleValueChange} + style={rtlMirrorSwitchStyle} + testID={`${testID}-switch`} + value={isMuted} + /> + } + /> + ); +}; + +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 ( + <ChannelDetailsActionItem + destructive={item.type === 'destructive'} + Icon={item.Icon} + label={item.label} + testID={testID} + trailing={ + <Switch + {...switchColors} + onValueChange={handleValueChange} + style={rtlMirrorSwitchStyle} + testID={`${testID}-switch`} + value={isUserMuted} + /> + } + /> + ); +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelDetailsActionsSection = () => { + const { + theme: { + channelDetails: { sectionCard: sectionCardOverride }, + semantics, + }, + } = useTheme(); + const { ChannelDetailsActionItem } = useComponentsContext(); + const styles = useStyles(); + + const items = useChannelDetailsActionItems(); + + if (items.length === 0) return null; + + return ( + <View + style={[ + styles.sectionCard, + { backgroundColor: semantics.backgroundCoreSurfaceCard }, + sectionCardOverride, + ]} + > + {items.map((item) => { + if (item.id === 'mute') { + return <ChannelMuteToggleRow item={item} key={item.id} />; + } + if (item.id === 'muteUser') { + return <UserMuteToggleRow item={item} key={item.id} />; + } + + return ( + <ChannelDetailsActionItem + destructive={item.type === 'destructive'} + Icon={item.Icon} + key={item.id} + label={item.label} + onPress={() => item.action()} + testID={`channel-details-action-${item.id}`} + /> + ); + })} + </View> + ); +}; + +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/ChannelDetails/components/ChannelDetailsEditButton.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx new file mode 100644 index 0000000000..8266ecf066 --- /dev/null +++ b/package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx @@ -0,0 +1,50 @@ +import React, { useCallback, useState } from 'react'; + +import { ChannelEditDetailsModal } from './ChannelEditDetailsModal'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useChannelOwnCapabilities } from '../../../hooks/useChannelOwnCapabilities'; +import { useIsDirectChat } from '../../../hooks/useIsDirectChat'; +import { Button } from '../../ui/Button/Button'; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelDetailsEditButton = () => { + const { channel, onEditChannelPress } = useChannelDetailsContext(); + const { t } = useTranslationContext(); + const ownCapabilities = useChannelOwnCapabilities(channel); + const canUpdateChannel = ownCapabilities?.includes('update-channel') ?? false; + const isDirect = useIsDirectChat(channel); + const [editModalVisible, setEditModalVisible] = useState(false); + + const handleEditPress = useCallback(() => { + if (onEditChannelPress) { + onEditChannelPress(); + return; + } + setEditModalVisible(true); + }, [onEditChannelPress]); + + const handleEditModalClose = useCallback(() => setEditModalVisible(false), []); + + if (!canUpdateChannel || isDirect) { + return null; + } + + return ( + <> + <Button + accessibilityLabelKey='a11y/Edit channel' + label={t('Edit')} + onPress={handleEditPress} + size='sm' + testID='channel-details-edit-button' + type='outline' + variant='secondary' + /> + <ChannelEditDetailsModal onClose={handleEditModalClose} visible={editModalVisible} /> + </> + ); +}; diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx new file mode 100644 index 0000000000..24dc7a9cfa --- /dev/null +++ b/package/src/components/ChannelDetails/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: { + channelDetails: { + 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<ChannelMemberResponse | null>(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 ( + <View + style={[ + styles.sectionCard, + { backgroundColor: semantics.backgroundCoreSurfaceCard }, + sectionCardOverride, + ]} + > + <View style={[styles.header, headerOverride]}> + <Text + accessibilityRole='header' + style={[styles.headerTitle, { color: semantics.textPrimary }, headerTitleOverride]} + > + {t('{{count}} members', { count: total })} + </Text> + {updateChannelMembers ? ( + <View style={styles.headerAddButton}> + <Button + accessibilityLabelKey='a11y/Add members' + label={t('Add')} + onPress={handleAddMembersPress} + size='sm' + style={styles.headerAddButtonInner} + testID='channel-details-member-section-add-button' + type='outline' + variant='secondary' + /> + </View> + ) : null} + </View> + <View style={styles.list}> + {visible.map((member) => { + if (!member.user?.id) return null; + return ( + <ChannelMemberItem key={member.user.id} member={member} onPress={handleMemberPress} /> + ); + })} + </View> + {hasMore ? ( + <Pressable + accessibilityLabel={t('View all')} + accessibilityRole='button' + onPress={handleViewAllPress} + style={[styles.footer, { borderTopColor: semantics.borderCoreDefault }, footerOverride]} + > + <View style={styles.viewAllButton}> + <Text + style={[styles.viewAllLabel, { color: semantics.textPrimary }, viewAllLabelOverride]} + > + {t('View all')} + </Text> + </View> + </Pressable> + ) : null} + <ChannelAllMembersModal + onAddMembersPress={handleAddMembersPress} + onClose={handleMemberListClose} + visible={isMemberListVisible} + /> + <ChannelAddMembersModal onClose={handleAddMembersClose} visible={isAddMembersVisible} /> + {selectedMember ? ( + <ChannelMemberActionsSheet + member={selectedMember} + onClose={handleMemberActionsClose} + visible + /> + ) : null} + </View> + ); +}; + +const useStyles = () => + useMemo( + () => + StyleSheet.create({ + footer: { + alignItems: 'center', + borderTopWidth: 1, + paddingHorizontal: primitives.spacingMd, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: primitives.spacingSm, + justifyContent: 'space-between', + paddingHorizontal: primitives.spacingMd, + paddingTop: primitives.spacingXs, + }, + headerAddButton: { + flexShrink: 0, + }, + headerAddButtonInner: { + width: 'auto', + }, + headerTitle: { + flexShrink: 1, + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + list: { + paddingBottom: primitives.spacingSm, + }, + sectionCard: { + borderRadius: primitives.radiusLg, + overflow: 'hidden', + }, + viewAllButton: { + alignItems: 'center', + justifyContent: 'center', + minHeight: 48, + width: '100%', + }, + viewAllLabel: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + }, + }), + [], + ); diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsNavHeader.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsNavHeader.tsx new file mode 100644 index 0000000000..77adcbd899 --- /dev/null +++ b/package/src/components/ChannelDetails/components/ChannelDetailsNavHeader.tsx @@ -0,0 +1,114 @@ +import React, { useMemo } from 'react'; +import { I18nManager, StyleSheet, Text, View } from 'react-native'; + +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useIsDirectChat } from '../../../hooks/useIsDirectChat'; +import { ChevronLeft } from '../../../icons/chevron-left'; +import { primitives } from '../../../theme'; +import { Button } from '../../ui/Button/Button'; + +export type ChannelDetailsNavHeaderProps = { + /** Content rendered on the trailing side of the header (e.g. the edit button). */ + action?: React.ReactNode; + /** Override the auto-resolved screen title (1:1 → "Contact Info", group → "Group Info"). */ + title?: string; +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelDetailsNavHeader = ({ action, title }: ChannelDetailsNavHeaderProps) => { + const { channel, onBack } = useChannelDetailsContext(); + const { t } = useTranslationContext(); + const { + theme: { + channelDetails: { + header: { container: containerOverride, title: titleOverride }, + }, + semantics, + }, + } = useTheme(); + const isDirect = useIsDirectChat(channel); + const styles = useStyles(); + + const resolvedTitle = title ?? (isDirect ? t('Contact Info') : t('Group Info')); + + return ( + <View + style={[ + styles.container, + { backgroundColor: semantics.backgroundCoreElevation1 }, + { borderBottomColor: semantics.borderCoreSubtle }, + containerOverride, + ]} + > + <View style={styles.sideContainer}> + {onBack ? ( + <Button + accessibilityLabelKey='a11y/Back' + iconOnly + LeadingIcon={ChevronLeft} + onPress={onBack} + size='md' + testID='channel-details-back-button' + type='ghost' + variant='secondary' + /> + ) : null} + </View> + <View style={styles.centerContainer}> + <Text + accessibilityRole='header' + numberOfLines={1} + style={[styles.title, { color: semantics.textPrimary }, titleOverride]} + > + {resolvedTitle} + </Text> + </View> + <View style={[styles.sideContainer, styles.sideContainerRight]}>{action ?? null}</View> + </View> + ); +}; + +const useStyles = () => { + const insets = useSafeAreaInsets(); + + return useMemo( + () => + StyleSheet.create({ + centerContainer: { + alignItems: 'center', + flex: 2, + justifyContent: 'center', + }, + container: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + paddingTop: insets.top + primitives.spacingSm, + paddingBottom: primitives.spacingSm, + paddingHorizontal: primitives.spacingSm, + gap: primitives.spacingXs, + borderBottomWidth: 1, + }, + sideContainer: { + flex: 1, + justifyContent: 'center', + }, + sideContainerRight: { + alignItems: 'flex-end', + }, + title: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + }), + [insets.top], + ); +}; diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsNavigationSection.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsNavigationSection.tsx new file mode 100644 index 0000000000..b00390d0dd --- /dev/null +++ b/package/src/components/ChannelDetails/components/ChannelDetailsNavigationSection.tsx @@ -0,0 +1,126 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { ChannelDetailsActionItem } from './ChannelDetailsActionItem'; +import { ChannelDetailsModal } from './modal/Modal'; +import { ModalHeader } from './modal/ModalHeader'; + +import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; +import { useOverlayContext } from '../../../contexts/overlayContext/OverlayContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { ChevronRight } from '../../../icons'; +import { ChevronLeft } from '../../../icons/chevron-left'; +import { primitives } from '../../../theme'; +import { ImageGallery } from '../../ImageGallery/ImageGallery'; +import { + type ChannelDetailsNavigationSectionType, + useChannelDetailsNavigationItems, +} from '../hooks/useChannelDetailsNavigationItems'; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelDetailsNavigationSection = () => { + const { + theme: { + channelDetails: { sectionCard: sectionCardOverride }, + semantics, + }, + } = useTheme(); + const styles = useStyles(); + const { FileAttachmentList, MediaList, PinnedMessageList } = useComponentsContext(); + const items = useChannelDetailsNavigationItems(); + const [activeSection, setActiveSection] = useState<ChannelDetailsNavigationSectionType | null>( + null, + ); + const { overlayOpacity, overlay } = useOverlayContext(); + const closeModal = useCallback(() => setActiveSection(null), []); + + const modalContent = useMemo(() => { + switch (activeSection) { + case 'pinned-messages': + return <PinnedMessageList />; + case 'photos-and-videos': + return ( + <> + <MediaList /> + {overlay === 'gallery' ? <ImageGallery overlayOpacity={overlayOpacity} /> : null} + </> + ); + case 'files': + return <FileAttachmentList />; + default: + return null; + } + }, [activeSection, FileAttachmentList, MediaList, PinnedMessageList, overlayOpacity, overlay]); + + const activeItem = useMemo( + () => items.find((item) => item.section === activeSection), + [activeSection, items], + ); + + const chevron = useMemo( + () => ( + <View accessibilityElementsHidden importantForAccessibility='no-hide-descendants'> + <ChevronRight height={20} stroke={semantics.textTertiary} width={20} /> + </View> + ), + [semantics.textTertiary], + ); + + const closeButtonProps = useMemo( + () => ({ type: 'ghost' as const, LeadingIcon: ChevronLeft }), + [], + ); + + return ( + <> + <View + style={[ + styles.sectionCard, + { backgroundColor: semantics.backgroundCoreSurfaceCard }, + sectionCardOverride, + ]} + > + {items.map((item) => ( + <ChannelDetailsActionItem + Icon={item.Icon} + key={item.section} + label={item.label} + onPress={item.onPress ?? (() => setActiveSection(item.section))} + testID={`channel-details-${item.section}`} + trailing={chevron} + /> + ))} + </View> + <ChannelDetailsModal + onClose={closeModal} + visible={activeSection !== null} + presentationStyle='fullScreen' + > + {activeItem ? ( + <ModalHeader + onClose={closeModal} + title={activeItem.label} + additionalCloseButtonProps={closeButtonProps} + /> + ) : null} + {modalContent} + </ChannelDetailsModal> + </> + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + sectionCard: { + borderRadius: primitives.radiusLg, + overflow: 'hidden', + paddingVertical: primitives.spacingXs, + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsProfile.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsProfile.tsx new file mode 100644 index 0000000000..a8abbe1537 --- /dev/null +++ b/package/src/components/ChannelDetails/components/ChannelDetailsProfile.tsx @@ -0,0 +1,115 @@ +import React, { useMemo } from 'react'; +import { I18nManager, StyleSheet, Text, View } from 'react-native'; + +import { composeAccessibilityLabel } from '../../../a11y/a11yUtils'; +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useChannelMuteActive } from '../../../hooks/useChannelMuteActive'; +import { Mute } from '../../../icons/mute'; +import { primitives } from '../../../theme'; +import { useChannelPreviewDisplayName } from '../../ChannelPreview/hooks/useChannelPreviewDisplayName'; +import { ChannelAvatar } from '../../ui/Avatar/ChannelAvatar'; +import { useChannelDetailsMemberStatusText } from '../hooks/useChannelDetailsMemberStatusText'; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelDetailsProfile = () => { + const { channel } = useChannelDetailsContext(); + const { t } = useTranslationContext(); + const { + theme: { + channelDetails: { + profile: { + container: containerOverride, + heading: headingOverride, + subtitle: subtitleOverride, + title: titleOverride, + }, + }, + semantics, + }, + } = useTheme(); + const displayName = useChannelPreviewDisplayName(channel); + const subtitle = useChannelDetailsMemberStatusText(channel); + const muted = useChannelMuteActive(channel); + const styles = useStyles(); + + return ( + <View style={[styles.container, containerOverride]}> + <ChannelAvatar channel={channel} showBorder={false} size='2xl' /> + <View style={[styles.heading, headingOverride]}> + <View + accessibilityLabel={composeAccessibilityLabel( + displayName, + muted ? t('Muted') : undefined, + )} + accessibilityRole='header' + accessible + style={styles.titleRow} + > + <Text + numberOfLines={2} + style={[styles.title, { color: semantics.textPrimary }, titleOverride]} + > + {displayName ?? ''} + </Text> + {muted ? ( + <Mute + fill={semantics.textTertiary} + height={20} + testID='channel-details-profile-muted-indicator' + width={20} + /> + ) : null} + </View> + {subtitle ? ( + <Text style={[styles.subtitle, { color: semantics.textSecondary }, subtitleOverride]}> + {subtitle} + </Text> + ) : null} + </View> + </View> + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + container: { + alignItems: 'center', + gap: primitives.spacingMd, + paddingBottom: primitives.spacing2xl, + }, + heading: { + alignItems: 'center', + gap: primitives.spacingXxs, + width: '100%', + }, + subtitle: { + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + textAlign: 'center', + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + title: { + flexShrink: 1, + fontSize: primitives.typographyFontSizeXl, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightRelaxed, + textAlign: 'center', + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + titleRow: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingXs, + justifyContent: 'center', + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetails/components/ChannelEditDetails.tsx b/package/src/components/ChannelDetails/components/ChannelEditDetails.tsx new file mode 100644 index 0000000000..d90cde94a7 --- /dev/null +++ b/package/src/components/ChannelDetails/components/ChannelEditDetails.tsx @@ -0,0 +1,121 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useChannelEditDetailsContext } from '../../../contexts/channelEditDetailsContext/ChannelEditDetailsContext'; +import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useStateStore } from '../../../hooks/useStateStore'; +import type { EditChannelDetailsState } from '../../../state-store/edit-channel-details-store'; +import { primitives } from '../../../theme'; +import { ChannelAvatar } from '../../ui/Avatar/ChannelAvatar'; +import { Button } from '../../ui/Button/Button'; +import { useEditChannelImage } from '../hooks/useEditChannelImage'; + +const selector = (state: EditChannelDetailsState) => ({ + pendingAction: state.pendingAction, + updatedImage: state.updatedImage, +}); + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelEditDetails = () => { + const { channel } = useChannelDetailsContext(); + const { store } = useChannelEditDetailsContext(); + const { ChannelEditImageSheet, ChannelEditName } = useComponentsContext(); + const { t } = useTranslationContext(); + const { + theme: { + channelDetails: { + editChannel: { + avatarSection: avatarSectionOverride, + container: containerOverride, + uploadButton: uploadButtonOverride, + }, + }, + }, + } = useTheme(); + const styles = useStyles(); + const { pickImageFromNativePicker, takePhoto } = useEditChannelImage(); + + const { pendingAction, updatedImage } = useStateStore(store.state, selector); + + const [sheetVisible, setSheetVisible] = useState(false); + + const openSheet = useCallback(() => setSheetVisible(true), []); + const closeSheet = useCallback(() => setSheetVisible(false), []); + + useEffect(() => { + if (sheetVisible || !pendingAction) return; + const action = pendingAction; + store.setPendingAction(null); + (async () => { + if (action === 'camera') { + const file = await takePhoto(); + if (file) { + store.setUpdatedImage(file); + } + } else if (action === 'library') { + const file = await pickImageFromNativePicker(); + if (file) { + store.setUpdatedImage(file); + } + } else if (action === 'reset') { + store.setUpdatedImage(null); + } + })(); + }, [pendingAction, pickImageFromNativePicker, sheetVisible, store, takePhoto]); + + // `undefined` = untouched (show live channel image), `File` = picked uri, `null` = reset. + const isPreview = updatedImage !== undefined; + const previewUri = updatedImage ? updatedImage.uri : null; + + return ( + <View style={[styles.container, containerOverride]}> + <View style={[styles.avatarSection, avatarSectionOverride]}> + <ChannelAvatar + channel={channel} + isPreview={isPreview} + previewUri={previewUri} + showBorder={false} + size='2xl' + /> + <Button + accessibilityLabelKey='a11y/Upload channel image' + label={t('Upload')} + onPress={openSheet} + size='sm' + style={[styles.uploadButton, uploadButtonOverride]} + testID='channel-edit-upload-button' + type='ghost' + variant='primary' + /> + </View> + <ChannelEditName /> + <ChannelEditImageSheet onClose={closeSheet} visible={sheetVisible} /> + </View> + ); +}; + +const useStyles = () => + useMemo( + () => + StyleSheet.create({ + avatarSection: { + alignItems: 'center', + gap: primitives.spacingXs, + }, + container: { + flex: 1, + gap: primitives.spacingXl, + paddingHorizontal: primitives.spacingMd, + paddingTop: primitives.spacingMd, + }, + uploadButton: { + width: 'auto', + }, + }), + [], + ); diff --git a/package/src/components/ChannelDetails/components/ChannelEditDetailsModal.tsx b/package/src/components/ChannelDetails/components/ChannelEditDetailsModal.tsx new file mode 100644 index 0000000000..6d644e22c8 --- /dev/null +++ b/package/src/components/ChannelDetails/components/ChannelEditDetailsModal.tsx @@ -0,0 +1,152 @@ +import React, { useState } from 'react'; + +import { ActivityIndicator, Keyboard } from 'react-native'; + +import { ChannelDetailsModal } from './modal/Modal'; +import { ModalHeader } from './modal/ModalHeader'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { + ChannelEditDetailsProvider, + useChannelEditDetailsContext, +} from '../../../contexts/channelEditDetailsContext/ChannelEditDetailsContext'; +import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useChannelActions } from '../../../hooks/actions/useChannelActions'; +import { useStableCallback } from '../../../hooks/useStableCallback'; +import { Checkmark } from '../../../icons/checkmark-1'; +import { + isImageDirty, + isNameDirty, + useIsImageDirty, + useIsNameDirty, +} from '../../../state-store/edit-channel-details-store'; +import type { File } from '../../../types/types'; +import { NotificationList } from '../../Notifications/NotificationList'; +import { NotificationTargetProvider } from '../../Notifications/NotificationTargetContext'; +import { Button } from '../../ui/Button/Button'; + +const loadingIconStyle = { margin: 0 }; +const LoadingButtonIcon = ({ height, width }: { height?: number; width?: number }) => ( + <ActivityIndicator style={{ ...loadingIconStyle, ...{ height, width } }} /> +); + +export type ChannelEditDetailsModalProps = { + onClose: () => void; + visible: boolean; +}; + +type ChannelEditDetailsModalContentProps = { + onClose: () => void; +}; + +const ChannelEditDetailsModalBody = ({ onClose }: ChannelEditDetailsModalContentProps) => { + const { channel, doFileUploadRequest } = useChannelDetailsContext(); + const { store } = useChannelEditDetailsContext(); + const { updateImage, updateName } = useChannelActions(channel); + const { ChannelEditDetails } = useComponentsContext(); + const { t } = useTranslationContext(); + const [saving, setSaving] = useState(false); + const nameDirty = useIsNameDirty(store); + const imageDirty = useIsImageDirty(store); + const confirmEnabled = (nameDirty || imageDirty) && !saving; + + const handleConfirm = useStableCallback(async () => { + if (!confirmEnabled) return; + Keyboard.dismiss(); + setSaving(true); + try { + const state = store.state.getLatestValue(); + const { currentName, updatedImage } = state; + const nameDirty = isNameDirty(state); + const imageDirty = isImageDirty(state); + let nameOk = true; + let imageOk = true; + const tasks: Promise<void>[] = []; + if (nameDirty) { + nameOk = false; + tasks.push( + updateName(currentName, { + onSuccess: () => { + nameOk = true; + }, + }), + ); + } + if (imageDirty) { + imageOk = false; + tasks.push( + updateImage( + updatedImage as File | null, + { + onSuccess: () => { + imageOk = true; + }, + }, + doFileUploadRequest, + ), + ); + } + await Promise.all(tasks); + if (nameOk && imageOk) onClose(); + } finally { + setSaving(false); + } + }); + + return ( + <> + <ModalHeader + onClose={onClose} + rightAction={ + <Button + accessibilityLabel={t('a11y/Confirm edit channel')} + accessibilityRole='button' + accessibilityState={{ busy: saving, disabled: !confirmEnabled }} + disabled={!confirmEnabled} + iconOnly + LeadingIcon={saving ? LoadingButtonIcon : Checkmark} + onPress={handleConfirm} + testID='channel-details-edit-confirm-button' + type='solid' + variant='primary' + /> + } + title={t('Edit')} + /> + <ChannelEditDetails /> + <NotificationList /> + </> + ); +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelEditDetailsModalContent = ({ + onClose, +}: ChannelEditDetailsModalContentProps) => { + const { channel } = useChannelDetailsContext(); + const notificationHostId = channel?.cid ? `channel-edit-details:${channel.cid}` : undefined; + + if (!notificationHostId || !channel) { + return null; + } + + return ( + <ChannelEditDetailsProvider channel={channel}> + <NotificationTargetProvider hostId={notificationHostId} panel='channel-details'> + <ChannelEditDetailsModalBody onClose={onClose} /> + </NotificationTargetProvider> + </ChannelEditDetailsProvider> + ); +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelEditDetailsModal = ({ onClose, visible }: ChannelEditDetailsModalProps) => ( + <ChannelDetailsModal onClose={onClose} visible={visible}> + <ChannelEditDetailsModalContent onClose={onClose} /> + </ChannelDetailsModal> +); diff --git a/package/src/components/ChannelDetails/components/ChannelEditImageSheet.tsx b/package/src/components/ChannelDetails/components/ChannelEditImageSheet.tsx new file mode 100644 index 0000000000..bb8168c4ea --- /dev/null +++ b/package/src/components/ChannelDetails/components/ChannelEditImageSheet.tsx @@ -0,0 +1,224 @@ +import React, { useCallback, useMemo } from 'react'; +import { + I18nManager, + ListRenderItem, + StyleSheet, + Text, + TextStyle, + View, + ViewStyle, +} from 'react-native'; + +import { useBottomSheetContext } from '../../../contexts'; +import { useChannelEditDetailsContext } from '../../../contexts/channelEditDetailsContext/ChannelEditDetailsContext'; +import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useStateStore } from '../../../hooks/useStateStore'; +import { Camera } from '../../../icons/camera'; +import { Delete } from '../../../icons/delete'; +import { Picture } from '../../../icons/image'; +import type { IconProps } from '../../../icons/utils/base'; +import { Cross } from '../../../icons/xmark-1'; +import type { + EditChannelDetailsState, + EditChannelImagePendingAction, +} from '../../../state-store/edit-channel-details-store'; +import { primitives } from '../../../theme'; +import { Button } from '../../ui/Button/Button'; +import { BottomSheetModal } from '../../UIComponents/BottomSheetModal'; +import { StreamBottomSheetModalFlatList } from '../../UIComponents/StreamBottomSheetModalFlatList'; + +export type ChannelEditImageSheetProps = { + onClose: () => void; + visible: boolean; +}; + +const selectCanReset = (state: EditChannelDetailsState) => ({ + canReset: Boolean(state.initialImage || state.updatedImage), +}); + +type SheetItem = { + destructive?: boolean; + Icon: React.ComponentType<IconProps>; + id: 'take-photo' | 'choose-image' | 'reset-picture'; + label: string; + onPress: () => void; + testID: string; +}; + +const keyExtractor = (item: SheetItem) => item.id; + +const ChannelEditImageSheetInner = () => { + const { store } = useChannelEditDetailsContext(); + const { ChannelDetailsActionItem } = useComponentsContext(); + const { t } = useTranslationContext(); + const { + theme: { + channelDetails: { + editImageSheet: { + actionsList: actionsListOverride, + container: containerOverride, + header: headerOverride, + headerTitle: headerTitleOverride, + }, + }, + semantics, + }, + } = useTheme(); + const styles = useStyles(); + + const { close, dismiss } = useBottomSheetContext(); + const { canReset } = useStateStore(store.state, selectCanReset); + + const onSelect = useCallback( + (action: EditChannelImagePendingAction) => { + dismiss(() => store.setPendingAction(action)); + }, + [dismiss, store], + ); + + const items = useMemo<SheetItem[]>(() => { + const base: SheetItem[] = [ + { + Icon: Camera, + id: 'take-photo', + label: t('Take Photo'), + onPress: () => { + onSelect('camera'); + }, + testID: 'channel-edit-picture-take-photo', + }, + { + Icon: Picture, + id: 'choose-image', + label: t('Choose Image'), + onPress: () => { + onSelect('library'); + }, + testID: 'channel-edit-picture-choose-image', + }, + ]; + + if (canReset) { + base.push({ + destructive: true, + Icon: Delete, + id: 'reset-picture', + label: t('Reset Picture'), + onPress: () => { + onSelect('reset'); + }, + testID: 'channel-edit-picture-reset', + }); + } + + return base; + }, [canReset, onSelect, t]); + + const renderItem = useCallback<ListRenderItem<SheetItem>>( + ({ item }) => ( + <ChannelDetailsActionItem + destructive={item.destructive} + Icon={item.Icon} + label={item.label} + onPress={item.onPress} + testID={item.testID} + /> + ), + [ChannelDetailsActionItem], + ); + + return ( + <View style={[styles.container, containerOverride]}> + <View style={[styles.header, headerOverride]}> + <View style={styles.side}> + <Button + accessibilityLabelKey='a11y/Close edit picture sheet' + iconOnly + LeadingIcon={Cross} + onPress={() => close()} + size='md' + testID='channel-edit-picture-sheet-close-button' + type='outline' + variant='secondary' + /> + </View> + <View style={styles.center}> + <Text + accessibilityRole='header' + numberOfLines={1} + style={[styles.title, { color: semantics.textPrimary }, headerTitleOverride]} + > + {t('Edit Group Picture')} + </Text> + </View> + <View style={[styles.side, styles.sideRight]} /> + </View> + <StreamBottomSheetModalFlatList<SheetItem> + contentContainerStyle={[styles.actionsList, actionsListOverride]} + data={items} + keyExtractor={keyExtractor} + renderItem={renderItem} + /> + </View> + ); +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelEditImageSheet = ({ onClose, visible }: ChannelEditImageSheetProps) => ( + <BottomSheetModal enableDynamicSizing onClose={onClose} visible={visible}> + <ChannelEditImageSheetInner /> + </BottomSheetModal> +); + +const useStyles = () => + useMemo<{ + actionsList: ViewStyle; + center: ViewStyle; + container: ViewStyle; + header: ViewStyle; + side: ViewStyle; + sideRight: ViewStyle; + title: TextStyle; + }>( + () => + StyleSheet.create({ + actionsList: { + paddingHorizontal: primitives.spacingXxs, + }, + center: { + alignItems: 'center', + flex: 2, + justifyContent: 'center', + }, + container: { + flexDirection: 'column', + }, + header: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingSm, + justifyContent: 'space-between', + paddingBottom: primitives.spacingSm, + paddingHorizontal: primitives.spacingSm, + paddingTop: primitives.spacingSm, + }, + side: { + flex: 1, + justifyContent: 'center', + }, + sideRight: { + alignItems: 'flex-end', + }, + title: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + }), + [], + ); diff --git a/package/src/components/ChannelDetails/components/ChannelEditName.tsx b/package/src/components/ChannelDetails/components/ChannelEditName.tsx new file mode 100644 index 0000000000..1c0d51d22f --- /dev/null +++ b/package/src/components/ChannelDetails/components/ChannelEditName.tsx @@ -0,0 +1,52 @@ +import React, { useCallback } from 'react'; + +import { useChannelEditDetailsContext } from '../../../contexts/channelEditDetailsContext/ChannelEditDetailsContext'; +import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useStateStore } from '../../../hooks/useStateStore'; +import type { EditChannelDetailsState } from '../../../state-store/edit-channel-details-store'; +import { Input } from '../../ui/Input/Input'; + +const selectCurrentName = (state: EditChannelDetailsState) => ({ + currentName: state.currentName, +}); + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelEditName = () => { + const { store } = useChannelEditDetailsContext(); + const { t } = useTranslationContext(); + const { + theme: { + channelDetails: { + editChannel: { nameInput: nameInputOverride }, + }, + }, + } = useTheme(); + + const { currentName } = useStateStore(store.state, selectCurrentName); + + const handleNameChange = useCallback( + (newName: string) => { + store.setCurrentName(newName); + }, + [store], + ); + + return ( + <Input + accessibilityLabel={t('a11y/Channel name')} + autoCapitalize='words' + autoCorrect={false} + containerStyle={nameInputOverride} + helperText={false} + onChangeText={handleNameChange} + placeholder={t('Channel name')} + returnKeyType='done' + testID='channel-edit-name-input' + value={currentName} + variant='outline' + /> + ); +}; diff --git a/package/src/components/ChannelDetails/components/__tests__/navigation-section/FileAttachmentItem.test.tsx b/package/src/components/ChannelDetails/components/__tests__/navigation-section/FileAttachmentItem.test.tsx new file mode 100644 index 0000000000..effb80ba9a --- /dev/null +++ b/package/src/components/ChannelDetails/components/__tests__/navigation-section/FileAttachmentItem.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import type { Attachment, MessageResponse } from 'stream-chat'; + +import { ThemeProvider } from '../../../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../../../contexts/themeContext/utils/theme'; +import { generateFileAttachment } from '../../../../../mock-builders/generator/attachment'; +import { generateMessage } from '../../../../../mock-builders/generator/message'; +import { FileAttachmentItem } from '../../navigation-section/FileAttachmentItem'; + +const mockOpenUrlSafely = jest.fn(); + +jest.mock('../../../../Attachment/utils/openUrlSafely', () => ({ + openUrlSafely: (url?: string) => mockOpenUrlSafely(url), +})); + +const mockFilePreviewProbe: { title?: string }[] = []; + +jest.mock('../../../../Attachment/FilePreview', () => { + const ReactLib = require('react'); + const { Text } = require('react-native'); + return { + FilePreview: ({ attachment }: { attachment: { title?: string } }) => { + mockFilePreviewProbe.push({ title: attachment.title }); + return ReactLib.createElement(Text, null, attachment.title); + }, + }; +}); + +const tree = ( + attachment: Attachment, + message: MessageResponse, + onPress?: React.ComponentProps<typeof FileAttachmentItem>['onPress'], +) => ( + <ThemeProvider theme={defaultTheme}> + <FileAttachmentItem attachment={attachment} message={message} onPress={onPress} /> + </ThemeProvider> +); + +describe('FileAttachmentItem', () => { + beforeEach(() => { + mockFilePreviewProbe.length = 0; + }); + + afterEach(() => jest.clearAllMocks()); + + it('renders the attachment preview', () => { + const file = generateFileAttachment({ title: 'a-file.pdf' }); + const message = generateMessage({ id: 'm-1' }) as unknown as MessageResponse; + + render(tree(file, message)); + + expect(mockFilePreviewProbe.map((p) => p.title)).toEqual(['a-file.pdf']); + expect(screen.getByTestId('file-attachment-item-m-1')).toBeTruthy(); + }); + + it('opens the attachment url when a row is pressed', () => { + const file = generateFileAttachment({ asset_url: 'https://example.com/a.pdf', title: 'a.pdf' }); + const message = generateMessage({ id: 'm-3' }) as unknown as MessageResponse; + + render(tree(file, message)); + + fireEvent.press(screen.getByTestId('file-attachment-row-m-3')); + expect(mockOpenUrlSafely).toHaveBeenCalledWith('https://example.com/a.pdf'); + }); + + it('calls the provided onPress with the attachment and message, overriding the default', () => { + const file = generateFileAttachment({ asset_url: 'https://example.com/a.pdf', title: 'a.pdf' }); + const message = generateMessage({ id: 'm-4' }) as unknown as MessageResponse; + const onPress = jest.fn(); + + render(tree(file, message, onPress)); + + fireEvent.press(screen.getByTestId('file-attachment-row-m-4')); + expect(onPress).toHaveBeenCalledWith({ attachment: file, message }); + expect(mockOpenUrlSafely).not.toHaveBeenCalled(); + }); +}); diff --git a/package/src/components/ChannelDetails/components/__tests__/navigation-section/FileAttachmentList.test.tsx b/package/src/components/ChannelDetails/components/__tests__/navigation-section/FileAttachmentList.test.tsx new file mode 100644 index 0000000000..11b99972b4 --- /dev/null +++ b/package/src/components/ChannelDetails/components/__tests__/navigation-section/FileAttachmentList.test.tsx @@ -0,0 +1,334 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react-native'; + +import Dayjs from 'dayjs'; +import { StateStore } from 'stream-chat'; +import type { MessageResponse, SearchSourceState } from 'stream-chat'; + +import { + ChannelDetailsContextProvider, + type ChannelDetailsContextValue, +} from '../../../../../contexts/channelDetailsContext/channelDetailsContext'; +import { ThemeProvider } from '../../../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../../../contexts/themeContext/utils/theme'; +import { + TranslationProvider, + type TranslationContextValue, +} from '../../../../../contexts/translationContext/TranslationContext'; +import { generateFileAttachment } from '../../../../../mock-builders/generator/attachment'; +import { generateMessage } from '../../../../../mock-builders/generator/message'; +import { Streami18n } from '../../../../../utils/i18n/Streami18n'; +import type { FileAttachmentItemProps } from '../../navigation-section/FileAttachmentItem'; +import { FileAttachmentList } from '../../navigation-section/FileAttachmentList'; + +const mockRowProbe: FileAttachmentItemProps[] = []; + +type FakeSearchSource = { + search: jest.Mock; + state: StateStore< + Pick< + SearchSourceState<MessageResponse>, + 'hasNext' | 'isLoading' | 'items' | 'lastQueryError' | 'searchQuery' + > + >; +}; + +const mockChannel = { cid: 'messaging:1' }; +let mockCurrentSearchSource: FakeSearchSource; +const mockProviderProbe: { channel: unknown }[] = []; +const mockNotificationTargetProbe: { hostId?: string; panel?: string }[] = []; +const mockAddNotification = jest.fn(); + +jest.mock('../../../../Notifications/hooks/useNotificationApi', () => ({ + useNotificationApi: () => ({ addNotification: mockAddNotification }), +})); + +jest.mock('../../../../Notifications/NotificationTargetContext', () => ({ + NotificationTargetProvider: ({ + children, + hostId, + panel, + }: { + children: React.ReactNode; + hostId?: string; + panel?: string; + }) => { + mockNotificationTargetProbe.push({ hostId, panel }); + return children; + }, +})); + +jest.mock('../../../../Notifications/NotificationList', () => { + const ReactLib = require('react'); + const { View } = require('react-native'); + return { + NotificationList: () => ReactLib.createElement(View, { testID: 'notification-list' }), + }; +}); + +jest.mock( + '../../../../../contexts/channelFileAttachmentListContext/ChannelFileAttachmentListContext', + () => ({ + ChannelFileAttachmentListProvider: ({ + channel: providedChannel, + children, + }: { + channel: unknown; + children: React.ReactNode; + }) => { + mockProviderProbe.push({ channel: providedChannel }); + return children; + }, + useChannelFileAttachmentListContext: () => ({ + channel: mockChannel, + searchSource: mockCurrentSearchSource, + }), + }), +); + +jest.mock('../../navigation-section/FileAttachmentItem', () => { + const ReactLib = require('react'); + const { Text } = require('react-native'); + return { + FileAttachmentItem: (props: FileAttachmentItemProps) => { + mockRowProbe.push(props); + return ReactLib.createElement( + Text, + { testID: `file-row-${props.message.id}` }, + props.message.id, + ); + }, + }; +}); + +const makeSearchSource = ( + overrides: Partial<{ + hasNext: boolean; + isLoading: boolean; + items: MessageResponse[]; + lastQueryError: Error; + }> = {}, +): FakeSearchSource => ({ + search: jest.fn(), + state: new StateStore({ + hasNext: overrides.hasNext ?? false, + isLoading: overrides.isLoading ?? false, + items: overrides.items, + searchQuery: '', + ...(overrides.lastQueryError ? { lastQueryError: overrides.lastQueryError } : {}), + }), +}); + +const fakeTranslators = { + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => Dayjs(input as string)) as never, + userLanguage: 'en', +} as unknown as TranslationContextValue; + +const tree = ( + searchSource: FakeSearchSource, + props: { additionalSectionListProps?: object; translators?: TranslationContextValue } = {}, +) => { + mockCurrentSearchSource = searchSource; + return ( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider value={props.translators ?? fakeTranslators}> + <ChannelDetailsContextProvider + value={{ channel: mockChannel } as unknown as ChannelDetailsContextValue} + > + <FileAttachmentList + additionalSectionListProps={props.additionalSectionListProps as never} + /> + </ChannelDetailsContextProvider> + </TranslationProvider> + </ThemeProvider> + ); +}; + +describe('FileAttachmentList', () => { + let realTranslators: TranslationContextValue; + + beforeAll(async () => { + const i18nInstance = new Streami18n(); + realTranslators = (await i18nInstance.getTranslators()) as unknown as TranslationContextValue; + }); + + beforeEach(() => { + mockRowProbe.length = 0; + mockProviderProbe.length = 0; + mockNotificationTargetProbe.length = 0; + }); + + afterEach(() => jest.clearAllMocks()); + + it('wraps its content in the file attachment list provider for the channel', () => { + render(tree(makeSearchSource())); + + expect(mockProviderProbe).toHaveLength(1); + expect(mockProviderProbe[0].channel).toBe(mockChannel); + }); + + it('calls search when the component is created', () => { + const searchSource = makeSearchSource(); + render(tree(searchSource)); + + expect(searchSource.search).toHaveBeenCalledTimes(1); + expect(searchSource.search).toHaveBeenCalledWith(); + }); + + it('does not render a search input', () => { + render( + tree( + makeSearchSource({ + items: [generateMessage({ id: 'm-1' }) as unknown as MessageResponse], + }), + ), + ); + + expect(screen.queryByPlaceholderText('Search')).toBeNull(); + }); + + it('renders a row per attachment', () => { + const messageA = generateMessage({ + attachments: [generateFileAttachment()] as never, + created_at: new Date('2026-03-15T00:00:00.000Z'), + id: 'm-1', + }) as unknown as MessageResponse; + const messageB = generateMessage({ + attachments: [generateFileAttachment()] as never, + created_at: new Date('2026-03-16T00:00:00.000Z'), + id: 'm-2', + }) as unknown as MessageResponse; + + render(tree(makeSearchSource({ items: [messageA, messageB] }))); + + // The probe accumulates across re-renders, so assert on distinct rows instead of call count. + expect(new Set(mockRowProbe.map((p) => p.message.id))).toEqual(new Set(['m-1', 'm-2'])); + expect(screen.getByTestId('file-row-m-1')).toBeTruthy(); + expect(screen.getByTestId('file-row-m-2')).toBeTruthy(); + }); + + it('groups messages under month section headers in newest-first order', () => { + const march = generateMessage({ + attachments: [generateFileAttachment()] as never, + created_at: new Date('2026-03-15T00:00:00.000Z'), + id: 'm-mar', + }) as unknown as MessageResponse; + const february = generateMessage({ + attachments: [generateFileAttachment()] as never, + created_at: new Date('2026-02-10T00:00:00.000Z'), + id: 'm-feb', + }) as unknown as MessageResponse; + + // The search source returns messages newest-first; the component groups them by month. + render(tree(makeSearchSource({ items: [march, february] }), { translators: realTranslators })); + + const marchHeader = screen.getByText('March 2026'); + const februaryHeader = screen.getByText('February 2026'); + expect(marchHeader).toBeTruthy(); + expect(februaryHeader).toBeTruthy(); + }); + + it('shows the loading skeleton on the initial load', () => { + render(tree(makeSearchSource({ isLoading: true }))); + expect(screen.getByTestId('file-attachment-list-loading-skeleton')).toBeTruthy(); + }); + + it('shows the empty list when there are no files', () => { + render(tree(makeSearchSource({ isLoading: false, items: [] }))); + + expect(screen.getByTestId('empty-list')).toBeTruthy(); + expect(screen.getByText('No files')).toBeTruthy(); + expect(screen.getByText('Share a file to see it here')).toBeTruthy(); + }); + + it('renders the loading-more indicator only while loading with existing results', () => { + render( + tree( + makeSearchSource({ + isLoading: true, + items: [generateMessage({ id: 'm-1' }) as unknown as MessageResponse], + }), + ), + ); + expect(screen.UNSAFE_getByType(require('react-native').ActivityIndicator)).toBeTruthy(); + }); + + it('loads more via the search source when the list end is reached and there is a next page', () => { + const searchSource = makeSearchSource({ + hasNext: true, + items: [generateMessage({ id: 'm-1' }) as unknown as MessageResponse], + }); + render(tree(searchSource)); + searchSource.search.mockClear(); + + const list = screen.getByTestId('file-attachment-list'); + expect(list.props.onEndReachedThreshold).toBe(0.2); + list.props.onEndReached(); + expect(searchSource.search).toHaveBeenCalledTimes(1); + }); + + it('does not load more when there is no next page', () => { + const searchSource = makeSearchSource({ + hasNext: false, + items: [generateMessage({ id: 'm-1' }) as unknown as MessageResponse], + }); + render(tree(searchSource)); + searchSource.search.mockClear(); + + screen.getByTestId('file-attachment-list').props.onEndReached(); + expect(searchSource.search).not.toHaveBeenCalled(); + }); + + it('forwards additionalSectionListProps to the underlying list', () => { + render( + tree( + makeSearchSource({ items: [generateMessage({ id: 'm-1' }) as unknown as MessageResponse] }), + { additionalSectionListProps: { bounces: false, testID: 'custom-list' } }, + ), + ); + + const list = screen.getByTestId('custom-list'); + expect(list.props.bounces).toBe(false); + expect(screen.queryByTestId('file-attachment-list')).toBeNull(); + }); + + it('targets the channel-details panel with a channel-scoped notification host', () => { + render(tree(makeSearchSource())); + + expect(mockNotificationTargetProbe).toHaveLength(1); + expect(mockNotificationTargetProbe[0]).toEqual({ + hostId: 'file-attachment-list:messaging:1', + panel: 'channel-details', + }); + }); + + it('renders the notification list host', () => { + render(tree(makeSearchSource())); + + expect(screen.getByTestId('notification-list')).toBeTruthy(); + }); + + it('adds an error notification when the search source reports a query error', () => { + const lastQueryError = new Error('boom'); + render(tree(makeSearchSource({ lastQueryError }))); + + expect(mockAddNotification).toHaveBeenCalledTimes(1); + expect(mockAddNotification).toHaveBeenCalledWith({ + message: 'Failed to load files', + options: { + originalError: lastQueryError, + severity: 'error', + type: 'api:channel:query-file-attachments:failed', + }, + origin: { context: { channel: mockChannel }, emitter: 'ChannelFileAttachmentList' }, + }); + }); + + it('does not add a notification when there is no query error', () => { + render(tree(makeSearchSource())); + + expect(mockAddNotification).not.toHaveBeenCalled(); + }); +}); diff --git a/package/src/components/ChannelDetails/components/__tests__/navigation-section/FileAttachmentListSectionHeader.test.tsx b/package/src/components/ChannelDetails/components/__tests__/navigation-section/FileAttachmentListSectionHeader.test.tsx new file mode 100644 index 0000000000..c237aea528 --- /dev/null +++ b/package/src/components/ChannelDetails/components/__tests__/navigation-section/FileAttachmentListSectionHeader.test.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react-native'; + +import { ThemeProvider } from '../../../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../../../contexts/themeContext/utils/theme'; +import { FileAttachmentListSectionHeader } from '../../navigation-section/FileAttachmentListSectionHeader'; + +describe('FileAttachmentListSectionHeader', () => { + it('renders the provided title', () => { + render( + <ThemeProvider theme={defaultTheme}> + <FileAttachmentListSectionHeader title='March 2026' /> + </ThemeProvider>, + ); + + expect(screen.getByText('March 2026')).toBeTruthy(); + }); +}); diff --git a/package/src/components/ChannelDetails/components/__tests__/navigation-section/MediaItem.test.tsx b/package/src/components/ChannelDetails/components/__tests__/navigation-section/MediaItem.test.tsx new file mode 100644 index 0000000000..add09cc752 --- /dev/null +++ b/package/src/components/ChannelDetails/components/__tests__/navigation-section/MediaItem.test.tsx @@ -0,0 +1,97 @@ +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; +import type { Attachment, MessageResponse } from 'stream-chat'; + +import { ThemeProvider } from '../../../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../../../contexts/translationContext/TranslationContext'; +import { + generateImageAttachment, + generateVideoAttachment, +} from '../../../../../mock-builders/generator/attachment'; +import { generateMessage } from '../../../../../mock-builders/generator/message'; +import { generateUser } from '../../../../../mock-builders/generator/user'; +import { MediaItem, type MediaItemProps } from '../../navigation-section/MediaItem'; + +const renderItem = (props: Partial<MediaItemProps> & { attachment: Attachment }) => + render( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <MediaItem + attachment={props.attachment} + message={props.message ?? (generateMessage({ id: 'm-1' }) as unknown as MessageResponse)} + onPress={props.onPress} + size={120} + /> + </TranslationProvider> + </ThemeProvider>, + ); + +describe('MediaItem', () => { + afterEach(() => jest.clearAllMocks()); + + it('renders the thumbnail from the attachment url', () => { + renderItem({ + attachment: generateImageAttachment({ thumb_url: 'https://example.com/a.jpg' }), + }); + + expect(screen.getByTestId('media-item-thumbnail').props.source.uri).toBe( + 'https://example.com/a.jpg', + ); + }); + + it("renders the sender's avatar overlay", () => { + renderItem({ + attachment: generateImageAttachment({ thumb_url: 'https://example.com/a.jpg' }), + message: generateMessage({ + id: 'm-1', + user: generateUser({ id: 'u-1', name: 'Maya Ross' }), + }) as unknown as MessageResponse, + }); + + expect(screen.getByTestId('user-avatar')).toBeTruthy(); + }); + + it('renders the duration badge for video attachments', () => { + renderItem({ + attachment: generateVideoAttachment({ duration: 8000 }), + }); + + expect(screen.getByText('0:08')).toBeTruthy(); + }); + + it('does not render a duration badge for image attachments', () => { + renderItem({ + attachment: generateImageAttachment({ thumb_url: 'https://example.com/a.jpg' }), + }); + + expect(screen.queryByText('0:08')).toBeNull(); + }); + + it('fires onPress with the attachment and message', () => { + const onPress = jest.fn(); + const attachment = generateImageAttachment({ thumb_url: 'https://example.com/a.jpg' }); + const message = generateMessage({ id: 'm-1' }) as unknown as MessageResponse; + + renderItem({ attachment, message, onPress }); + + fireEvent.press(screen.getByTestId('media-item-m-1')); + expect(onPress).toHaveBeenCalledWith(expect.objectContaining({ attachment, message })); + }); + + it('renders a tile keyed by the message id', () => { + renderItem({ + attachment: generateImageAttachment({ thumb_url: 'https://example.com/a.jpg' }), + message: generateMessage({ id: 'm-99' }) as unknown as MessageResponse, + }); + + expect(screen.getByTestId('media-item-m-99')).toBeTruthy(); + }); +}); diff --git a/package/src/components/ChannelDetails/components/__tests__/navigation-section/MediaList.test.tsx b/package/src/components/ChannelDetails/components/__tests__/navigation-section/MediaList.test.tsx new file mode 100644 index 0000000000..787a806e72 --- /dev/null +++ b/package/src/components/ChannelDetails/components/__tests__/navigation-section/MediaList.test.tsx @@ -0,0 +1,351 @@ +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { StateStore } from 'stream-chat'; +import type { MessageResponse, SearchSourceState } from 'stream-chat'; + +import { + ChannelDetailsContextProvider, + type ChannelDetailsContextValue, +} from '../../../../../contexts/channelDetailsContext/channelDetailsContext'; +import { ThemeProvider } from '../../../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../../../contexts/translationContext/TranslationContext'; +import { + generateImageAttachment, + generateVideoAttachment, +} from '../../../../../mock-builders/generator/attachment'; +import { generateMessage } from '../../../../../mock-builders/generator/message'; +import type { MediaItemProps } from '../../navigation-section/MediaItem'; +import { MediaList } from '../../navigation-section/MediaList'; + +const mockTileProbe: MediaItemProps[] = []; + +const mockOpenImageGallery = jest.fn(); +const mockImageGalleryStateStore = { openImageGallery: mockOpenImageGallery }; +const mockSetOverlay = jest.fn(); +const mockIsVideoPlayerAvailable = jest.fn(() => true); +const mockOpenUrlSafely = jest.fn(); + +jest.mock('../../../../../contexts/imageGalleryContext/ImageGalleryContext', () => ({ + ...jest.requireActual('../../../../../contexts/imageGalleryContext/ImageGalleryContext'), + useImageGalleryContext: () => ({ imageGalleryStateStore: mockImageGalleryStateStore }), +})); + +jest.mock('../../../../../contexts/overlayContext/OverlayContext', () => ({ + ...jest.requireActual('../../../../../contexts/overlayContext/OverlayContext'), + useOverlayContext: () => ({ setOverlay: mockSetOverlay }), +})); + +jest.mock('../../../../../native', () => ({ + ...jest.requireActual('../../../../../native'), + isVideoPlayerAvailable: () => mockIsVideoPlayerAvailable(), +})); + +jest.mock('../../../../Attachment/utils/openUrlSafely', () => ({ + openUrlSafely: (...args: unknown[]) => mockOpenUrlSafely(...args), +})); + +type FakeSearchSource = { + search: jest.Mock; + state: StateStore< + Pick<SearchSourceState<MessageResponse>, 'hasNext' | 'isLoading' | 'items' | 'lastQueryError'> + >; +}; + +const mockChannel = { cid: 'messaging:1' }; +let mockCurrentSearchSource: FakeSearchSource; +const mockProviderProbe: { channel: unknown }[] = []; +const mockNotificationTargetProbe: { hostId?: string; panel?: string }[] = []; +const mockAddNotification = jest.fn(); + +jest.mock('../../../../Notifications/hooks/useNotificationApi', () => ({ + useNotificationApi: () => ({ addNotification: mockAddNotification }), +})); + +jest.mock('../../../../Notifications/NotificationTargetContext', () => ({ + NotificationTargetProvider: ({ + children, + hostId, + panel, + }: { + children: React.ReactNode; + hostId?: string; + panel?: string; + }) => { + mockNotificationTargetProbe.push({ hostId, panel }); + return children; + }, +})); + +jest.mock('../../../../Notifications/NotificationList', () => { + const ReactLib = require('react'); + const { View } = require('react-native'); + return { + NotificationList: () => ReactLib.createElement(View, { testID: 'notification-list' }), + }; +}); + +jest.mock('../../../../../contexts/channelMediaListContext/ChannelMediaListContext', () => { + const actual = jest.requireActual( + '../../../../../contexts/channelMediaListContext/ChannelMediaListContext', + ); + const ReactLib = require('react'); + return { + ...actual, + // Inject a fake search source through the real context so MediaListContent can read it via + // the real `useChannelMediaListContext`. + ChannelMediaListProvider: ({ + channel: providedChannel, + children, + }: { + channel: unknown; + children: React.ReactNode; + }) => { + mockProviderProbe.push({ channel: providedChannel }); + return ReactLib.createElement( + actual.ChannelMediaListContext.Provider, + { value: { channel: mockChannel, searchSource: mockCurrentSearchSource } }, + children, + ); + }, + }; +}); + +jest.mock('../../navigation-section/MediaItem', () => { + const ReactLib = require('react'); + const { Text } = require('react-native'); + return { + MediaItem: (props: MediaItemProps) => { + mockTileProbe.push(props); + // Mirror production: the tile receives its press handler from the list via `renderItem`. + return ReactLib.createElement( + Text, + { + onPress: () => + props.onPress?.({ + attachment: props.attachment, + message: props.message, + requesterNode: 123, + }), + testID: 'media-tile', + }, + `${props.message.id}-${props.attachment.type}`, + ); + }, + }; +}); + +const makeSearchSource = ( + overrides: Partial<{ + hasNext: boolean; + isLoading: boolean; + items: MessageResponse[]; + lastQueryError: Error; + }> = {}, +): FakeSearchSource => ({ + search: jest.fn(), + state: new StateStore({ + hasNext: overrides.hasNext ?? false, + isLoading: overrides.isLoading ?? false, + items: overrides.items, + ...(overrides.lastQueryError ? { lastQueryError: overrides.lastQueryError } : {}), + }), +}); + +const tree = (searchSource: FakeSearchSource, props: { additionalFlatListProps?: object } = {}) => { + mockCurrentSearchSource = searchSource; + return ( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChannelDetailsContextProvider + value={{ channel: mockChannel } as unknown as ChannelDetailsContextValue} + > + <MediaList additionalFlatListProps={props.additionalFlatListProps as never} /> + </ChannelDetailsContextProvider> + </TranslationProvider> + </ThemeProvider> + ); +}; + +const messageWithAttachments = (id: string, attachments: unknown[]) => + generateMessage({ attachments: attachments as never, id }) as unknown as MessageResponse; + +describe('MediaList', () => { + beforeEach(() => { + mockTileProbe.length = 0; + mockProviderProbe.length = 0; + mockNotificationTargetProbe.length = 0; + mockIsVideoPlayerAvailable.mockReturnValue(true); + }); + + afterEach(() => jest.clearAllMocks()); + + it('wraps its content in the media list provider for the channel', () => { + render(tree(makeSearchSource())); + + expect(mockProviderProbe).toHaveLength(1); + expect(mockProviderProbe[0].channel).toBe(mockChannel); + }); + + it('calls search when the component is created', () => { + const searchSource = makeSearchSource(); + render(tree(searchSource)); + + expect(searchSource.search).toHaveBeenCalledTimes(1); + expect(searchSource.search).toHaveBeenCalledWith(); + }); + + it('renders one tile per media attachment returned by the search source', () => { + const messageA = messageWithAttachments('m-1', [ + generateImageAttachment(), + generateVideoAttachment(), + ]); + + render(tree(makeSearchSource({ items: [messageA] }))); + + const tiles = screen.getAllByTestId('media-tile'); + expect(tiles.map((tile) => tile.props.children)).toEqual(['m-1-image', 'm-1-video']); + }); + + it('opens the fullscreen gallery over all loaded media when an image tile is pressed', () => { + const attachment = generateImageAttachment({ image_url: 'https://example.com/a.jpg' }); + const messageA = messageWithAttachments('m-1', [attachment]); + const messageB = messageWithAttachments('m-2', [generateImageAttachment()]); + + render(tree(makeSearchSource({ items: [messageA, messageB] }))); + + fireEvent.press(screen.getAllByTestId('media-tile')[0]); + + expect(mockOpenImageGallery).toHaveBeenCalledTimes(1); + const callArgs = mockOpenImageGallery.mock.calls[0][0]; + expect(callArgs.selectedAttachmentUrl).toBe('https://example.com/a.jpg'); + expect(callArgs.requesterNode).toBe(123); + expect(callArgs.messages.map((m: MessageResponse) => m.id)).toEqual(['m-1', 'm-2']); + expect(mockSetOverlay).toHaveBeenCalledWith('gallery'); + }); + + it('opens the video URL instead of the gallery when no video player is available', () => { + mockIsVideoPlayerAvailable.mockReturnValue(false); + const attachment = generateVideoAttachment({ asset_url: 'https://example.com/v.mp4' }); + const messageA = messageWithAttachments('m-1', [attachment]); + + render(tree(makeSearchSource({ items: [messageA] }))); + + fireEvent.press(screen.getAllByTestId('media-tile')[0]); + + expect(mockOpenUrlSafely).toHaveBeenCalledWith('https://example.com/v.mp4'); + expect(mockOpenImageGallery).not.toHaveBeenCalled(); + expect(mockSetOverlay).not.toHaveBeenCalled(); + }); + + it('shows the loading skeleton on the initial load', () => { + render(tree(makeSearchSource({ isLoading: true }))); + expect(screen.getByTestId('media-list-loading-skeleton')).toBeTruthy(); + }); + + it('shows the empty state when there is no media', () => { + render(tree(makeSearchSource({ isLoading: false, items: [] }))); + + expect(screen.getByTestId('empty-list')).toBeTruthy(); + expect(screen.getByText('No photos or videos')).toBeTruthy(); + expect(screen.getByText('Share a photo or video to see it here')).toBeTruthy(); + }); + + it('renders the loading-more indicator only while loading with existing results', () => { + render( + tree( + makeSearchSource({ + isLoading: true, + items: [messageWithAttachments('m-1', [generateImageAttachment()])], + }), + ), + ); + expect(screen.UNSAFE_getByType(require('react-native').ActivityIndicator)).toBeTruthy(); + }); + + it('loads more via the search source when the list end is reached and there is a next page', () => { + const searchSource = makeSearchSource({ + hasNext: true, + items: [messageWithAttachments('m-1', [generateImageAttachment()])], + }); + render(tree(searchSource)); + searchSource.search.mockClear(); + + const list = screen.getByTestId('media-list'); + expect(list.props.onEndReachedThreshold).toBe(0.2); + list.props.onEndReached(); + expect(searchSource.search).toHaveBeenCalledTimes(1); + }); + + it('does not load more when there is no next page', () => { + const searchSource = makeSearchSource({ + hasNext: false, + items: [messageWithAttachments('m-1', [generateImageAttachment()])], + }); + render(tree(searchSource)); + searchSource.search.mockClear(); + + screen.getByTestId('media-list').props.onEndReached(); + expect(searchSource.search).not.toHaveBeenCalled(); + }); + + it('forwards additionalFlatListProps to the underlying list', () => { + render( + tree( + makeSearchSource({ items: [messageWithAttachments('m-1', [generateImageAttachment()])] }), + { + additionalFlatListProps: { bounces: false, testID: 'custom-list' }, + }, + ), + ); + + const list = screen.getByTestId('custom-list'); + expect(list.props.bounces).toBe(false); + expect(screen.queryByTestId('media-list')).toBeNull(); + }); + + it('targets the channel-details panel with a channel-scoped notification host', () => { + render(tree(makeSearchSource())); + + expect(mockNotificationTargetProbe).toHaveLength(1); + expect(mockNotificationTargetProbe[0]).toEqual({ + hostId: 'media-list:messaging:1', + panel: 'channel-details', + }); + }); + + it('renders the notification list host', () => { + render(tree(makeSearchSource())); + + expect(screen.getByTestId('notification-list')).toBeTruthy(); + }); + + it('adds an error notification when the search source reports a query error', () => { + const lastQueryError = new Error('boom'); + render(tree(makeSearchSource({ lastQueryError }))); + + expect(mockAddNotification).toHaveBeenCalledTimes(1); + expect(mockAddNotification).toHaveBeenCalledWith({ + message: 'Failed to load media', + options: { + originalError: lastQueryError, + severity: 'error', + type: 'api:channel:query-media:failed', + }, + origin: { context: { channel: mockChannel }, emitter: 'ChannelMediaList' }, + }); + }); + + it('does not add a notification when there is no query error', () => { + render(tree(makeSearchSource())); + + expect(mockAddNotification).not.toHaveBeenCalled(); + }); +}); diff --git a/package/src/components/ChannelDetails/components/__tests__/navigation-section/PinnedMessageItem.test.tsx b/package/src/components/ChannelDetails/components/__tests__/navigation-section/PinnedMessageItem.test.tsx new file mode 100644 index 0000000000..85d568e150 --- /dev/null +++ b/package/src/components/ChannelDetails/components/__tests__/navigation-section/PinnedMessageItem.test.tsx @@ -0,0 +1,112 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react-native'; +import type { MessageResponse } from 'stream-chat'; + +import { ThemeProvider } from '../../../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../../../contexts/translationContext/TranslationContext'; +import { generateMessage } from '../../../../../mock-builders/generator/message'; +import { generateUser } from '../../../../../mock-builders/generator/user'; +import { PinnedMessageItem } from '../../navigation-section/PinnedMessageItem'; + +jest.mock('../../../../ChannelPreview/ChannelLastMessagePreview', () => { + const ReactLib = require('react'); + const { Text } = require('react-native'); + return { + ChannelLastMessagePreview: ({ message }: { message: MessageResponse }) => + ReactLib.createElement(Text, { testID: 'preview-message' }, message.id), + }; +}); + +const mockChannelPreviewStatusSpy = jest.fn(); + +jest.mock('../../../../ChannelPreview/ChannelPreviewStatus', () => { + const ReactLib = require('react'); + const { Text } = require('react-native'); + return { + ChannelPreviewStatus: (props: { lastMessage: MessageResponse }) => { + mockChannelPreviewStatusSpy(props); + return ReactLib.createElement(Text, { testID: 'preview-status' }, props.lastMessage.id); + }, + }; +}); + +const channel = { cid: 'messaging:1' } as never; + +const renderItem = ( + message: MessageResponse, + formatMessageDate?: (date?: string | Date) => string, +) => + render( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <PinnedMessageItem + channel={channel} + formatMessageDate={formatMessageDate as never} + message={message} + /> + </TranslationProvider> + </ThemeProvider>, + ); + +describe('PinnedMessageItem', () => { + afterEach(() => jest.clearAllMocks()); + + it("renders the sender's avatar and name", () => { + const message = generateMessage({ + id: 'm-1', + user: generateUser({ id: 'u-1', name: 'Maya Ross' }), + }) as unknown as MessageResponse; + + renderItem(message); + + expect(screen.getByText('Maya Ross')).toBeTruthy(); + expect(screen.getByTestId('user-avatar')).toBeTruthy(); + }); + + it('falls back to the user id when the sender has no name', () => { + const message = generateMessage({ + id: 'm-1', + user: generateUser({ id: 'just-an-id', name: undefined }), + }) as unknown as MessageResponse; + + renderItem(message); + + expect(screen.getByText('just-an-id')).toBeTruthy(); + }); + + it('passes the message to the preview status and message components', () => { + const message = generateMessage({ id: 'm-42' }) as unknown as MessageResponse; + + renderItem(message); + + expect(screen.getByTestId('preview-status')).toHaveTextContent('m-42'); + expect(screen.getByTestId('preview-message')).toHaveTextContent('m-42'); + }); + + it('forwards the formatMessageDate prop to the preview status as formatLatestMessageDate', () => { + const message = generateMessage({ id: 'm-1' }) as unknown as MessageResponse; + const formatMessageDate = jest.fn(() => 'formatted-date'); + + renderItem(message, formatMessageDate); + + expect(mockChannelPreviewStatusSpy).toHaveBeenCalledWith( + expect.objectContaining({ formatLatestMessageDate: formatMessageDate }), + ); + }); + + it('renders a row keyed by the message id', () => { + const message = generateMessage({ id: 'm-1' }) as unknown as MessageResponse; + + renderItem(message); + + expect(screen.getByTestId('pinned-message-item-m-1')).toBeTruthy(); + }); +}); diff --git a/package/src/components/ChannelDetails/components/__tests__/navigation-section/PinnedMessageList.test.tsx b/package/src/components/ChannelDetails/components/__tests__/navigation-section/PinnedMessageList.test.tsx new file mode 100644 index 0000000000..604f29ccec --- /dev/null +++ b/package/src/components/ChannelDetails/components/__tests__/navigation-section/PinnedMessageList.test.tsx @@ -0,0 +1,324 @@ +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { StateStore } from 'stream-chat'; +import type { MessageResponse, SearchSourceState } from 'stream-chat'; + +import { + ChannelDetailsContextProvider, + type ChannelDetailsContextValue, +} from '../../../../../contexts/channelDetailsContext/channelDetailsContext'; +import { ThemeProvider } from '../../../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../../../contexts/themeContext/utils/theme'; +import { TranslationProvider } from '../../../../../contexts/translationContext/TranslationContext'; +import { generateMessage } from '../../../../../mock-builders/generator/message'; +import type { PinnedMessageItemProps } from '../../navigation-section/PinnedMessageItem'; +import { PinnedMessageList } from '../../navigation-section/PinnedMessageList'; + +const mockRowProbe: PinnedMessageItemProps[] = []; + +type FakeSearchSource = { + search: jest.Mock; + state: StateStore< + Pick< + SearchSourceState<MessageResponse>, + 'hasNext' | 'isLoading' | 'items' | 'lastQueryError' | 'searchQuery' + > + >; +}; + +const mockChannel = { cid: 'messaging:1' }; +let mockCurrentSearchSource: FakeSearchSource; +const mockProviderProbe: { channel: unknown }[] = []; +const mockNotificationTargetProbe: { hostId?: string; panel?: string }[] = []; +const mockAddNotification = jest.fn(); + +jest.mock('../../../../Notifications/hooks/useNotificationApi', () => ({ + useNotificationApi: () => ({ addNotification: mockAddNotification }), +})); + +jest.mock('../../../../Notifications/NotificationTargetContext', () => ({ + NotificationTargetProvider: ({ + children, + hostId, + panel, + }: { + children: React.ReactNode; + hostId?: string; + panel?: string; + }) => { + mockNotificationTargetProbe.push({ hostId, panel }); + return children; + }, +})); + +jest.mock('../../../../Notifications/NotificationList', () => { + const ReactLib = require('react'); + const { View } = require('react-native'); + return { + NotificationList: () => ReactLib.createElement(View, { testID: 'notification-list' }), + }; +}); + +jest.mock( + '../../../../../contexts/channelPinnedMessageListContext/ChannelPinnedMessageListContext', + () => ({ + ChannelPinnedMessageListProvider: ({ + channel: providedChannel, + children, + }: { + channel: unknown; + children: React.ReactNode; + }) => { + mockProviderProbe.push({ channel: providedChannel }); + return children; + }, + useChannelPinnedMessageListContext: () => ({ + channel: mockChannel, + searchSource: mockCurrentSearchSource, + }), + }), +); + +jest.mock('../../navigation-section/PinnedMessageItem', () => { + const ReactLib = require('react'); + const { Text } = require('react-native'); + return { + PinnedMessageItem: (props: PinnedMessageItemProps) => { + mockRowProbe.push(props); + return ReactLib.createElement( + Text, + { testID: `pinned-row-${props.message.id}` }, + props.message.id, + ); + }, + }; +}); + +jest.mock('../../../../UIComponents/SearchInput', () => { + const ReactLib = require('react'); + const { Text } = require('react-native'); + return { + SearchInput: ({ onChangeText }: { onChangeText: (t: string) => void }) => + ReactLib.createElement( + ReactLib.Fragment, + null, + ReactLib.createElement( + Text, + { onPress: () => onChangeText('query'), testID: 'search-change' }, + 'change', + ), + ReactLib.createElement( + Text, + { onPress: () => onChangeText(''), testID: 'search-clear' }, + 'clear', + ), + ), + }; +}); + +const makeSearchSource = ( + overrides: Partial<{ + hasNext: boolean; + isLoading: boolean; + items: MessageResponse[]; + lastQueryError: Error; + searchQuery: string; + }> = {}, +): FakeSearchSource => ({ + search: jest.fn(), + state: new StateStore({ + hasNext: overrides.hasNext ?? false, + isLoading: overrides.isLoading ?? false, + items: overrides.items, + searchQuery: overrides.searchQuery ?? '', + ...(overrides.lastQueryError ? { lastQueryError: overrides.lastQueryError } : {}), + }), +}); + +const tree = (searchSource: FakeSearchSource, props: { additionalFlatListProps?: object } = {}) => { + mockCurrentSearchSource = searchSource; + return ( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <ChannelDetailsContextProvider + value={{ channel: mockChannel } as unknown as ChannelDetailsContextValue} + > + <PinnedMessageList additionalFlatListProps={props.additionalFlatListProps as never} /> + </ChannelDetailsContextProvider> + </TranslationProvider> + </ThemeProvider> + ); +}; + +describe('PinnedMessageList', () => { + beforeEach(() => { + mockRowProbe.length = 0; + mockProviderProbe.length = 0; + mockNotificationTargetProbe.length = 0; + }); + + afterEach(() => jest.clearAllMocks()); + + it('wraps its content in the pinned message list provider for the channel', () => { + render(tree(makeSearchSource())); + + expect(mockProviderProbe).toHaveLength(1); + expect(mockProviderProbe[0].channel).toBe(mockChannel); + }); + + it('calls search when the component is created', () => { + const searchSource = makeSearchSource(); + render(tree(searchSource)); + + expect(searchSource.search).toHaveBeenCalledTimes(1); + expect(searchSource.search).toHaveBeenCalledWith(); + }); + + it('wires the search input to the search source callbacks', () => { + const searchSource = makeSearchSource({ + items: [generateMessage({ id: 'm-1' }) as unknown as MessageResponse], + }); + render(tree(searchSource)); + searchSource.search.mockClear(); + + fireEvent.press(screen.getByTestId('search-change')); + expect(searchSource.search).toHaveBeenCalledWith('query'); + + fireEvent.press(screen.getByTestId('search-clear')); + expect(searchSource.search).toHaveBeenCalledWith(''); + }); + + it('renders a row per pinned message and forwards the channel', () => { + const messageA = generateMessage({ id: 'm-1' }) as unknown as MessageResponse; + const messageB = generateMessage({ id: 'm-2' }) as unknown as MessageResponse; + + render(tree(makeSearchSource({ items: [messageA, messageB] }))); + + expect(mockRowProbe).toHaveLength(2); + expect(mockRowProbe.map((p) => p.message.id)).toEqual(['m-1', 'm-2']); + expect(mockRowProbe[0].channel).toBe(mockChannel); + expect(screen.getByTestId('pinned-row-m-1')).toBeTruthy(); + }); + + it('shows the loading skeleton and keeps the search bar on the initial load', () => { + render(tree(makeSearchSource({ isLoading: true }))); + expect(screen.getByTestId('pinned-message-list-loading-skeleton')).toBeTruthy(); + expect(screen.getByTestId('search-change')).toBeTruthy(); + }); + + it('shows the empty list and hides the search bar when there are no pins and no query', () => { + render(tree(makeSearchSource({ isLoading: false, items: [], searchQuery: '' }))); + + expect(screen.getByTestId('empty-list')).toBeTruthy(); + expect(screen.getByText('No pinned messages')).toBeTruthy(); + expect(screen.getByText('Long-press a message to pin it to the chat')).toBeTruthy(); + expect(screen.queryByTestId('search-change')).toBeNull(); + }); + + it('shows the empty search result and keeps the search bar when a query returns no results', () => { + render(tree(makeSearchSource({ isLoading: false, items: [], searchQuery: 'query' }))); + + expect(screen.getByTestId('empty-search-result')).toBeTruthy(); + expect(screen.getByText('No pinned messages')).toBeTruthy(); + expect(screen.queryByTestId('empty-list')).toBeNull(); + expect(screen.getByTestId('search-change')).toBeTruthy(); + }); + + it('renders the loading-more indicator only while loading with existing results', () => { + render( + tree( + makeSearchSource({ + isLoading: true, + items: [generateMessage({ id: 'm-1' }) as unknown as MessageResponse], + }), + ), + ); + expect(screen.UNSAFE_getByType(require('react-native').ActivityIndicator)).toBeTruthy(); + }); + + it('loads more via the search source when the list end is reached and there is a next page', () => { + const searchSource = makeSearchSource({ + hasNext: true, + items: [generateMessage({ id: 'm-1' }) as unknown as MessageResponse], + }); + render(tree(searchSource)); + searchSource.search.mockClear(); + + const list = screen.getByTestId('pinned-message-list'); + expect(list.props.onEndReachedThreshold).toBe(0.2); + list.props.onEndReached(); + expect(searchSource.search).toHaveBeenCalledTimes(1); + }); + + it('does not load more when there is no next page', () => { + const searchSource = makeSearchSource({ + hasNext: false, + items: [generateMessage({ id: 'm-1' }) as unknown as MessageResponse], + }); + render(tree(searchSource)); + searchSource.search.mockClear(); + + screen.getByTestId('pinned-message-list').props.onEndReached(); + expect(searchSource.search).not.toHaveBeenCalled(); + }); + + it('forwards additionalFlatListProps to the underlying list', () => { + render( + tree( + makeSearchSource({ items: [generateMessage({ id: 'm-1' }) as unknown as MessageResponse] }), + { + additionalFlatListProps: { bounces: false, testID: 'custom-list' }, + }, + ), + ); + + const list = screen.getByTestId('custom-list'); + expect(list.props.bounces).toBe(false); + expect(screen.queryByTestId('pinned-message-list')).toBeNull(); + }); + + it('targets the channel-details panel with a channel-scoped notification host', () => { + render(tree(makeSearchSource())); + + expect(mockNotificationTargetProbe).toHaveLength(1); + expect(mockNotificationTargetProbe[0]).toEqual({ + hostId: 'pinned-message-list:messaging:1', + panel: 'channel-details', + }); + }); + + it('renders the notification list host', () => { + render(tree(makeSearchSource())); + + expect(screen.getByTestId('notification-list')).toBeTruthy(); + }); + + it('adds an error notification when the search source reports a query error', () => { + const lastQueryError = new Error('boom'); + render(tree(makeSearchSource({ lastQueryError }))); + + expect(mockAddNotification).toHaveBeenCalledTimes(1); + expect(mockAddNotification).toHaveBeenCalledWith({ + message: 'Failed to load pinned messages', + options: { + originalError: lastQueryError, + severity: 'error', + type: 'api:channel:query-pinned-messages:failed', + }, + origin: { context: { channel: mockChannel }, emitter: 'ChannelPinnedMessageList' }, + }); + }); + + it('does not add a notification when there is no query error', () => { + render(tree(makeSearchSource())); + + expect(mockAddNotification).not.toHaveBeenCalled(); + }); +}); diff --git a/package/src/components/ChannelDetails/components/index.ts b/package/src/components/ChannelDetails/components/index.ts new file mode 100644 index 0000000000..8eb88bebce --- /dev/null +++ b/package/src/components/ChannelDetails/components/index.ts @@ -0,0 +1,14 @@ +export * from './ChannelDetailsActionsSection'; +export * from './ChannelDetailsActionItem'; +export * from './ChannelDetailsMemberSection'; +export * from './ChannelDetailsNavigationSection'; +export * from './ChannelDetailsProfile'; +export * from './ChannelDetailsEditButton'; +export * from './ChannelDetailsNavHeader'; +export * from './ChannelEditDetails'; +export * from './ChannelEditDetailsModal'; +export * from './ChannelEditImageSheet'; +export * from './ChannelEditName'; +export * from './ChannelDetailsEditButton'; +export * from './members'; +export * from './navigation-section'; diff --git a/package/src/components/ChannelDetails/components/members/AddMemberSearchResultItem.tsx b/package/src/components/ChannelDetails/components/members/AddMemberSearchResultItem.tsx new file mode 100644 index 0000000000..85467b3c52 --- /dev/null +++ b/package/src/components/ChannelDetails/components/members/AddMemberSearchResultItem.tsx @@ -0,0 +1,136 @@ +import React, { useMemo } from 'react'; +import { I18nManager, Pressable, StyleSheet, Text, View } from 'react-native'; + +import type { UserResponse } from 'stream-chat'; + +import { useChannelAddMembersContext, useChannelDetailsContext } from '../../../../contexts'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; +import { useIsChannelMember } from '../../../../hooks'; +import { useIsSelected } from '../../../../state-store/selection-store'; +import { primitives } from '../../../../theme'; +import { UserAvatar } from '../../../ui/Avatar/UserAvatar'; +import { SelectionCircle } from '../../../UIComponents/SelectionCircle'; + +export type AddMemberSearchResultItemProps = { + onPress: (user: UserResponse) => void; + user: UserResponse; +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const AddMemberSearchResultItem = ({ onPress, user }: AddMemberSearchResultItemProps) => { + const { t } = useTranslationContext(); + const { selectionStore } = useChannelAddMembersContext(); + const { channel } = useChannelDetailsContext(); + + const selected = useIsSelected(selectionStore, user.id); + const isAlreadyMember = useIsChannelMember(channel, user.id); + const { + theme: { + channelDetails: { + addMembers: { + searchResultItem: { + alreadyMemberInfo: alreadyMemberInfoOverride, + memberLabel: memberLabelOverride, + userName: userNameOverride, + userRow: userRowOverride, + }, + }, + }, + semantics, + }, + } = useTheme(); + const styles = useStyles(); + + const displayName = user.name ?? user.id; + const accessibilityLabel = isAlreadyMember + ? t('a11y/{{name}} is already a member', { name: displayName }) + : t('a11y/Select {{name}}', { name: displayName }); + + const avatar = <UserAvatar showOnlineIndicator={user.online} size='md' user={user} />; + const name = ( + <Text + numberOfLines={1} + style={[ + styles.userName, + { color: isAlreadyMember ? semantics.textSecondary : semantics.textPrimary }, + userNameOverride, + ]} + > + {displayName} + </Text> + ); + + if (isAlreadyMember) { + return ( + <View + accessibilityLabel={accessibilityLabel} + accessibilityState={{ disabled: true, selected: false }} + style={[styles.userRow, userRowOverride]} + testID={`channel-add-members-row-${user.id}`} + > + {avatar} + <View style={[styles.alreadyMemberInfo, alreadyMemberInfoOverride]}> + {name} + <Text + style={[styles.memberLabel, { color: semantics.textSecondary }, memberLabelOverride]} + testID={`channel-add-members-row-${user.id}-member-label`} + > + {t('Already a member')} + </Text> + </View> + </View> + ); + } + + return ( + <Pressable + accessibilityLabel={accessibilityLabel} + accessibilityRole='button' + accessibilityState={{ disabled: false, selected }} + onPress={() => onPress(user)} + style={[styles.userRow, userRowOverride]} + testID={`channel-add-members-row-${user.id}`} + > + {avatar} + {name} + <SelectionCircle selected={selected} /> + </Pressable> + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + alreadyMemberInfo: { + flex: 1, + flexDirection: 'column', + }, + memberLabel: { + fontSize: primitives.typographyFontSizeXs, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + userName: { + flex: 1, + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + userRow: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingSm, + minHeight: 52, + paddingHorizontal: primitives.spacingMd, + paddingVertical: primitives.spacingXs, + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetails/components/members/ChannelAddMembers.tsx b/package/src/components/ChannelDetails/components/members/ChannelAddMembers.tsx new file mode 100644 index 0000000000..d2117a1f56 --- /dev/null +++ b/package/src/components/ChannelDetails/components/members/ChannelAddMembers.tsx @@ -0,0 +1,148 @@ +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { ActivityIndicator, FlatList, type FlatListProps, StyleSheet, View } from 'react-native'; + +import type { SearchSourceState, UserResponse } from 'stream-chat'; + +import { AddMemberSearchResultItem } from './AddMemberSearchResultItem'; +import { UserListLoadingSkeleton } from './UserListLoadingSkeleton'; + +import { useChannelAddMembersContext } from '../../../../contexts/channelAddMembersContext/ChannelAddMembersContext'; +import { useChannelDetailsContext } from '../../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; +import { getNotificationErrorOptions } from '../../../../hooks/actions/useChannelActions'; +import { useStateStore } from '../../../../hooks/useStateStore'; +import { primitives } from '../../../../theme'; +import { useNotificationApi } from '../../../Notifications/hooks/useNotificationApi'; +import { EmptySearchResult } from '../../../UIComponents/EmptySearchResult'; +import { SearchInput } from '../../../UIComponents/SearchInput'; + +export type ChannelAddMembersProps = { + /** + * Besides the existing default behavior of the user list, you can attach + * additional props to the underlying React Native FlatList. + * + * See https://reactnative.dev/docs/flatlist#props for the full list. + */ + additionalFlatListProps?: Partial<FlatListProps<UserResponse>>; +}; + +const keyExtractor = (user: UserResponse) => user.id; + +const listStateSelector = (state: SearchSourceState<UserResponse>) => { + return { + users: state.items, + loading: state.isLoading, + hasNext: state.hasNext, + error: state.lastQueryError, + }; +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelAddMembers = ({ additionalFlatListProps }: ChannelAddMembersProps) => { + const { t } = useTranslationContext(); + const styles = useStyles(); + + const { channel } = useChannelDetailsContext(); + const { addNotification } = useNotificationApi(); + + const { searchSource, selectionStore } = useChannelAddMembersContext(); + const { users, loading, hasNext, error } = useStateStore(searchSource.state, listStateSelector); + + const initialized = useRef(false); + useEffect(() => { + if (!initialized.current) { + initialized.current = true; + searchSource.search(''); + } + }, [searchSource]); + + useEffect(() => { + if (!error) { + return; + } + addNotification({ + message: t('Failed to load users'), + options: { + ...getNotificationErrorOptions(error), + severity: 'error', + type: 'api:channel:query-users:failed', + }, + origin: { context: { channel }, emitter: 'AddChannelMembers' }, + }); + }, [error, addNotification, channel, t]); + + const select = useCallback( + (user: UserResponse) => { + selectionStore.toggle(user.id); + }, + [selectionStore], + ); + + const renderItem = useCallback( + ({ item }: { item: UserResponse }) => ( + <AddMemberSearchResultItem onPress={select} user={item} /> + ), + [select], + ); + + const loadMore = useCallback(() => { + // hasNext true by default, !!users prevents calling search on initial load + if (hasNext && !!users) { + searchSource.search(); + } + }, [hasNext, searchSource, users]); + + const emptyState = loading ? ( + <UserListLoadingSkeleton /> + ) : ( + <EmptySearchResult label={t('No user found')} /> + ); + + const loadingMoreIndicator = <>{loading && users && users.length > 0 && <ActivityIndicator />}</>; + + return ( + <View style={styles.container}> + <SearchInput + accessibilityLabel={t('a11y/Search users to add')} + onChangeText={(text) => searchSource.search(text)} + /> + + <FlatList + contentContainerStyle={styles.listContent} + data={users} + keyboardDismissMode='interactive' + keyboardShouldPersistTaps='handled' + keyExtractor={keyExtractor} + ListEmptyComponent={emptyState} + ListFooterComponent={loadingMoreIndicator} + onEndReached={loadMore} + onEndReachedThreshold={0.2} + renderItem={renderItem} + style={styles.list} + testID='channel-add-members-list' + {...additionalFlatListProps} + /> + </View> + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + container: { + flex: 1, + }, + list: { + flex: 1, + }, + listContent: { + flexGrow: 1, + paddingBottom: primitives.spacingXl, + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetails/components/members/ChannelAddMembersModal.tsx b/package/src/components/ChannelDetails/components/members/ChannelAddMembersModal.tsx new file mode 100644 index 0000000000..48c47b8972 --- /dev/null +++ b/package/src/components/ChannelDetails/components/members/ChannelAddMembersModal.tsx @@ -0,0 +1,126 @@ +import React, { useCallback, useState } from 'react'; + +import { ActivityIndicator } from 'react-native'; + +import { + ChannelAddMembersProvider, + useChannelAddMembersContext, +} from '../../../../contexts/channelAddMembersContext/ChannelAddMembersContext'; +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 { useChannelActions } from '../../../../hooks/actions/useChannelActions'; +import { useStableCallback } from '../../../../hooks/useStableCallback'; +import { Checkmark } from '../../../../icons/checkmark-1'; +import { useIsSelectionEmpty } from '../../../../state-store/selection-store'; +import { NotificationList } from '../../../Notifications/NotificationList'; +import { NotificationTargetProvider } from '../../../Notifications/NotificationTargetContext'; +import { Button } from '../../../ui/Button/Button'; +import { ChannelDetailsModal } from '../modal/Modal'; +import { ModalHeader } from '../modal/ModalHeader'; + +const loadingIconStyle = { margin: 0 }; +const LoadingButtonIcon = ({ height, width }: { height?: number; width?: number }) => ( + <ActivityIndicator style={{ ...loadingIconStyle, ...{ height, width } }} /> +); + +export type ChannelAddMembersModalProps = { + onClose: () => void; + visible: boolean; +}; + +type ChannelAddMembersModalContentProps = { + onClose: () => void; +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelAddMembersModalContent = ({ onClose }: ChannelAddMembersModalContentProps) => { + const { channel } = useChannelDetailsContext(); + const notificationHostId = channel?.cid ? `channel-add-members:${channel.cid}` : undefined; + + if (!notificationHostId) { + return null; + } + + return ( + <ChannelAddMembersProvider> + <NotificationTargetProvider hostId={notificationHostId} panel='channel-details'> + <ChannelAddMembersModalBody onClose={onClose} /> + </NotificationTargetProvider> + </ChannelAddMembersProvider> + ); +}; + +const ChannelAddMembersModalBody = ({ onClose }: ChannelAddMembersModalContentProps) => { + const { channel } = useChannelDetailsContext(); + const { addMembers } = useChannelActions(channel); + const { ChannelAddMembers } = useComponentsContext(); + const { selectionStore } = useChannelAddMembersContext(); + const { t } = useTranslationContext(); + const { + theme: { + channelDetails: { + memberSection: { confirmButton: confirmButtonOverride }, + }, + }, + } = useTheme(); + const isSelectionEmpty = useIsSelectionEmpty(selectionStore); + const [addingMembers, setAddingMembers] = useState(false); + const confirmEnabled = !isSelectionEmpty && !addingMembers; + + const handleClose = useCallback(() => { + onClose(); + }, [onClose]); + + const handleConfirm = useStableCallback(async () => { + setAddingMembers(true); + try { + const ids = Array.from(selectionStore.state.getLatestValue().selectedIds); + await addMembers(ids, { + onSuccess: () => { + onClose(); + }, + }); + } finally { + setAddingMembers(false); + } + }); + + return ( + <> + <ModalHeader + onClose={handleClose} + rightAction={ + <Button + accessibilityLabel={t('a11y/Confirm add members')} + accessibilityRole='button' + accessibilityState={{ busy: addingMembers, disabled: !confirmEnabled }} + disabled={!confirmEnabled} + variant='primary' + onPress={handleConfirm} + type='solid' + LeadingIcon={addingMembers ? LoadingButtonIcon : Checkmark} + iconOnly + testID='channel-details-add-members-confirm-button' + style={confirmButtonOverride} + /> + } + title={t('Add Members')} + /> + <ChannelAddMembers /> + <NotificationList /> + </> + ); +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelAddMembersModal = ({ onClose, visible }: ChannelAddMembersModalProps) => ( + <ChannelDetailsModal onClose={onClose} visible={visible}> + <ChannelAddMembersModalContent onClose={onClose} /> + </ChannelDetailsModal> +); diff --git a/package/src/components/ChannelDetails/components/members/ChannelAllMembersModal.tsx b/package/src/components/ChannelDetails/components/members/ChannelAllMembersModal.tsx new file mode 100644 index 0000000000..1d269763ba --- /dev/null +++ b/package/src/components/ChannelDetails/components/members/ChannelAllMembersModal.tsx @@ -0,0 +1,92 @@ +import React from 'react'; + +import { useChannelDetailsContext } from '../../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext'; +import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; +import { useChannelMemberCount } from '../../../../hooks'; +import { useChannelOwnCapabilities } from '../../../../hooks/useChannelOwnCapabilities'; +import { UserAdd } from '../../../../icons/user-add'; +import { NotificationList } from '../../../Notifications/NotificationList'; +import { NotificationTargetProvider } from '../../../Notifications/NotificationTargetContext'; +import { Button } from '../../../ui/Button/Button'; +import { ChannelDetailsModal } from '../modal/Modal'; +import { ModalHeader } from '../modal/ModalHeader'; + +export type ChannelAllMembersModalProps = { + onAddMembersPress: () => void; + onClose: () => void; + visible: boolean; +}; + +type ChannelAllMembersModalContentProps = Omit<ChannelAllMembersModalProps, 'visible'>; + +const ChannelAllMembersModalBody = ({ + onAddMembersPress, + onClose, +}: ChannelAllMembersModalContentProps) => { + const { channel } = useChannelDetailsContext(); + const { ChannelMemberList } = useComponentsContext(); + const { t } = useTranslationContext(); + const ownCapabilities = useChannelOwnCapabilities(channel); + const updateChannelMembers = ownCapabilities?.includes('update-channel-members') ?? false; + const total = useChannelMemberCount(channel); + + return ( + <> + <ModalHeader + onClose={onClose} + rightAction={ + updateChannelMembers ? ( + <Button + accessibilityLabelKey='a11y/Add members' + iconOnly + LeadingIcon={UserAdd} + onPress={onAddMembersPress} + size='md' + testID='channel-details-member-list-add-button' + type='outline' + variant='secondary' + /> + ) : null + } + title={t('{{count}} members', { count: total })} + /> + <ChannelMemberList /> + <NotificationList /> + </> + ); +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelAllMembersModalContent = ({ + onAddMembersPress, + onClose, +}: ChannelAllMembersModalContentProps) => { + const { channel } = useChannelDetailsContext(); + const notificationHostId = channel?.cid ? `channel-member-list:${channel.cid}` : undefined; + + if (!notificationHostId) { + return null; + } + + return ( + <NotificationTargetProvider hostId={notificationHostId} panel='channel-details'> + <ChannelAllMembersModalBody onAddMembersPress={onAddMembersPress} onClose={onClose} /> + </NotificationTargetProvider> + ); +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelAllMembersModal = ({ + onAddMembersPress, + onClose, + visible, +}: ChannelAllMembersModalProps) => ( + <ChannelDetailsModal onClose={onClose} visible={visible}> + <ChannelAllMembersModalContent onAddMembersPress={onAddMembersPress} onClose={onClose} /> + </ChannelDetailsModal> +); diff --git a/package/src/components/ChannelDetails/components/members/ChannelMemberActionsSheet.tsx b/package/src/components/ChannelDetails/components/members/ChannelMemberActionsSheet.tsx new file mode 100644 index 0000000000..608be3147c --- /dev/null +++ b/package/src/components/ChannelDetails/components/members/ChannelMemberActionsSheet.tsx @@ -0,0 +1,105 @@ +import React, { useCallback, useMemo } from 'react'; +import { ListRenderItem, StyleSheet, View } from 'react-native'; + +import type { ChannelMemberResponse } from 'stream-chat'; + +import { useChannelDetailsContext } from '../../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { + ChannelMemberActionItem, + useChannelMemberActionItems, +} from '../../../../hooks/actions/useChannelMemberActionItems'; +import { BottomSheetModal } from '../../../UIComponents/BottomSheetModal'; +import { StreamBottomSheetModalFlatList } from '../../../UIComponents/StreamBottomSheetModalFlatList'; + +export type ChannelMemberActionsSheetProps = { + member: ChannelMemberResponse; + onClose: () => void; + visible: boolean; +}; + +const keyExtractor = (item: ChannelMemberActionItem) => item.id; + +const ChannelMemberActionsSheetInner = ({ + member, + onClose, +}: Omit<ChannelMemberActionsSheetProps, 'visible'>) => { + const { channel, getChannelMemberActionItems } = useChannelDetailsContext(); + const { ChannelDetailsActionItem, ChannelMemberItem } = useComponentsContext(); + const { + theme: { + channelDetails: { + memberActionsSheet: { + actionsList: actionsListOverride, + container: containerOverride, + header: headerOverride, + }, + }, + }, + } = useTheme(); + const styles = useStyles(); + + const actionItems = useChannelMemberActionItems({ + channel, + getChannelMemberActionItems, + member, + }); + + const renderItem = useCallback<ListRenderItem<ChannelMemberActionItem>>( + ({ item }) => ( + <ChannelDetailsActionItem + destructive={item.type === 'destructive'} + Icon={item.Icon} + label={item.label} + onPress={() => { + item.action(); + onClose(); + }} + testID={`channel-details-member-action-${item.id}`} + /> + ), + [ChannelDetailsActionItem, onClose], + ); + + if (!member.user) return null; + + return ( + <View style={[styles.container, containerOverride]}> + <View style={headerOverride}> + <ChannelMemberItem member={member} size='lg' /> + </View> + <StreamBottomSheetModalFlatList<ChannelMemberActionItem> + contentContainerStyle={[styles.actionsList, actionsListOverride]} + data={actionItems} + keyExtractor={keyExtractor} + renderItem={renderItem} + /> + </View> + ); +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelMemberActionsSheet = ({ + member, + onClose, + visible, +}: ChannelMemberActionsSheetProps) => ( + <BottomSheetModal enableDynamicSizing onClose={onClose} visible={visible}> + <ChannelMemberActionsSheetInner member={member} onClose={onClose} /> + </BottomSheetModal> +); + +const useStyles = () => + useMemo( + () => + StyleSheet.create({ + actionsList: {}, + container: { + flexDirection: 'column', + }, + }), + [], + ); diff --git a/package/src/components/ChannelDetails/components/members/ChannelMemberItem.tsx b/package/src/components/ChannelDetails/components/members/ChannelMemberItem.tsx new file mode 100644 index 0000000000..7d0c9f3adc --- /dev/null +++ b/package/src/components/ChannelDetails/components/members/ChannelMemberItem.tsx @@ -0,0 +1,224 @@ +import React, { useMemo } from 'react'; +import { I18nManager, Pressable, StyleSheet, Text, View } from 'react-native'; + +import type { ChannelMemberResponse } from 'stream-chat'; + +import { composeAccessibilityLabel } from '../../../../a11y/a11yUtils'; +import { useChatContext } from '../../../../contexts/chatContext/ChatContext'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; +import { Mute } from '../../../../icons'; +import { primitives } from '../../../../theme'; +import { useUserMuteActive } from '../../../Message/hooks/useUserMuteActive'; +import { UserAvatar } from '../../../ui/Avatar/UserAvatar'; +import { useMemberRoleLabel } from '../../hooks/members/useMemberRoleLabel'; +import { useUserActivityStatus } from '../../hooks/useUserActivityStatus'; + +export type ChannelMemberItemSize = 'sm' | 'lg'; + +export type ChannelMemberItemProps = { + member: ChannelMemberResponse; + onPress?: (member: ChannelMemberResponse) => void; + /** + * Visual size of the row. + * - `'sm'` (default) renders the compact list row with a small avatar, regular-weight name, and a trailing role label. + * - `'lg'` renders a profile-style header with a larger avatar, semibold name, larger status, and no role label. + * Useful for sheet / modal headers (e.g. the per-member actions bottom sheet). + */ + size?: ChannelMemberItemSize; + testID?: string; +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelMemberItem = ({ + member, + onPress, + size = 'sm', + testID, +}: ChannelMemberItemProps) => { + const { t } = useTranslationContext(); + const { client } = useChatContext(); + const { + theme: { + channelDetails: { + memberItem: { + container: containerOverride, + name: nameOverride, + role: roleOverride, + status: statusOverride, + }, + }, + semantics, + }, + } = useTheme(); + const styles = useStyles(); + const statusLine = useUserActivityStatus(member.user); + const roleLabel = useMemberRoleLabel(member); + const isMuted = useUserMuteActive(member.user); + + const user = member.user; + if (!user) return null; + + const isLarge = size === 'lg'; + const isCurrentUser = !!client?.userID && user.id === client.userID; + const displayName = isCurrentUser ? t('You') : (user.name ?? user.id); + const accessibilityLabel = composeAccessibilityLabel( + displayName, + roleLabel, + isMuted ? t('Muted') : undefined, + statusLine, + ); + + const content = ( + <> + <UserAvatar showOnlineIndicator={user.online} size={isLarge ? 'lg' : 'md'} user={user} /> + <View style={styles.body}> + <Text + numberOfLines={1} + style={[ + isLarge ? styles.nameLarge : styles.name, + { color: semantics.textPrimary }, + nameOverride, + ]} + > + {displayName} + </Text> + {statusLine ? ( + <Text + numberOfLines={1} + style={[ + isLarge ? styles.statusLarge : styles.status, + { color: semantics.textTertiary }, + statusOverride, + ]} + > + {statusLine} + </Text> + ) : null} + </View> + {isMuted || roleLabel ? ( + <View style={styles.trailing}> + {isMuted ? ( + <Mute + height={16} + pathFill={semantics.textTertiary} + testID='channel-member-muted-indicator' + width={16} + /> + ) : null} + {roleLabel ? ( + <Text + numberOfLines={1} + style={[styles.role, { color: semantics.textTertiary }, roleOverride]} + > + {roleLabel} + </Text> + ) : null} + </View> + ) : null} + </> + ); + + if (onPress) { + return ( + <Pressable + accessibilityLabel={accessibilityLabel} + accessibilityRole='button' + onPress={() => onPress(member)} + style={({ pressed }) => [ + isLarge ? styles.containerLarge : styles.container, + pressed + ? { + backgroundColor: semantics.backgroundUtilityPressed, + borderRadius: primitives.radiusLg, + } + : null, + containerOverride, + ]} + testID={testID} + > + {content} + </Pressable> + ); + } + + return ( + <View + accessibilityLabel={accessibilityLabel} + accessibilityRole={isLarge ? 'header' : undefined} + accessible + style={[isLarge ? styles.containerLarge : styles.container, containerOverride]} + testID={testID} + > + {content} + </View> + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + body: { + flex: 1, + gap: 0, + minWidth: 0, + }, + container: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingSm, + minHeight: 48, + paddingHorizontal: primitives.spacingMd, + paddingVertical: primitives.spacingXs, + }, + containerLarge: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingSm, + paddingHorizontal: primitives.spacingSm, + paddingVertical: primitives.spacingSm, + }, + name: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + nameLarge: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + role: { + flexShrink: 0, + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + status: { + fontSize: primitives.typographyFontSizeXs, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightTight, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + statusLarge: { + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + trailing: { + alignItems: 'center', + flexDirection: 'row', + flexShrink: 0, + gap: primitives.spacingXs, + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetails/components/members/ChannelMemberList.tsx b/package/src/components/ChannelDetails/components/members/ChannelMemberList.tsx new file mode 100644 index 0000000000..7122fac470 --- /dev/null +++ b/package/src/components/ChannelDetails/components/members/ChannelMemberList.tsx @@ -0,0 +1,83 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { ActivityIndicator, FlatList, type FlatListProps } from 'react-native'; + +import type { ChannelMemberResponse } from 'stream-chat'; + +import { MemberListLoadingSkeleton } from './MemberListLoadingSkeleton'; + +import { useChannelDetailsContext } from '../../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext'; +import { useChannelAllMembers } from '../../hooks/members/useChannelAllMembers'; + +const keyExtractor = (member: ChannelMemberResponse) => member.user?.id ?? member.user_id ?? ''; + +export type ChannelMemberListProps = { + /** + * Besides the existing default behavior of the members list, you can attach + * additional props to the underlying React Native FlatList. + * + * See https://reactnative.dev/docs/flatlist#props for the full list. + */ + additionalFlatListProps?: Partial<FlatListProps<ChannelMemberResponse>>; +}; + +/** + * Lists all channel members. + * @experimental This component is experimental and is subject to change. + */ +export const ChannelMemberList = ({ additionalFlatListProps }: ChannelMemberListProps = {}) => { + const { channel, onMemberPress } = useChannelDetailsContext(); + const { ChannelMemberActionsSheet, ChannelMemberItem } = useComponentsContext(); + const { hasMore, loading, loadMore, results } = useChannelAllMembers({ channel }); + const [selectedMember, setSelectedMember] = useState<ChannelMemberResponse | null>(null); + + const handleMemberActionsClose = useCallback(() => setSelectedMember(null), []); + + const handleMemberPress = useCallback( + (member: ChannelMemberResponse) => { + if (onMemberPress) { + onMemberPress(member); + return; + } + setSelectedMember(member); + }, + [onMemberPress], + ); + + const renderItem = useCallback( + ({ item }: { item: ChannelMemberResponse }) => ( + <ChannelMemberItem member={item} onPress={handleMemberPress} /> + ), + [ChannelMemberItem, handleMemberPress], + ); + + const ListFooterComponent = useMemo( + () => (loading && results.length > 0 ? <ActivityIndicator /> : null), + [loading, results.length], + ); + + if (loading && results.length === 0) { + return <MemberListLoadingSkeleton />; + } + + return ( + <> + <FlatList + data={results} + keyExtractor={keyExtractor} + ListFooterComponent={ListFooterComponent} + onEndReached={hasMore ? loadMore : undefined} + onEndReachedThreshold={0.2} + renderItem={renderItem} + {...additionalFlatListProps} + /> + {selectedMember ? ( + <ChannelMemberActionsSheet + member={selectedMember} + onClose={handleMemberActionsClose} + visible + /> + ) : null} + </> + ); +}; diff --git a/package/src/components/ChannelDetails/components/members/MemberListLoadingSkeleton.tsx b/package/src/components/ChannelDetails/components/members/MemberListLoadingSkeleton.tsx new file mode 100644 index 0000000000..e3fafd7d7e --- /dev/null +++ b/package/src/components/ChannelDetails/components/members/MemberListLoadingSkeleton.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { GenericListLoadingSkeleton } from '../../../UIComponents/GenericListLoadingSkeleton'; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const MemberListLoadingSkeleton = () => { + const { + theme: { memberListSkeleton }, + } = useTheme(); + + return ( + <GenericListLoadingSkeleton + skeleton={memberListSkeleton} + testID='member-list-loading-skeleton' + /> + ); +}; + +MemberListLoadingSkeleton.displayName = 'MemberListLoadingSkeleton{memberListSkeleton}'; diff --git a/package/src/components/ChannelDetails/components/members/UserListLoadingSkeleton.tsx b/package/src/components/ChannelDetails/components/members/UserListLoadingSkeleton.tsx new file mode 100644 index 0000000000..29310a0501 --- /dev/null +++ b/package/src/components/ChannelDetails/components/members/UserListLoadingSkeleton.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { GenericListLoadingSkeleton } from '../../../UIComponents/GenericListLoadingSkeleton'; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const UserListLoadingSkeleton = () => { + const { + theme: { userListSkeleton }, + } = useTheme(); + + return ( + <GenericListLoadingSkeleton skeleton={userListSkeleton} testID='user-list-loading-skeleton' /> + ); +}; + +UserListLoadingSkeleton.displayName = 'UserListLoadingSkeleton{userListSkeleton}'; diff --git a/package/src/components/ChannelDetails/components/members/index.ts b/package/src/components/ChannelDetails/components/members/index.ts new file mode 100644 index 0000000000..a5d516144d --- /dev/null +++ b/package/src/components/ChannelDetails/components/members/index.ts @@ -0,0 +1,9 @@ +export * from './AddMemberSearchResultItem'; +export * from './ChannelAddMembers'; +export * from './ChannelAddMembersModal'; +export * from './ChannelAllMembersModal'; +export * from './ChannelMemberActionsSheet'; +export * from './ChannelMemberItem'; +export * from './ChannelMemberList'; +export * from './MemberListLoadingSkeleton'; +export * from './UserListLoadingSkeleton'; diff --git a/package/src/components/ChannelDetails/components/modal/Modal.tsx b/package/src/components/ChannelDetails/components/modal/Modal.tsx new file mode 100644 index 0000000000..e1393307c0 --- /dev/null +++ b/package/src/components/ChannelDetails/components/modal/Modal.tsx @@ -0,0 +1,69 @@ +import React, { useMemo } from 'react'; +import { Modal } from 'react-native'; + +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; + +type ChannelDetailsModalProps = { + children: React.ReactNode; + onClose: () => void; + visible: boolean; + presentationStyle?: 'pageSheet' | 'formSheet' | 'fullScreen'; +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ChannelDetailsModal = ({ + children, + onClose, + visible, + presentationStyle = 'pageSheet', +}: ChannelDetailsModalProps) => { + const { + theme: { + channelDetails: { + modal: { body: bodyOverride }, + }, + }, + } = useTheme(); + const styles = useStyles(); + const { top } = useSafeAreaInsets(); + + return ( + <Modal + animationType='slide' + onRequestClose={onClose} + presentationStyle={presentationStyle} + visible={visible} + > + <GestureHandlerRootView + style={[ + styles.body, + presentationStyle === 'fullScreen' ? { paddingTop: top } : {}, + bodyOverride, + ]} + > + {children} + </GestureHandlerRootView> + </Modal> + ); +}; + +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + + return useMemo( + () => ({ + body: { + flex: 1, + backgroundColor: semantics.backgroundCoreElevation1, + }, + }), + [semantics], + ); +}; diff --git a/package/src/components/ChannelDetails/components/modal/ModalHeader.tsx b/package/src/components/ChannelDetails/components/modal/ModalHeader.tsx new file mode 100644 index 0000000000..c31bee428a --- /dev/null +++ b/package/src/components/ChannelDetails/components/modal/ModalHeader.tsx @@ -0,0 +1,96 @@ +import React, { useMemo } from 'react'; +import { I18nManager, StyleSheet, Text, View } from 'react-native'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { Cross } from '../../../../icons/xmark-1'; +import { primitives } from '../../../../theme'; +import { Button, ButtonProps } from '../../../ui/Button/Button'; + +type ModalHeaderProps = { + additionalCloseButtonProps?: Partial<ButtonProps>; + onClose: () => void; + title: string; + rightAction?: React.ReactNode; +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const ModalHeader = ({ + onClose, + rightAction, + title, + additionalCloseButtonProps, +}: ModalHeaderProps) => { + const { + theme: { + channelDetails: { + modal: { header: headerOverride, headerTitle: headerTitleOverride }, + }, + semantics, + }, + } = useTheme(); + const styles = useStyles(); + + return ( + <View style={[styles.header, headerOverride]}> + <View style={styles.side}> + <Button + accessibilityLabelKey='a11y/Close' + iconOnly + LeadingIcon={Cross} + onPress={onClose} + size='md' + type='outline' + variant='secondary' + {...additionalCloseButtonProps} + /> + </View> + <View style={styles.center}> + <Text + accessibilityRole='header' + numberOfLines={1} + style={[styles.title, { color: semantics.textPrimary }, headerTitleOverride]} + > + {title} + </Text> + </View> + <View style={[styles.side, styles.sideRight]}>{rightAction}</View> + </View> + ); +}; + +const useStyles = () => + useMemo( + () => + StyleSheet.create({ + center: { + alignItems: 'center', + flex: 2, + justifyContent: 'center', + }, + header: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + paddingBottom: primitives.spacingSm, + paddingHorizontal: primitives.spacingSm, + paddingTop: primitives.spacingSm, + gap: primitives.spacingSm, + }, + side: { + flex: 1, + justifyContent: 'center', + }, + sideRight: { + alignItems: 'flex-end', + }, + title: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + }), + [], + ); diff --git a/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentItem.tsx b/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentItem.tsx new file mode 100644 index 0000000000..bf03432ef7 --- /dev/null +++ b/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentItem.tsx @@ -0,0 +1,92 @@ +import React, { useMemo } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; + +import { type Attachment, type MessageResponse } from 'stream-chat'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../../../theme'; +import { FilePreview } from '../../../Attachment/FilePreview'; +import { openUrlSafely } from '../../../Attachment/utils/openUrlSafely'; + +/** + * The shape passed to `FileAttachmentItem`'s `onPress` callback, identifying the rendered + * attachment and the message it belongs to. + * + * @experimental This type is experimental and is subject to change. + */ +export type FileAttachmentItemPressParams = { + attachment: Attachment; + message: MessageResponse; +}; + +export type FileAttachmentItemProps = { + /** The file/audio attachment to render. */ + attachment: Attachment; + /** The message the attachment belongs to. */ + message: MessageResponse; + /** + * Fired with the pressed attachment and its message. When provided, this overrides the default + * behavior of opening the attachment's `asset_url`. + */ + onPress?: (params: FileAttachmentItemPressParams) => void; +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const FileAttachmentItem = (props: FileAttachmentItemProps) => { + const { attachment, message, onPress } = props; + const { + theme: { + channelDetails: { fileAttachmentItem }, + }, + } = useTheme(); + const styles = useStyles(); + + return ( + <View + style={[styles.container, fileAttachmentItem.container]} + testID={`file-attachment-item-${message.id}`} + > + <Pressable + accessibilityRole='button' + onPress={() => + onPress ? onPress({ attachment, message }) : openUrlSafely(attachment.asset_url) + } + testID={`file-attachment-row-${message.id}`} + > + <FilePreview attachment={attachment} styles={styles.filePreview} /> + </Pressable> + </View> + ); +}; + +FileAttachmentItem.displayName = 'FileAttachmentItem{fileAttachmentItem}'; + +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + + return useMemo( + () => ({ + ...StyleSheet.create({ + container: { + gap: primitives.spacingXxs, + paddingHorizontal: primitives.spacingSm, + paddingVertical: primitives.spacingXs, + }, + }), + // FilePreview's default container has a hardcoded width; stretch it to the row width. + filePreview: StyleSheet.create({ + container: { width: '100%' }, + title: { + fontWeight: primitives.typographyFontWeightRegular, + fontSize: primitives.typographyFontSizeMd, + }, + size: { color: semantics.textTertiary }, + }), + }), + [semantics], + ); +}; diff --git a/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentList.tsx b/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentList.tsx new file mode 100644 index 0000000000..9950a787cb --- /dev/null +++ b/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentList.tsx @@ -0,0 +1,204 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + SectionList, + type SectionListProps, + StyleSheet, + View, +} from 'react-native'; + +import type { MessageResponse, SearchSourceState } from 'stream-chat'; + +import { FileAttachmentListLoadingSkeleton } from './FileAttachmentListLoadingSkeleton'; +import { FileAttachmentListSectionHeader } from './FileAttachmentListSectionHeader'; + +import { useChannelDetailsContext } from '../../../../contexts/channelDetailsContext/channelDetailsContext'; +import { + ChannelFileAttachmentListProvider, + useChannelFileAttachmentListContext, +} from '../../../../contexts/channelFileAttachmentListContext/ChannelFileAttachmentListContext'; +import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; +import { getNotificationErrorOptions } from '../../../../hooks/actions/useChannelActions'; +import { useStateStore } from '../../../../hooks/useStateStore'; +import { Folder } from '../../../../icons/folder'; +import { primitives } from '../../../../theme'; +import { useNotificationApi } from '../../../Notifications/hooks/useNotificationApi'; +import { NotificationList } from '../../../Notifications/NotificationList'; +import { NotificationTargetProvider } from '../../../Notifications/NotificationTargetContext'; +import { EmptyList } from '../../../UIComponents/EmptyList'; +import { + type FileAttachmentSection, + type FileAttachmentTile, + useFileAttachmentListSections, +} from '../../hooks/useFileAttachmentListSections'; + +export type FileAttachmentListProps = { + /** + * Besides the existing default behavior of the file attachment list, you can attach + * additional props to the underlying React Native SectionList. + * + * See https://reactnative.dev/docs/sectionlist#props for the full list. + */ + additionalSectionListProps?: Partial<SectionListProps<FileAttachmentTile, FileAttachmentSection>>; +}; + +const keyExtractor = (item: FileAttachmentTile, index: number) => `${item.message.id}-${index}`; + +const listStateSelector = (state: SearchSourceState<MessageResponse>) => ({ + error: state.lastQueryError, + hasNext: state.hasNext, + loading: state.isLoading, + messages: state.items, +}); + +const FileAttachmentListContent = ({ additionalSectionListProps }: FileAttachmentListProps) => { + const { t } = useTranslationContext(); + const { + theme: { + channelDetails: { fileAttachmentList }, + }, + } = useTheme(); + const styles = useStyles(); + const { FileAttachmentItem } = useComponentsContext(); + + const { addNotification } = useNotificationApi(); + + const { channel, searchSource } = useChannelFileAttachmentListContext(); + const { error, hasNext, loading, messages } = useStateStore( + searchSource.state, + listStateSelector, + ); + + const initialized = useRef(false); + useEffect(() => { + if (!initialized.current) { + initialized.current = true; + searchSource.search(); + } + }, [searchSource]); + + const [isEmpty, setIsEmpty] = useState<boolean | undefined>(undefined); + useEffect(() => { + if (!messages || isEmpty !== undefined) { + return; + } + if (messages.length === 0) { + setIsEmpty(true); + } else { + setIsEmpty(false); + } + }, [isEmpty, messages]); + + useEffect(() => { + if (!error) { + return; + } + addNotification({ + message: t('Failed to load files'), + options: { + ...getNotificationErrorOptions(error), + severity: 'error', + type: 'api:channel:query-file-attachments:failed', + }, + origin: { context: { channel }, emitter: 'ChannelFileAttachmentList' }, + }); + }, [error, addNotification, channel, t]); + + const sections = useFileAttachmentListSections(messages); + + const renderItem = useCallback( + ({ item }: { item: FileAttachmentTile }) => ( + <FileAttachmentItem attachment={item.attachment} message={item.message} /> + ), + [FileAttachmentItem], + ); + + const renderSectionHeader = useCallback( + ({ section }: { section: FileAttachmentSection }) => ( + <FileAttachmentListSectionHeader title={section.title} /> + ), + [], + ); + + const loadMore = useCallback(() => { + // hasNext is true by default, !!messages prevents calling search on initial load + if (hasNext && !!messages) { + searchSource.search(); + } + }, [hasNext, messages, searchSource]); + + const emptyState = + loading || isEmpty === undefined ? ( + <FileAttachmentListLoadingSkeleton /> + ) : ( + <EmptyList icon={Folder} subtitle={t('Share a file to see it here')} title={t('No files')} /> + ); + + const loadingMoreIndicator = ( + <>{loading && messages && messages.length > 0 && <ActivityIndicator />}</> + ); + + return ( + <View style={[styles.container, fileAttachmentList.container]}> + <SectionList + contentContainerStyle={[styles.listContent, fileAttachmentList.listContent]} + keyboardDismissMode='interactive' + keyboardShouldPersistTaps='handled' + keyExtractor={keyExtractor} + ListEmptyComponent={emptyState} + ListFooterComponent={loadingMoreIndicator} + onEndReached={loadMore} + onEndReachedThreshold={0.2} + renderItem={renderItem} + renderSectionHeader={renderSectionHeader} + sections={sections} + stickySectionHeadersEnabled + style={[styles.list, fileAttachmentList.list]} + testID='file-attachment-list' + {...additionalSectionListProps} + /> + <NotificationList /> + </View> + ); +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const FileAttachmentList = (props: FileAttachmentListProps) => { + const { channel } = useChannelDetailsContext(); + const notificationHostId = channel?.cid ? `file-attachment-list:${channel.cid}` : undefined; + + if (!notificationHostId) { + return null; + } + + return ( + <ChannelFileAttachmentListProvider channel={channel}> + <NotificationTargetProvider hostId={notificationHostId} panel='channel-details'> + <FileAttachmentListContent {...props} /> + </NotificationTargetProvider> + </ChannelFileAttachmentListProvider> + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + container: { + flex: 1, + }, + list: { + flex: 1, + }, + listContent: { + flexGrow: 1, + paddingBottom: primitives.spacingXl, + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentListLoadingSkeleton.tsx b/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentListLoadingSkeleton.tsx new file mode 100644 index 0000000000..43b2fa4fd7 --- /dev/null +++ b/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentListLoadingSkeleton.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { GenericListLoadingSkeleton } from '../../../UIComponents/GenericListLoadingSkeleton'; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const FileAttachmentListLoadingSkeleton = () => { + const { + theme: { fileAttachmentListSkeleton }, + } = useTheme(); + + return ( + <GenericListLoadingSkeleton + skeleton={fileAttachmentListSkeleton} + testID='file-attachment-list-loading-skeleton' + /> + ); +}; + +FileAttachmentListLoadingSkeleton.displayName = + 'FileAttachmentListLoadingSkeleton{fileAttachmentListSkeleton}'; diff --git a/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentListSectionHeader.tsx b/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentListSectionHeader.tsx new file mode 100644 index 0000000000..17f086f399 --- /dev/null +++ b/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentListSectionHeader.tsx @@ -0,0 +1,56 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../../../theme'; + +export type FileAttachmentListSectionHeaderProps = { + /** The already-formatted section title (e.g. "March 2026"). */ + title: string; +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const FileAttachmentListSectionHeader = ({ + title, +}: FileAttachmentListSectionHeaderProps) => { + const { + theme: { + channelDetails: { fileAttachmentList }, + }, + } = useTheme(); + const styles = useStyles(); + + return ( + <View style={[styles.container, fileAttachmentList.sectionHeader]}> + <Text style={[styles.text, fileAttachmentList.sectionHeaderText]}>{title}</Text> + </View> + ); +}; + +FileAttachmentListSectionHeader.displayName = 'FileAttachmentListSectionHeader{fileAttachmentList}'; + +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + + return useMemo( + () => + StyleSheet.create({ + container: { + backgroundColor: semantics.backgroundCoreSurfaceSubtle, + paddingHorizontal: primitives.spacingMd, + paddingVertical: primitives.spacingXs, + }, + text: { + color: semantics.chatTextSystem, + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + }, + }), + [semantics], + ); +}; diff --git a/package/src/components/ChannelDetails/components/navigation-section/MediaItem.tsx b/package/src/components/ChannelDetails/components/navigation-section/MediaItem.tsx new file mode 100644 index 0000000000..24e7a26eb6 --- /dev/null +++ b/package/src/components/ChannelDetails/components/navigation-section/MediaItem.tsx @@ -0,0 +1,160 @@ +import React, { useMemo, useRef } from 'react'; +import { findNodeHandle, Pressable, StyleSheet, Text, View } from 'react-native'; + +import type { Attachment, MessageResponse } from 'stream-chat'; + +import { useChatConfigContext } from '../../../../contexts/chatConfigContext/ChatConfigContext'; +import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { Recorder } from '../../../../icons/video-fill'; +import { primitives } from '../../../../theme'; +import { FileTypes } from '../../../../types/types'; +import { getResizedImageUrl } from '../../../../utils/getResizedImageUrl'; +import { getUrlOfImageAttachment } from '../../../../utils/getUrlOfImageAttachment'; +import { getDurationLabelFromDuration } from '../../../../utils/utils'; +import { UserAvatar } from '../../../ui/Avatar/UserAvatar'; + +/** + * The shape passed to `MediaItem`'s `onPress` callback, identifying the rendered attachment, the + * message it belongs to, and the tile's native node handle (used as the open-animation origin when + * launching the fullscreen image gallery). + * + * @experimental This type is experimental and is subject to change. + */ +export type MediaItemPressParams = { + attachment: Attachment; + message: MessageResponse; + requesterNode: number | null; +}; + +export type MediaItemProps = { + /** The image/video attachment rendered by this tile. */ + attachment: Attachment; + /** The message the attachment belongs to. */ + message: MessageResponse; + /** Side length of the square tile, in points. */ + size: number; + /** + * Fired with the tile's attachment and message when the tile is pressed. The media list passes + * its gallery-opening handler here via `renderItem`. + */ + onPress?: (params: MediaItemPressParams) => void; +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const MediaItem = (props: MediaItemProps) => { + const { ImageComponent: Image } = useComponentsContext(); + const { attachment, message, onPress, size } = props; + const { + theme: { + channelDetails: { mediaItem }, + semantics, + }, + } = useTheme(); + const { resizableCDNHosts } = useChatConfigContext(); + const styles = useStyles(); + const containerRef = useRef<View>(null); + + const isVideo = attachment.type === FileTypes.Video; + const url = attachment.thumb_url || getUrlOfImageAttachment(attachment); + const thumbnailUrl = url + ? getResizedImageUrl({ height: size, resizableCDNHosts, url, width: size }) + : undefined; + const durationLabel = isVideo + ? getDurationLabelFromDuration(attachment.duration ?? 0) + : undefined; + + return ( + <Pressable + onPress={ + onPress + ? () => + onPress({ attachment, message, requesterNode: findNodeHandle(containerRef.current) }) + : undefined + } + ref={containerRef} + style={[ + styles.container, + { backgroundColor: semantics.backgroundCoreSurfaceStrong, width: size }, + mediaItem.container, + ]} + testID={`media-item-${message.id}`} + > + {thumbnailUrl ? ( + <Image + source={{ uri: thumbnailUrl }} + style={[styles.thumbnail, mediaItem.thumbnail]} + testID='media-item-thumbnail' + /> + ) : null} + {message.user ? ( + <UserAvatar size='sm' style={[styles.avatar, mediaItem.avatar]} user={message.user} /> + ) : null} + {isVideo ? ( + <View + style={[ + styles.videoBadge, + { backgroundColor: semantics.badgeBgInverse }, + mediaItem.videoBadge, + ]} + > + <Recorder fill={semantics.badgeTextOnInverse} size={12} /> + <Text + style={[ + styles.videoBadgeText, + { color: semantics.badgeTextOnInverse }, + mediaItem.videoBadgeText, + ]} + > + {durationLabel} + </Text> + </View> + ) : null} + </Pressable> + ); +}; + +MediaItem.displayName = 'MediaItem{mediaItem}'; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + avatar: { + left: primitives.spacingXs, + position: 'absolute', + top: primitives.spacingXs, + }, + container: { + aspectRatio: 1, + borderRadius: primitives.radiusXxs, + overflow: 'hidden', + }, + thumbnail: { + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, + videoBadge: { + alignItems: 'center', + borderRadius: primitives.radiusMax, + bottom: primitives.spacingXs, + flexDirection: 'row', + gap: primitives.spacingXxs, + left: primitives.spacingXs, + paddingHorizontal: primitives.spacingXs, + paddingVertical: primitives.spacingXxs, + position: 'absolute', + }, + videoBadgeText: { + fontSize: primitives.typographyFontSizeXxs, + fontWeight: primitives.typographyFontWeightBold, + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetails/components/navigation-section/MediaList.tsx b/package/src/components/ChannelDetails/components/navigation-section/MediaList.tsx new file mode 100644 index 0000000000..dd4c69bc58 --- /dev/null +++ b/package/src/components/ChannelDetails/components/navigation-section/MediaList.tsx @@ -0,0 +1,244 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + type FlatListProps, + StyleSheet, + useWindowDimensions, + View, +} from 'react-native'; + +import { formatMessage, type MessageResponse, type SearchSourceState } from 'stream-chat'; + +import { type MediaItemPressParams } from './MediaItem'; +import { MediaListLoadingSkeleton } from './MediaListLoadingSkeleton'; + +import { useChannelDetailsContext } from '../../../../contexts/channelDetailsContext/channelDetailsContext'; +import { + ChannelMediaListProvider, + useChannelMediaListContext, +} from '../../../../contexts/channelMediaListContext/ChannelMediaListContext'; +import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext'; +import { useImageGalleryContext } from '../../../../contexts/imageGalleryContext/ImageGalleryContext'; +import { useOverlayContext } from '../../../../contexts/overlayContext/OverlayContext'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; +import { getNotificationErrorOptions } from '../../../../hooks/actions/useChannelActions'; +import { useStateStore } from '../../../../hooks/useStateStore'; +import { Picture } from '../../../../icons/image'; +import { isVideoPlayerAvailable } from '../../../../native'; +import { primitives } from '../../../../theme'; +import { FileTypes } from '../../../../types/types'; +import { getUrlOfImageAttachment } from '../../../../utils/getUrlOfImageAttachment'; +import { openUrlSafely } from '../../../Attachment/utils/openUrlSafely'; +import { useNotificationApi } from '../../../Notifications/hooks/useNotificationApi'; +import { NotificationList } from '../../../Notifications/NotificationList'; +import { NotificationTargetProvider } from '../../../Notifications/NotificationTargetContext'; +import { EmptyList } from '../../../UIComponents/EmptyList'; +import { type MediaTile, useMediaList } from '../../hooks/useMediaList'; + +const NUMBER_OF_COLUMNS = 3; +const GRID_GAP = primitives.spacingXxxs; + +export type MediaListProps = { + /** + * Besides the existing default behavior of the media list, you can attach additional props to + * the underlying React Native FlatList. + * + * See https://reactnative.dev/docs/flatlist#props for the full list. + */ + additionalFlatListProps?: Partial<FlatListProps<MediaTile>>; +}; + +const keyExtractor = (item: MediaTile, index: number) => `${item.message.id}-${index}`; + +const listStateSelector = (state: SearchSourceState<MessageResponse>) => ({ + error: state.lastQueryError, + hasNext: state.hasNext, + loading: state.isLoading, + messages: state.items, +}); + +const MediaListContent = ({ additionalFlatListProps }: MediaListProps) => { + const { t } = useTranslationContext(); + const { + theme: { + channelDetails: { mediaList }, + }, + } = useTheme(); + const styles = useStyles(); + const { width } = useWindowDimensions(); + const { MediaItem } = useComponentsContext(); + + const { addNotification } = useNotificationApi(); + + const { channel, searchSource } = useChannelMediaListContext(); + const { imageGalleryStateStore } = useImageGalleryContext(); + const { setOverlay } = useOverlayContext(); + const { error, hasNext, loading, messages } = useStateStore( + searchSource.state, + listStateSelector, + ); + + const initialized = useRef(false); + useEffect(() => { + if (!initialized.current) { + initialized.current = true; + searchSource.search(); + } + }, [searchSource]); + + const [isEmpty, setIsEmpty] = useState<boolean | undefined>(undefined); + useEffect(() => { + if (!messages || isEmpty !== undefined) { + return; + } + if (messages.length === 0) { + setIsEmpty(true); + } else { + setIsEmpty(false); + } + }, [isEmpty, messages]); + + useEffect(() => { + if (!error) { + return; + } + addNotification({ + message: t('Failed to load media'), + options: { + ...getNotificationErrorOptions(error), + severity: 'error', + type: 'api:channel:query-media:failed', + }, + origin: { context: { channel }, emitter: 'ChannelMediaList' }, + }); + }, [error, addNotification, channel, t]); + + const tiles = useMediaList(messages); + + // Tile side length: full width minus the inter-column gaps, split across the columns. + const tileSize = useMemo( + () => (width - GRID_GAP * (NUMBER_OF_COLUMNS - 1)) / NUMBER_OF_COLUMNS, + [width], + ); + + // Opens the fullscreen gallery over the whole loaded collection, selecting the tapped attachment. + // Mirrors the in-message gallery (`components/Attachment/Gallery.tsx`), but passes every loaded + // message so the viewer can swipe across all media in the list rather than a single message. + const handlePressItem = useCallback( + ({ attachment, requesterNode }: MediaItemPressParams) => { + const url = getUrlOfImageAttachment(attachment); + if (!url) { + return; + } + if (attachment.type === FileTypes.Video && !isVideoPlayerAvailable()) { + // Safeguard for customizations that render videos without a player installed. + openUrlSafely(url); + return; + } + imageGalleryStateStore.openImageGallery({ + messages: messages?.map((message) => formatMessage(message)) ?? [], + requesterNode, + selectedAttachmentUrl: url, + }); + setOverlay('gallery'); + }, + [imageGalleryStateStore, messages, setOverlay], + ); + + const renderItem = useCallback( + ({ item }: { item: MediaTile }) => ( + <MediaItem + attachment={item.attachment} + message={item.message} + onPress={handlePressItem} + size={tileSize} + /> + ), + [handlePressItem, tileSize, MediaItem], + ); + + const loadMore = useCallback(() => { + // hasNext is true by default, !!messages prevents calling search on initial load + if (hasNext && !!messages) { + searchSource.search(); + } + }, [hasNext, messages, searchSource]); + + const emptyState = + loading || isEmpty === undefined ? ( + <MediaListLoadingSkeleton /> + ) : ( + <EmptyList + icon={Picture} + subtitle={t('Share a photo or video to see it here')} + title={t('No photos or videos')} + /> + ); + + const loadingMoreIndicator = <>{loading && tiles.length > 0 && <ActivityIndicator />}</>; + + return ( + <View style={[styles.container, mediaList.container]}> + <FlatList + columnWrapperStyle={tiles.length > 0 ? styles.columnWrapper : undefined} + contentContainerStyle={[styles.listContent, mediaList.listContent]} + data={tiles} + keyExtractor={keyExtractor} + ListEmptyComponent={emptyState} + ListFooterComponent={loadingMoreIndicator} + numColumns={NUMBER_OF_COLUMNS} + onEndReached={loadMore} + onEndReachedThreshold={0.2} + renderItem={renderItem} + style={[styles.list, mediaList.list]} + testID='media-list' + {...additionalFlatListProps} + /> + <NotificationList /> + </View> + ); +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const MediaList = (props: MediaListProps) => { + const { channel } = useChannelDetailsContext(); + const notificationHostId = channel?.cid ? `media-list:${channel.cid}` : undefined; + + if (!notificationHostId) { + return null; + } + + return ( + <ChannelMediaListProvider channel={channel}> + <NotificationTargetProvider hostId={notificationHostId} panel='channel-details'> + <MediaListContent {...props} /> + </NotificationTargetProvider> + </ChannelMediaListProvider> + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + columnWrapper: { + gap: GRID_GAP, + }, + container: { + flex: 1, + }, + list: { + flex: 1, + }, + listContent: { + flexGrow: 1, + gap: GRID_GAP, + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetails/components/navigation-section/MediaListLoadingSkeleton.tsx b/package/src/components/ChannelDetails/components/navigation-section/MediaListLoadingSkeleton.tsx new file mode 100644 index 0000000000..ae7e3ea2bf --- /dev/null +++ b/package/src/components/ChannelDetails/components/navigation-section/MediaListLoadingSkeleton.tsx @@ -0,0 +1,66 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, useWindowDimensions, View } from 'react-native'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../../../theme'; +import { NativeShimmerView } from '../../../UIComponents/NativeShimmerView'; + +const NUMBER_OF_COLUMNS = 3; +const NUMBER_OF_ROWS = 6; +const GRID_GAP = primitives.spacingXxxs; +const ANIMATION_TIME = 1000; + +/** + * Grid of shimmering placeholder tiles shown while the media list is loading. + * + * @experimental This component is experimental and is subject to change. + */ +export const MediaListLoadingSkeleton = () => { + const { + theme: { semantics }, + } = useTheme(); + const { width } = useWindowDimensions(); + const styles = useStyles(); + + const tileSize = (width - GRID_GAP * (NUMBER_OF_COLUMNS - 1)) / NUMBER_OF_COLUMNS; + + return ( + <View style={styles.container} testID='media-list-loading-skeleton'> + {Array.from({ length: NUMBER_OF_ROWS }).map((_, rowIndex) => ( + <View key={rowIndex} style={styles.row}> + {Array.from({ length: NUMBER_OF_COLUMNS }).map((__, columnIndex) => ( + <View key={columnIndex} style={[styles.tile, { height: tileSize, width: tileSize }]}> + <NativeShimmerView + baseColor={semantics.backgroundCoreSurfaceDefault} + duration={ANIMATION_TIME} + gradientColor={semantics.skeletonLoadingHighlight} + style={StyleSheet.absoluteFill} + /> + </View> + ))} + </View> + ))} + </View> + ); +}; + +MediaListLoadingSkeleton.displayName = 'MediaListLoadingSkeleton'; + +const useStyles = () => + useMemo( + () => + StyleSheet.create({ + container: { + gap: GRID_GAP, + }, + row: { + flexDirection: 'row', + gap: GRID_GAP, + }, + tile: { + borderRadius: primitives.radiusXxs, + overflow: 'hidden', + }, + }), + [], + ); diff --git a/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageItem.tsx b/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageItem.tsx new file mode 100644 index 0000000000..7712225363 --- /dev/null +++ b/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageItem.tsx @@ -0,0 +1,94 @@ +import React, { useMemo } from 'react'; +import { I18nManager, StyleSheet, Text, View } from 'react-native'; + +import type { Channel, MessageResponse } from 'stream-chat'; + +import { useComponentsContext } from '../../../../contexts'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../../../theme'; +import { ChannelPreviewStatusProps } from '../../../ChannelPreview/ChannelPreviewStatus'; +import { UserAvatar } from '../../../ui/Avatar/UserAvatar'; + +export type PinnedMessageItemProps = { + /** The channel the pinned message belongs to. */ + channel: Channel; + /** The pinned message to render. */ + message: MessageResponse; +} & { formatMessageDate?: ChannelPreviewStatusProps['formatLatestMessageDate'] }; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const PinnedMessageItem = (props: PinnedMessageItemProps) => { + const { channel, message, formatMessageDate } = props; + const { + theme: { + channelDetails: { pinnedMessageItem }, + semantics, + }, + } = useTheme(); + const { ChannelPreviewLastMessage, ChannelPreviewStatus } = useComponentsContext(); + const styles = useStyles(); + + const senderName = message.user?.name || message.user?.id || ''; + + return ( + <View + style={[styles.container, pinnedMessageItem.container]} + testID={`pinned-message-item-${message.id}`} + > + {message.user ? <UserAvatar size='lg' user={message.user} /> : null} + <View style={[styles.content, pinnedMessageItem.content]}> + <View style={[styles.title, pinnedMessageItem.title]}> + <Text + ellipsizeMode='tail' + numberOfLines={1} + style={[styles.name, { color: semantics.textPrimary }, pinnedMessageItem.name]} + > + {senderName} + </Text> + <ChannelPreviewStatus + channel={channel} + lastMessage={message} + formatLatestMessageDate={formatMessageDate} + /> + </View> + <ChannelPreviewLastMessage message={message}></ChannelPreviewLastMessage> + </View> + </View> + ); +}; + +PinnedMessageItem.displayName = 'PinnedMessageItem{pinnedMessageItem}'; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingSm, + paddingHorizontal: primitives.spacingSm, + paddingVertical: primitives.spacingMd, + }, + content: { + flex: 1, + gap: primitives.spacingXxxs, + }, + name: { + flex: 1, + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + title: { + alignItems: 'center', + flexDirection: 'row', + gap: primitives.spacingXxs, + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageList.tsx b/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageList.tsx new file mode 100644 index 0000000000..506906be49 --- /dev/null +++ b/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageList.tsx @@ -0,0 +1,198 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ActivityIndicator, FlatList, type FlatListProps, StyleSheet, View } from 'react-native'; + +import type { MessageResponse, SearchSourceState } from 'stream-chat'; + +import { PinnedMessageListLoadingSkeleton } from './PinnedMessageListLoadingSkeleton'; + +import { useChannelDetailsContext } from '../../../../contexts/channelDetailsContext/channelDetailsContext'; +import { + ChannelPinnedMessageListProvider, + useChannelPinnedMessageListContext, +} from '../../../../contexts/channelPinnedMessageListContext/ChannelPinnedMessageListContext'; +import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext'; +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; +import { getNotificationErrorOptions } from '../../../../hooks/actions/useChannelActions'; +import { useStateStore } from '../../../../hooks/useStateStore'; +import { Pin } from '../../../../icons/pin'; +import { primitives } from '../../../../theme'; +import { useNotificationApi } from '../../../Notifications/hooks/useNotificationApi'; +import { NotificationList } from '../../../Notifications/NotificationList'; +import { NotificationTargetProvider } from '../../../Notifications/NotificationTargetContext'; +import { EmptyList } from '../../../UIComponents/EmptyList'; +import { EmptySearchResult } from '../../../UIComponents/EmptySearchResult'; +import { SearchInput, SearchInputProps } from '../../../UIComponents/SearchInput'; + +export type PinnedMessageListProps = { + /** + * Besides the existing default behavior of the pinned message list, you can attach + * additional props to the underlying React Native FlatList. + * + * See https://reactnative.dev/docs/flatlist#props for the full list. + */ + additionalFlatListProps?: Partial<FlatListProps<MessageResponse>>; + searchInputProps?: SearchInputProps; +}; + +const keyExtractor = (message: MessageResponse) => message.id; + +const listStateSelector = (state: SearchSourceState<MessageResponse>) => ({ + error: state.lastQueryError, + hasNext: state.hasNext, + loading: state.isLoading, + messages: state.items, + searchQuery: state.searchQuery, +}); + +const PinnedMessageListContent = ({ + additionalFlatListProps, + searchInputProps, +}: PinnedMessageListProps) => { + const { t } = useTranslationContext(); + const { + theme: { + channelDetails: { pinnedMessageList }, + }, + } = useTheme(); + const styles = useStyles(); + const { PinnedMessageItem } = useComponentsContext(); + + const { addNotification } = useNotificationApi(); + + const { channel, searchSource } = useChannelPinnedMessageListContext(); + const { error, hasNext, loading, messages, searchQuery } = useStateStore( + searchSource.state, + listStateSelector, + ); + + const initialized = useRef(false); + useEffect(() => { + if (!initialized.current) { + initialized.current = true; + searchSource.search(); + } + }, [searchSource]); + + const [isEmpty, setIsEmpty] = useState<boolean | undefined>(undefined); + useEffect(() => { + if (!messages || isEmpty !== undefined) return; + if (!searchSource.state.getLatestValue().searchQuery && messages.length === 0) { + setIsEmpty(true); + } + }, [isEmpty, messages, searchSource]); + + useEffect(() => { + if (!error) { + return; + } + addNotification({ + message: t('Failed to load pinned messages'), + options: { + ...getNotificationErrorOptions(error), + severity: 'error', + type: 'api:channel:query-pinned-messages:failed', + }, + origin: { context: { channel }, emitter: 'ChannelPinnedMessageList' }, + }); + }, [error, addNotification, channel, t]); + + const renderItem = useCallback( + ({ item }: { item: MessageResponse }) => <PinnedMessageItem channel={channel} message={item} />, + [channel, PinnedMessageItem], + ); + + const loadMore = useCallback(() => { + // hasNext is true by default, !!messages prevents calling search on initial load + if (hasNext && !!messages) { + searchSource.search(); + } + }, [hasNext, messages, searchSource]); + + const emptyState = loading ? ( + <PinnedMessageListLoadingSkeleton /> + ) : isEmpty ? ( + <EmptyList + icon={Pin} + subtitle={t('Long-press a message to pin it to the chat')} + title={t('No pinned messages')} + /> + ) : ( + <EmptySearchResult label={t('No pinned messages')} /> + ); + + const loadingMoreIndicator = ( + <>{loading && messages && messages.length > 0 && <ActivityIndicator />}</> + ); + + return ( + <View style={[styles.container, pinnedMessageList.container]}> + {!isEmpty && ( + <SearchInput + value={searchQuery} + accessibilityLabel={t('a11y/Search pinned messages')} + onChangeText={(text) => { + searchSource.state.partialNext({ searchQuery: text }); + searchSource.search(text); + }} + {...searchInputProps} + /> + )} + <FlatList + contentContainerStyle={[styles.listContent, pinnedMessageList.listContent]} + data={messages} + keyboardDismissMode='interactive' + keyboardShouldPersistTaps='handled' + keyExtractor={keyExtractor} + ListEmptyComponent={emptyState} + ListFooterComponent={loadingMoreIndicator} + onEndReached={loadMore} + onEndReachedThreshold={0.2} + renderItem={renderItem} + style={[styles.list, pinnedMessageList.list]} + testID='pinned-message-list' + {...additionalFlatListProps} + /> + <NotificationList /> + </View> + ); +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const PinnedMessageList = (props: PinnedMessageListProps) => { + const { channel } = useChannelDetailsContext(); + const notificationHostId = channel?.cid ? `pinned-message-list:${channel.cid}` : undefined; + + if (!notificationHostId) { + return null; + } + + return ( + <ChannelPinnedMessageListProvider channel={channel}> + <NotificationTargetProvider hostId={notificationHostId} panel='channel-details'> + <PinnedMessageListContent {...props} /> + </NotificationTargetProvider> + </ChannelPinnedMessageListProvider> + ); +}; + +const useStyles = () => { + return useMemo( + () => + StyleSheet.create({ + container: { + flex: 1, + }, + list: { + flex: 1, + }, + listContent: { + flexGrow: 1, + paddingBottom: primitives.spacingXl, + }, + }), + [], + ); +}; diff --git a/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageListLoadingSkeleton.tsx b/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageListLoadingSkeleton.tsx new file mode 100644 index 0000000000..6f3110f2da --- /dev/null +++ b/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageListLoadingSkeleton.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { GenericListLoadingSkeleton } from '../../../UIComponents/GenericListLoadingSkeleton'; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const PinnedMessageListLoadingSkeleton = () => { + const { + theme: { pinnedMessageListSkeleton }, + } = useTheme(); + + return ( + <GenericListLoadingSkeleton + skeleton={pinnedMessageListSkeleton} + testID='pinned-message-list-loading-skeleton' + /> + ); +}; + +PinnedMessageListLoadingSkeleton.displayName = + 'PinnedMessageListLoadingSkeleton{pinnedMessageListSkeleton}'; diff --git a/package/src/components/ChannelDetails/components/navigation-section/index.ts b/package/src/components/ChannelDetails/components/navigation-section/index.ts new file mode 100644 index 0000000000..8c294e26f1 --- /dev/null +++ b/package/src/components/ChannelDetails/components/navigation-section/index.ts @@ -0,0 +1,10 @@ +export * from './FileAttachmentItem'; +export * from './FileAttachmentList'; +export * from './FileAttachmentListLoadingSkeleton'; +export * from './FileAttachmentListSectionHeader'; +export * from './MediaItem'; +export * from './MediaList'; +export * from './MediaListLoadingSkeleton'; +export * from './PinnedMessageItem'; +export * from './PinnedMessageList'; +export * from './PinnedMessageListLoadingSkeleton'; diff --git a/package/src/components/ChannelDetails/hooks/index.ts b/package/src/components/ChannelDetails/hooks/index.ts new file mode 100644 index 0000000000..748a5a8fa3 --- /dev/null +++ b/package/src/components/ChannelDetails/hooks/index.ts @@ -0,0 +1,9 @@ +export * from './useChannelDetailsActionItems'; +export * from './useChannelDetailsNavigationItems'; +export * from './useChannelDetailsMemberStatusText'; +export * from './useChannelDetailsMembersPreview'; +export * from './useEditChannelImage'; +export * from './useFileAttachmentListSections'; +export * from './useMediaList'; +export * from './useUserActivityStatus'; +export * from './members'; diff --git a/package/src/components/ChannelDetails/hooks/members/index.ts b/package/src/components/ChannelDetails/hooks/members/index.ts new file mode 100644 index 0000000000..65eac81365 --- /dev/null +++ b/package/src/components/ChannelDetails/hooks/members/index.ts @@ -0,0 +1,2 @@ +export * from './useChannelAllMembers'; +export * from './useMemberRoleLabel'; diff --git a/package/src/components/ChannelDetails/hooks/members/useChannelAllMembers.ts b/package/src/components/ChannelDetails/hooks/members/useChannelAllMembers.ts new file mode 100644 index 0000000000..ace9107089 --- /dev/null +++ b/package/src/components/ChannelDetails/hooks/members/useChannelAllMembers.ts @@ -0,0 +1,131 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import type { Channel, ChannelMemberResponse, MemberFilters, MemberSort } from 'stream-chat'; + +import { useTranslationContext } from '../../../../contexts'; +import { getNotificationErrorOptions } from '../../../../hooks/actions/useChannelActions'; +import { useChannelMembersState } from '../../../ChannelList/hooks/useChannelMembersState'; +import { useNotificationApi } from '../../../Notifications/hooks/useNotificationApi'; + +const PAGE_SIZE = 25; + +export type UseChannelAllMembersResult = { + hasMore: boolean; + loading: boolean; + loadMore: () => void; + results: ChannelMemberResponse[]; +}; + +const noop = () => undefined; + +/** + * @experimental This hook is experimental and is subject to change. + */ +export const useChannelAllMembers = ({ + channel, +}: { + channel: Channel; +}): UseChannelAllMembersResult => { + const { addNotification } = useNotificationApi(); + const { t } = useTranslationContext(); + const localMembers = useChannelMembersState(channel); + + // Mode is decided once on mount (per channel). If member_count is unknown or matches + // the locally-loaded count, we return local state and stay reactive to member events. + // Otherwise we switch into paginated mode for the lifetime of the hook. + const [mode] = useState<'local' | 'paginated'>(() => { + const memberCount = channel.data?.member_count; + const loadedCount = Object.keys(channel.state.members).length; + return memberCount == null || loadedCount >= memberCount ? 'local' : 'paginated'; + }); + + const [results, setResults] = useState<ChannelMemberResponse[]>([]); + const [loading, setLoading] = useState(mode === 'paginated'); + const [hasMore, setHasMore] = useState(mode === 'paginated'); + + const offsetRef = useRef(0); + const requestIdRef = useRef(0); + const inFlightRef = useRef(false); + + const fetchPage = useCallback( + async ({ append }: { append: boolean }) => { + const requestId = ++requestIdRef.current; + inFlightRef.current = true; + setLoading(true); + if (!append) { + offsetRef.current = 0; + setHasMore(true); + } + + try { + const filter: MemberFilters = {}; + const sort: MemberSort = { created_at: 1 }; + const response = await channel.queryMembers(filter, sort, { + limit: PAGE_SIZE, + offset: offsetRef.current, + }); + + if (requestId !== requestIdRef.current) return; + + const fetched = response.members ?? []; + setResults((prev) => { + if (!append) return fetched; + const seen = new Set(prev.map((m) => m.user_id ?? m.user?.id)); + const deduped = fetched.filter((m) => !seen.has(m.user_id ?? m.user?.id)); + return deduped.length ? [...prev, ...deduped] : prev; + }); + offsetRef.current += fetched.length; + if (fetched.length < PAGE_SIZE) { + setHasMore(false); + } + } catch (err) { + if (requestId !== requestIdRef.current) return; + addNotification({ + message: t('Failed to load members'), + options: { + ...getNotificationErrorOptions(err), + severity: 'error', + type: 'api:channel:query-members:failed', + }, + origin: { context: { channel }, emitter: 'ChannelAllMembers' }, + }); + } finally { + if (requestId === requestIdRef.current) { + inFlightRef.current = false; + setLoading(false); + } + } + }, + [addNotification, channel, t], + ); + + const fetchPageRef = useRef(fetchPage); + fetchPageRef.current = fetchPage; + + useEffect(() => { + if (mode !== 'paginated') return; + fetchPageRef.current({ append: false }); + return () => { + requestIdRef.current += 1; + }; + }, [mode]); + + const loadMore = useCallback(() => { + if (mode !== 'paginated') return; + if (inFlightRef.current || !hasMore || loading) return; + fetchPageRef.current({ append: true }); + }, [mode, hasMore, loading]); + + const localResults = useMemo(() => Object.values(localMembers), [localMembers]); + + if (mode === 'local') { + return { + hasMore: false, + loading: false, + loadMore: noop, + results: localResults, + }; + } + + return { hasMore, loading, loadMore, results }; +}; diff --git a/package/src/components/ChannelDetails/hooks/members/useMemberRoleLabel.ts b/package/src/components/ChannelDetails/hooks/members/useMemberRoleLabel.ts new file mode 100644 index 0000000000..c277fbb351 --- /dev/null +++ b/package/src/components/ChannelDetails/hooks/members/useMemberRoleLabel.ts @@ -0,0 +1,32 @@ +import type { ChannelMemberResponse } from 'stream-chat'; + +import { useChannelDetailsContext } from '../../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; + +/** + * Resolves the trailing role label for a channel member row in the channel details screen. + * + * Priority — Owner > Admin > Moderator. When a member matches none of the rules + * (and no custom `getMemberRoleLabel` is provided on the screen), returns `null`. + * @experimental This hook is experimental and is subject to change. + */ +export const useMemberRoleLabel = (member: ChannelMemberResponse): string | null => { + const { channel, getMemberRoleLabel } = useChannelDetailsContext(); + const { t } = useTranslationContext(); + + if (getMemberRoleLabel) { + return getMemberRoleLabel({ channel, member, t }) ?? null; + } + + const userId = member.user?.id; + if (userId && userId === channel?.data?.created_by?.id) { + return t('Owner'); + } + if (member.user?.role === 'admin') { + return t('Admin'); + } + if (member.channel_role === 'channel_moderator') { + return t('Moderator'); + } + return null; +}; diff --git a/package/src/components/ChannelDetails/hooks/useChannelDetailsActionItems.ts b/package/src/components/ChannelDetails/hooks/useChannelDetailsActionItems.ts new file mode 100644 index 0000000000..93420823d7 --- /dev/null +++ b/package/src/components/ChannelDetails/hooks/useChannelDetailsActionItems.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { + ChannelActionItem, + useChannelActionItems, +} from '../../../hooks/actions/useChannelActionItems'; + +/** + * @experimental This hook is experimental and is subject to change. + */ +export const useChannelDetailsActionItems = (): ChannelActionItem[] => { + const { channel, getChannelActionItems, onChannelDismiss } = useChannelDetailsContext(); + + const items = useChannelActionItems({ channel, getChannelActionItems, surface: 'details' }); + + return useMemo( + () => + items.map((item) => { + if (item.id === 'leave' || item.id === 'deleteChannel' || item.id === 'block') { + return { + ...item, + action: (options) => + item.action({ + ...options, + onSuccess: () => { + options?.onSuccess?.(); + onChannelDismiss?.(); + }, + }), + }; + } + return item; + }), + [items, onChannelDismiss], + ); +}; diff --git a/package/src/components/ChannelDetails/hooks/useChannelDetailsMemberStatusText.ts b/package/src/components/ChannelDetails/hooks/useChannelDetailsMemberStatusText.ts new file mode 100644 index 0000000000..e982542237 --- /dev/null +++ b/package/src/components/ChannelDetails/hooks/useChannelDetailsMemberStatusText.ts @@ -0,0 +1,36 @@ +import { useMemo } from 'react'; + +import type { Channel } from 'stream-chat'; + +import { useTranslationContext } 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'; + +/** + * Resolves the subtitle status line shown under the channel title. + * - Direct chats: `t('Online')` when the other member is online, otherwise an empty string. + * - Group chats: `t('{{memberCount}} members, {{onlineCount}} online')`. + * @experimental This hook is experimental and is subject to change. + */ +export const useChannelDetailsMemberStatusText = (channel: Channel): string => { + const { t } = useTranslationContext(); + const members = useChannelMembersState(channel); + const memberCount = useChannelMemberCount(channel); + const onlineCount = useChannelOnlineMemberCount(channel); + const isDirect = useIsDirectChat(channel); + + return useMemo(() => { + if (isDirect) { + const ownUserId = channel.getClient().userID; + const otherMember = Object.values(members).find((member) => member.user?.id !== ownUserId); + return otherMember?.user?.online ? t('Online') : ''; + } + return t('{{memberCount}} members, {{onlineCount}} online', { + count: memberCount, + memberCount, + onlineCount, + }); + }, [channel, isDirect, memberCount, members, onlineCount, t]); +}; diff --git a/package/src/components/ChannelDetails/hooks/useChannelDetailsMembersPreview.ts b/package/src/components/ChannelDetails/hooks/useChannelDetailsMembersPreview.ts new file mode 100644 index 0000000000..fe5d1c6f59 --- /dev/null +++ b/package/src/components/ChannelDetails/hooks/useChannelDetailsMembersPreview.ts @@ -0,0 +1,35 @@ +import { useMemo } from 'react'; + +import type { Channel, ChannelMemberResponse } from 'stream-chat'; + +import { useChannelMemberCount } from '../../../hooks/useChannelMemberCount'; +import { useChannelMembersState } from '../../ChannelList/hooks/useChannelMembersState'; + +export const DEFAULT_VISIBLE_MEMBER_COUNT = 5; + +export type ChannelDetailsMembersPreview = { + hasMore: boolean; + total: number; + visible: ChannelMemberResponse[]; +}; + +/** + * @experimental This hook is experimental and is subject to change. + */ +export const useChannelDetailsMembersPreview = ( + channel: Channel, + max: number = DEFAULT_VISIBLE_MEMBER_COUNT, +): ChannelDetailsMembersPreview => { + const members = useChannelMembersState(channel); + const memberCount = useChannelMemberCount(channel); + + return useMemo(() => { + const all = Object.values(members); + const total = memberCount || all.length; + return { + hasMore: total > max, + total, + visible: all.slice(0, max), + }; + }, [members, memberCount, max]); +}; diff --git a/package/src/components/ChannelDetails/hooks/useChannelDetailsNavigationItems.ts b/package/src/components/ChannelDetails/hooks/useChannelDetailsNavigationItems.ts new file mode 100644 index 0000000000..2d7d871b86 --- /dev/null +++ b/package/src/components/ChannelDetails/hooks/useChannelDetailsNavigationItems.ts @@ -0,0 +1,108 @@ +import React, { useMemo } from 'react'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { Picture } from '../../../icons'; +import { Folder } from '../../../icons/folder'; + +import { Pin } from '../../../icons/pin'; +import type { IconProps } from '../../../icons/utils/base'; + +/** + * Identifies a navigation row. The literals are the built-in sections rendered by default; + * consumers can also add their own rows with arbitrary section identifiers via `getNavigationItems`, + * so any string is allowed. + * + * @experimental This type is experimental and is subject to change. + */ +export type ChannelDetailsNavigationSectionType = + | 'pinned-messages' + | 'photos-and-videos' + | 'files' + | string; + +/** + * A single row in the channel details navigation section. + * + * @experimental This type is experimental and is subject to change. + */ +export type ChannelDetailsNavigationItem = { + /** Icon rendered at the start of the row and reused in the built-in modal header. */ + Icon: React.ComponentType<IconProps>; + /** Already-translated label rendered for the row and its built-in modal header. */ + label: string; + /** Identifies which built-in section this row represents. */ + section: ChannelDetailsNavigationSectionType; + /** + * Fired when the user taps the row. Leave unset to keep the built-in behavior (opening the + * built-in modal for the section); set it to route the row somewhere else (e.g. your own screen). + */ + onPress?: () => void; +}; + +/** + * Maps each navigation section to the icon and (translatable) label rendered for its row and, + * when opened, its modal header. The declaration order also drives the order of the rendered rows. + */ +const SECTION_CONFIG: Record< + 'pinned-messages' | 'photos-and-videos' | 'files', + { Icon: React.ComponentType<IconProps>; label: string } +> = { + 'pinned-messages': { Icon: Pin, label: 'Pinned Messages' }, + 'photos-and-videos': { Icon: Picture, label: 'Photos & Videos' }, + files: { Icon: Folder, label: 'Files' }, +}; + +const SECTIONS = Object.keys(SECTION_CONFIG) as Array<keyof typeof SECTION_CONFIG>; + +export type ChannelDetailsNavigationItemsContext = { + t: TranslationContextValue['t']; +}; + +/** + * Customizes the navigation rows rendered by `ChannelDetailsNavigationSection`. Receives the + * built-in `defaultItems` (and a `context`) and returns the items to render. Map over + * `defaultItems` to set a row's `onPress` (e.g. to push your own screen), or add/remove rows. Any + * row whose `onPress` you leave unset keeps its built-in behavior (opening the built-in modal) — + * including sections added in future SDK versions. + * + * @experimental This type is experimental and is subject to change. + */ +export type GetChannelDetailsNavigationItems = (params: { + context: ChannelDetailsNavigationItemsContext; + defaultItems: ChannelDetailsNavigationItem[]; +}) => ChannelDetailsNavigationItem[]; + +export const getChannelDetailsNavigationItems: GetChannelDetailsNavigationItems = ({ + defaultItems, +}) => defaultItems; + +/** + * Builds the navigation rows rendered by `ChannelDetailsNavigationSection`. Returns the items as a + * plain array — the section component owns the built-in modal and supplies the default open-modal + * behavior for any row without a custom `onPress`. Customize the rows by passing `getNavigationItems` + * to `ChannelDetails` (see {@link GetChannelDetailsNavigationItems}). + * + * @experimental This hook is experimental and is subject to change. + */ +export const useChannelDetailsNavigationItems = (): ChannelDetailsNavigationItem[] => { + const { t } = useTranslationContext(); + const { getNavigationItems = getChannelDetailsNavigationItems } = useChannelDetailsContext(); + + const context = useMemo<ChannelDetailsNavigationItemsContext>(() => ({ t }), [t]); + + const defaultItems = useMemo<ChannelDetailsNavigationItem[]>( + () => + SECTIONS.map((section) => { + const { Icon, label } = SECTION_CONFIG[section]; + return { Icon, label: t(label), section }; + }), + [t], + ); + + return useMemo( + () => getNavigationItems({ context, defaultItems }), + [context, defaultItems, getNavigationItems], + ); +}; diff --git a/package/src/components/ChannelDetails/hooks/useEditChannelImage.ts b/package/src/components/ChannelDetails/hooks/useEditChannelImage.ts new file mode 100644 index 0000000000..a792b495ca --- /dev/null +++ b/package/src/components/ChannelDetails/hooks/useEditChannelImage.ts @@ -0,0 +1,91 @@ +import { useCallback } from 'react'; +import { Alert, Linking } from 'react-native'; + +import { useChannelDetailsContext } from '../../../contexts/channelDetailsContext/channelDetailsContext'; +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { NativeHandlers } from '../../../native'; +import type { File } from '../../../types/types'; +import { compressedImageURI } from '../../../utils/compressImage'; + +export type UseEditChannelImageResult = { + /** + * Open the device's native image picker and return a single picked image + * with the compression configured on `ChannelDetails` already applied + * to its `uri`. Returns `undefined` if the user cancels or denies access. + */ + pickImageFromNativePicker: () => Promise<File | undefined>; + /** + * Launch the device's camera and return the captured image. Returns + * `undefined` if the user cancels or denies access. The native camera + * handler already honors `compressImageQuality`, so no extra compression + * pass is needed for camera files. + */ + takePhoto: () => Promise<File | undefined>; +}; + +/** + * Hook that exposes the two image-acquisition flows used by the channel edit + * screen — camera capture and native gallery pick — using the same control + * flow and compression behavior as the message composer (`takeAndUploadImage` + * and `pickAndUploadImageFromNativePicker` in `MessageInputContext`). The + * hook intentionally does NOT upload the picked file; the consumer receives a + * `File` and decides what to do with it. + * + * Reads `compressImageQuality` from `ChannelDetailsContext`. + * @experimental This hook is experimental and is subject to change. + */ +export const useEditChannelImage = (): UseEditChannelImageResult => { + const { compressImageQuality } = useChannelDetailsContext(); + const { t } = useTranslationContext(); + + const takePhoto = useCallback(async (): Promise<File | undefined> => { + const file = await NativeHandlers.takePhoto({ + compressImageQuality: compressImageQuality ?? 1, + mediaType: 'image', + }); + + if (file.askToOpenSettings) { + Alert.alert( + t('Allow camera access in device settings'), + t('Device camera is used to take photos or videos.'), + [ + { style: 'cancel', text: t('Cancel') }, + { onPress: () => Linking.openSettings(), style: 'default', text: t('Open Settings') }, + ], + ); + return undefined; + } + + if (file.cancelled) { + return undefined; + } + + return file; + }, [compressImageQuality, t]); + + const pickImageFromNativePicker = useCallback(async (): Promise<File | undefined> => { + const result = await NativeHandlers.pickImage({ maxNumberOfFiles: 1 }); + + if (result.askToOpenSettings) { + Alert.alert( + t('Allow access to your Gallery'), + t('Device gallery permissions is used to take photos or videos.'), + [ + { style: 'cancel', text: t('Cancel') }, + { onPress: () => Linking.openSettings(), style: 'default', text: t('Open Settings') }, + ], + ); + return undefined; + } + + if (result.cancelled || !result.assets?.length) { + return undefined; + } + + const asset = result.assets[0]; + const compressedUri = await compressedImageURI(asset, compressImageQuality); + return { ...asset, uri: compressedUri }; + }, [compressImageQuality, t]); + + return { pickImageFromNativePicker, takePhoto }; +}; diff --git a/package/src/components/ChannelDetails/hooks/useFileAttachmentListSections.ts b/package/src/components/ChannelDetails/hooks/useFileAttachmentListSections.ts new file mode 100644 index 0000000000..c5aea221d0 --- /dev/null +++ b/package/src/components/ChannelDetails/hooks/useFileAttachmentListSections.ts @@ -0,0 +1,79 @@ +import { useMemo } from 'react'; + +import { + type Attachment, + isAudioAttachment, + isFileAttachment, + isScrapedContent, + type MessageResponse, +} from 'stream-chat'; + +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { getDateString } from '../../../utils/i18n/getDateString'; + +/** + * A single file/audio attachment paired with the message it belongs to. The file attachment list + * renders one row per attachment, so a message with multiple file attachments yields multiple rows. + * + * @experimental This type is experimental and is subject to change. + */ +export type FileAttachmentTile = { attachment: Attachment; message: MessageResponse }; + +export type FileAttachmentSection = { data: FileAttachmentTile[]; title: string }; + +/** + * Gathers the file/audio attachments from a message, excluding scraped/OG link-preview content. + */ +const getFileAttachments = (message: MessageResponse): Attachment[] => + (message.attachments ?? []).filter( + (attachment) => + // We provide mime_type here to avoid attachments with mime_type being categorized as file + (isFileAttachment(attachment, attachment?.mime_type ? [attachment.mime_type] : []) || + isAudioAttachment(attachment)) && + !isScrapedContent(attachment), + ); + +/** + * Gathers and filters the file/audio attachments from a list of messages, then groups them into + * newest-first month sections for the file attachment list. Each section's `data` is a flat list + * of `{ attachment, message }` tiles; messages without renderable file attachments are skipped. + * + * The month label is produced through the shared `getDateString` + translation-key + * pipeline used by message timestamps (see `useUserActivityStatus`), so the format + * follows the configured locale and can be customized via the + * `timestamp/FileAttachmentListSection` translation key. + * @experimental This hook is experimental and is subject to change. + */ +export const useFileAttachmentListSections = ( + messages?: MessageResponse[], +): FileAttachmentSection[] => { + const { t, tDateTimeParser } = useTranslationContext(); + + return useMemo<FileAttachmentSection[]>(() => { + if (!messages || messages.length === 0) { + return []; + } + const result: FileAttachmentSection[] = []; + for (const message of messages) { + const fileAttachments = getFileAttachments(message); + if (fileAttachments.length === 0) { + continue; + } + const formatted = getDateString({ + date: message.created_at as string | Date | undefined, + t, + tDateTimeParser, + timestampTranslationKey: 'timestamp/FileAttachmentListSection', + }); + const title = typeof formatted === 'string' ? formatted : String(formatted ?? ''); + const tiles = fileAttachments.map((attachment) => ({ attachment, message })); + const lastSection = result[result.length - 1]; + if (lastSection && lastSection.title === title) { + lastSection.data.push(...tiles); + } else { + result.push({ data: tiles, title }); + } + } + return result; + }, [messages, t, tDateTimeParser]); +}; diff --git a/package/src/components/ChannelDetails/hooks/useMediaList.ts b/package/src/components/ChannelDetails/hooks/useMediaList.ts new file mode 100644 index 0000000000..fd3f4282ae --- /dev/null +++ b/package/src/components/ChannelDetails/hooks/useMediaList.ts @@ -0,0 +1,51 @@ +import { useMemo } from 'react'; + +import { type Attachment, isScrapedContent, type MessageResponse } from 'stream-chat'; + +import { FileTypes } from '../../../types/types'; + +/** + * A single image/video attachment paired with the message it belongs to. The media grid renders + * one tile per attachment, so a message with multiple media attachments yields multiple tiles. + * + * @experimental This type is experimental and is subject to change. + */ +export type MediaTile = { + attachment: Attachment; + message: MessageResponse; +}; + +/** + * Flattens search-result messages into one tile per renderable image/video attachment, applying + * the same attachment rules the message list uses to decide what counts as gallery media. + */ +const getMediaTiles = (messages: MessageResponse[] | undefined): MediaTile[] => { + if (!messages) { + return []; + } + const tiles: MediaTile[] = []; + for (const message of messages) { + if (!message.attachments) { + continue; + } + for (const attachment of message.attachments) { + if ( + (attachment.type === FileTypes.Image || attachment.type === FileTypes.Video) && + !isScrapedContent(attachment) + ) { + tiles.push({ attachment, message }); + } + } + } + return tiles; +}; + +/** + * Gathers and filters the image/video attachments from a list of messages into a flat list of + * media tiles, ready to render in the media grid. Scraped/OG link-preview attachments and + * non-media attachments are excluded. + * + * @experimental This hook is experimental and is subject to change. + */ +export const useMediaList = (messages?: MessageResponse[]): MediaTile[] => + useMemo(() => getMediaTiles(messages), [messages]); diff --git a/package/src/components/ChannelDetails/hooks/useUserActivityStatus.ts b/package/src/components/ChannelDetails/hooks/useUserActivityStatus.ts new file mode 100644 index 0000000000..01a42e5ba6 --- /dev/null +++ b/package/src/components/ChannelDetails/hooks/useUserActivityStatus.ts @@ -0,0 +1,39 @@ +import { useMemo } from 'react'; + +import type { UserResponse } from 'stream-chat'; + +import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { getDateString } from '../../../utils/i18n/getDateString'; + +/** + * Returns the localized presence status string for a user: + * - `t('Online')` when `user.online === true` + * - `t('timestamp/UserActivityStatus')` (e.g. "Last seen 10 minutes ago") when offline + * with a valid `last_active` + * - `t('Offline')` otherwise (including `user === undefined` or an unparseable date) + * + * The relative time is produced through the shared `getDateString` + translation-key + * pipeline used by message timestamps, so the format follows the configured locale. + * @experimental This hook is experimental and is subject to change. + */ +export const useUserActivityStatus = (user?: UserResponse): string => { + const { t, tDateTimeParser } = useTranslationContext(); + + return useMemo(() => { + if (user?.online) return t('Online'); + + if (user?.last_active) { + const lastSeen = getDateString({ + date: user.last_active, + t, + tDateTimeParser, + timestampTranslationKey: 'timestamp/UserActivityStatus', + }); + if (typeof lastSeen === 'string') { + return lastSeen; + } + } + + return t('Offline'); + }, [t, tDateTimeParser, user?.last_active, user?.online]); +}; diff --git a/package/src/components/ChannelDetails/index.ts b/package/src/components/ChannelDetails/index.ts new file mode 100644 index 0000000000..ce39d04611 --- /dev/null +++ b/package/src/components/ChannelDetails/index.ts @@ -0,0 +1,3 @@ +export * from './ChannelDetails'; +export * from './components'; +export * from './hooks'; diff --git a/package/src/components/ChannelList/ChannelList.tsx b/package/src/components/ChannelList/ChannelList.tsx index 330af118d8..819ff5397d 100644 --- a/package/src/components/ChannelList/ChannelList.tsx +++ b/package/src/components/ChannelList/ChannelList.tsx @@ -41,6 +41,7 @@ export type ChannelListProps = Partial< | 'maxUnreadCount' | 'numberOfSkeletons' | 'mutedStatusPosition' + | 'pinnedStatusPosition' > > & { /** Optional function to filter channels prior to rendering the list. Do not use any complex logic that would delay the loading of the ChannelList. We recommend using a pure function with array methods like filter/sort/reduce. */ @@ -252,6 +253,7 @@ export const ChannelList = (props: ChannelListProps) => { queryChannelsOverride, notificationHostId: notificationHostIdProp, mutedStatusPosition = 'inlineTitle', + pinnedStatusPosition = 'inlineTitle', swipeActionsEnabled = true, } = props; @@ -375,6 +377,7 @@ export const ChannelList = (props: ChannelListProps) => { } }, mutedStatusPosition, + pinnedStatusPosition, }); return ( diff --git a/package/src/components/ChannelList/hooks/__tests__/useChannelActionItems.test.tsx b/package/src/components/ChannelList/hooks/__tests__/useChannelActionItems.test.tsx deleted file mode 100644 index 57b5af4dd2..0000000000 --- a/package/src/components/ChannelList/hooks/__tests__/useChannelActionItems.test.tsx +++ /dev/null @@ -1,276 +0,0 @@ -import { Alert, AlertButton } from 'react-native'; - -import { renderHook } from '@testing-library/react-native'; -import type { Channel } from 'stream-chat'; - -import type { TranslationContextValue } from '../../../../contexts/translationContext/TranslationContext'; -import * as TranslationContext from '../../../../contexts/translationContext/TranslationContext'; -import { - GetChannelActionItems, - buildDefaultChannelActionItems, - getChannelActionItems, - useChannelActionItems, -} from '../useChannelActionItems'; -import * as useChannelActionsModule from '../useChannelActions'; -import * as useChannelMembershipStateModule from '../useChannelMembershipState'; -import * as useChannelMuteActiveModule from '../useChannelMuteActive'; -import * as useIsDirectChatModule from '../useIsDirectChat'; - -const createChannelActions = (): useChannelActionsModule.ChannelActions => ({ - archive: jest.fn(), - blockUser: jest.fn(), - deleteChannel: jest.fn(), - leave: jest.fn(), - muteChannel: jest.fn(), - muteUser: jest.fn(), - pin: jest.fn(), - unarchive: jest.fn(), - unblockUser: jest.fn(), - unmuteChannel: jest.fn(), - unmuteUser: jest.fn(), - unpin: jest.fn(), -}); - -const createChannelMock = (params?: { - blockedUserIds?: string[]; - createdById?: string; - ownUserId?: string; -}): Channel => { - const { - blockedUserIds = [], - createdById = 'current-user-id', - ownUserId = 'current-user-id', - } = params ?? {}; - return { - data: { - created_by: { - id: createdById, - }, - }, - getClient: () => ({ - blockedUsers: { - getLatestValue: () => ({ userIds: blockedUserIds }), - }, - userID: ownUserId, - }), - state: { - members: { - own: { user: { id: ownUserId } }, - other: { user: { id: 'other-user-id' } }, - }, - }, - } as unknown as Channel; -}; - -describe('useChannelActionItems', () => { - const channel = createChannelMock(); - - const channelActions = createChannelActions(); - - beforeEach(() => { - jest.clearAllMocks(); - jest - .spyOn(TranslationContext, 'useTranslationContext') - .mockImplementation( - () => ({ t: (value: string) => value }) as unknown as TranslationContextValue, - ); - jest.spyOn(useChannelMembershipStateModule, 'useChannelMembershipState').mockReturnValue({ - archived_at: undefined, - pinned_at: undefined, - } as never); - jest.spyOn(useChannelMuteActiveModule, 'useChannelMuteActive').mockReturnValue(false); - jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(false); - jest.spyOn(useChannelActionsModule, 'useChannelActions').mockReturnValue(channelActions); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('returns default channel action items', () => { - const { result } = renderHook(() => useChannelActionItems({ channel })); - - expect(result.current).toHaveLength(3); - expect(result.current.map((item) => item.action)).toEqual([ - channelActions.muteChannel, - channelActions.leave, - expect.any(Function), - ]); - expect(result.current.map((item) => item.id)).toEqual(['mute', 'leave', 'deleteChannel']); - expect(result.current.map((item) => item.type)).toEqual([ - 'standard', - 'destructive', - 'destructive', - ]); - expect(result.current.map((item) => item.placement)).toEqual(['swipe', 'sheet', 'sheet']); - }); - - it('uses custom getChannelActionItems with context and defaultItems when provided', () => { - const customGetChannelActionItems = jest.fn( - ({ defaultItems }: Parameters<GetChannelActionItems>[0]) => defaultItems.slice(0, 1), - ); - - const { result } = renderHook(() => - useChannelActionItems({ - channel, - getChannelActionItems: customGetChannelActionItems, - }), - ); - - expect(customGetChannelActionItems).toHaveBeenCalledWith({ - context: { - actions: channelActions, - channel, - isArchived: false, - isDirectChat: false, - isPinned: false, - muteActive: false, - t: expect.any(Function), - }, - defaultItems: expect.any(Array), - }); - expect(result.current).toHaveLength(1); - expect(result.current[0].action).toBe(channelActions.muteChannel); - expect(result.current[0].id).toBe('mute'); - expect(result.current[0].type).toBe('standard'); - }); -}); - -describe('getChannelActionItems', () => { - const channel = createChannelMock(); - - it('creates action items in default order', () => { - const channelActions = createChannelActions(); - - const defaultItems = buildDefaultChannelActionItems({ - actions: channelActions, - channel, - isArchived: false, - isDirectChat: false, - isPinned: false, - muteActive: false, - t: ((value: string) => value) as TranslationContextValue['t'], - }); - const actionItems = getChannelActionItems({ - context: { - actions: channelActions, - channel, - isArchived: false, - isDirectChat: false, - isPinned: false, - muteActive: false, - t: ((value: string) => value) as TranslationContextValue['t'], - }, - defaultItems, - }); - - expect(actionItems.map((item) => item.action)).toEqual([ - channelActions.muteChannel, - channelActions.leave, - expect.any(Function), - ]); - expect(actionItems.map((item) => item.id)).toEqual(['mute', 'leave', 'deleteChannel']); - expect(actionItems.map((item) => item.type)).toEqual([ - 'standard', - 'destructive', - 'destructive', - ]); - }); - - it('returns direct-chat variants for mute and block states', () => { - const channelActions = createChannelActions(); - const actionItems = buildDefaultChannelActionItems({ - actions: channelActions, - channel: createChannelMock({ blockedUserIds: ['other-user-id'] }), - isArchived: true, - isDirectChat: true, - isPinned: false, - muteActive: true, - t: ((value: string) => value) as TranslationContextValue['t'], - }); - - expect(actionItems.map((item) => item.id)).toEqual(['mute', 'block', 'leave', 'deleteChannel']); - expect(actionItems.map((item) => item.action)).toEqual([ - channelActions.unmuteUser, - channelActions.unblockUser, - channelActions.leave, - expect.any(Function), - ]); - expect(actionItems.map((item) => item.label)).toEqual([ - 'Unmute User', - 'Unblock User', - 'Leave Chat', - 'Delete Chat', - ]); - expect(actionItems.map((item) => item.placement)).toEqual(['sheet', 'sheet', 'sheet', 'sheet']); - }); - - it('omits delete action when current user is not the channel creator', () => { - const actionItems = buildDefaultChannelActionItems({ - actions: createChannelActions(), - channel: createChannelMock({ createdById: 'someone-else' }), - isArchived: false, - isDirectChat: false, - isPinned: false, - muteActive: false, - t: ((value: string) => value) as TranslationContextValue['t'], - }); - - expect(actionItems.map((item) => item.id)).toEqual(['mute', 'leave']); - }); - - it('uses group mute variants for labels and placements', () => { - const channelActions = createChannelActions(); - const actionItems = buildDefaultChannelActionItems({ - actions: channelActions, - channel, - isArchived: true, - isDirectChat: false, - isPinned: false, - muteActive: true, - t: ((value: string) => value) as TranslationContextValue['t'], - }); - - expect(actionItems[0].action).toBe(channelActions.unmuteChannel); - expect(actionItems[0].label).toBe('Unmute Group'); - expect(actionItems[0].placement).toBe('swipe'); - - expect(actionItems[1].action).toBe(channelActions.leave); - expect(actionItems[1].label).toBe('Leave Group'); - expect(actionItems[1].placement).toBe('sheet'); - }); - - it('shows delete confirmation and calls deleteChannel on destructive confirm', async () => { - const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(jest.fn()); - const channelActions = createChannelActions(); - - const actionItems = buildDefaultChannelActionItems({ - actions: channelActions, - channel, - isArchived: false, - isDirectChat: false, - isPinned: false, - muteActive: false, - t: ((value: string) => value) as TranslationContextValue['t'], - }); - - const deleteItem = actionItems.find((item) => item.id === 'deleteChannel'); - expect(deleteItem).toBeDefined(); - deleteItem?.action(); - - expect(alertSpy).toHaveBeenCalledWith( - 'Delete group', - "Are you sure you want to delete this group? This can't be undone.", - expect.any(Array), - ); - - const buttons = (alertSpy.mock.calls[0]?.[2] ?? []) as AlertButton[]; - const destructiveButton = buttons.find((button) => button.style === 'destructive'); - - expect(destructiveButton?.text).toBe('Delete'); - await destructiveButton?.onPress?.(); - expect(channelActions.deleteChannel).toHaveBeenCalledTimes(1); - - alertSpy.mockRestore(); - }); -}); diff --git a/package/src/components/ChannelList/hooks/__tests__/useChannelActions.test.tsx b/package/src/components/ChannelList/hooks/__tests__/useChannelActions.test.tsx deleted file mode 100644 index e885ee44d2..0000000000 --- a/package/src/components/ChannelList/hooks/__tests__/useChannelActions.test.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React, { PropsWithChildren } from 'react'; - -import { act, renderHook } from '@testing-library/react-native'; -import type { Channel } from 'stream-chat'; - -import { ChatProvider } from '../../../../contexts/chatContext/ChatContext'; -import { useChannelActions } from '../useChannelActions'; - -const createWrapper = - (client: unknown) => - ({ children }: PropsWithChildren) => ( - <ChatProvider value={{ client } as never}>{children}</ChatProvider> - ); - -const createClient = () => ({ - blockUser: jest.fn(), - notifications: { - add: jest.fn(), - remove: jest.fn(), - startTimeout: jest.fn(), - }, - muteUser: jest.fn(), - unBlockUser: jest.fn(), - unmuteUser: jest.fn(), - userID: 'current-user-id', -}); - -const createChannel = (client: ReturnType<typeof createClient>) => - ({ - archive: jest.fn(), - getClient: () => client, - mute: jest.fn(), - pin: jest.fn(), - removeMembers: jest.fn(), - state: { - members: { - current: { user: { id: 'current-user-id' } }, - other: { user: { id: 'other-user-id', name: 'Other User' } }, - }, - }, - unarchive: jest.fn(), - unmute: jest.fn(), - unpin: jest.fn(), - }) as unknown as Channel; - -describe('useChannelActions', () => { - it('notifies when channel mute succeeds', async () => { - const client = createClient(); - const channel = createChannel(client); - const { result } = renderHook(() => useChannelActions(channel), { - wrapper: createWrapper(client), - }); - - await act(async () => { - await result.current.muteChannel(); - }); - - expect(channel.mute).toHaveBeenCalledTimes(1); - expect(client.notifications.add).toHaveBeenCalledWith({ - message: 'Channel muted', - options: { - severity: 'success', - type: 'api:channel:mute:success', - }, - origin: { - context: { channel }, - emitter: 'ChannelActions', - }, - }); - }); - - it('notifies when channel mute fails', async () => { - const error = new Error('mute failed'); - const client = createClient(); - const channel = createChannel(client); - jest.mocked(channel.mute).mockRejectedValue(error); - const { result } = renderHook(() => useChannelActions(channel), { - wrapper: createWrapper(client), - }); - - await act(async () => { - await result.current.muteChannel(); - }); - - expect(client.notifications.add).toHaveBeenCalledWith({ - message: 'Failed to update channel mute status', - options: { - originalError: error, - severity: 'error', - type: 'api:channel:mute:failed', - }, - origin: { - context: { channel }, - emitter: 'ChannelActions', - }, - }); - }); - - it('notifies when a direct channel user is blocked', async () => { - const client = createClient(); - const channel = createChannel(client); - const { result } = renderHook(() => useChannelActions(channel), { - wrapper: createWrapper(client), - }); - - await act(async () => { - await result.current.blockUser(); - }); - - expect(client.blockUser).toHaveBeenCalledWith('other-user-id'); - expect(client.notifications.add).toHaveBeenCalledWith({ - message: 'User blocked', - options: { - severity: 'success', - type: 'api:user:block:success', - }, - origin: { - context: { channel }, - emitter: 'ChannelActions', - }, - }); - }); -}); diff --git a/package/src/components/ChannelList/hooks/index.ts b/package/src/components/ChannelList/hooks/index.ts index ed59263a05..47e0e5001b 100644 --- a/package/src/components/ChannelList/hooks/index.ts +++ b/package/src/components/ChannelList/hooks/index.ts @@ -1,11 +1,5 @@ export * from './listeners/useChannelUpdated'; -export * from './useChannelActionItems'; -export * from './useChannelActionItemsById'; -export * from './useChannelActions'; -export * from './useChannelMembershipState'; export * from './useChannelMembersState'; -export * from './useChannelMuteActive'; export * from './useChannelOnlineMemberCount'; -export * from './useIsDirectChat'; export * from './useMutedChannels'; export * from './useMutedUsers'; diff --git a/package/src/components/ChannelList/hooks/useChannelActionItems.tsx b/package/src/components/ChannelList/hooks/useChannelActionItems.tsx deleted file mode 100644 index 68be87888a..0000000000 --- a/package/src/components/ChannelList/hooks/useChannelActionItems.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import React, { useMemo } from 'react'; -import { Alert } from 'react-native'; - -import type { Channel } from 'stream-chat'; - -import { ChannelActions, getOtherUserInDirectChannel } from './useChannelActions'; -import { useChannelActions } from './useChannelActions'; -import { useChannelMembershipState } from './useChannelMembershipState'; - -import { useChannelMuteActive } from './useChannelMuteActive'; -import { useIsDirectChat } from './useIsDirectChat'; - -import { useTheme, useTranslationContext } from '../../../contexts'; -import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext'; -import { IconProps, Mute, BlockUser, Delete, Sound } from '../../../icons'; -import { ArrowBoxLeft } from '../../../icons/leave'; - -export type ChannelActionHandler = () => Promise<void> | void; - -export type ChannelActionItem = { - action: ChannelActionHandler; - Icon: React.ComponentType<IconProps>; - id: 'mute' | 'block' | 'archive' | 'leave' | 'deleteChannel' | string; - label: string; - placement: 'both' | 'sheet' | 'swipe'; - type: 'destructive' | 'standard'; -}; - -export type ChannelActionItemsParams = { - actions: ChannelActions; - channel: Channel; - isArchived: boolean; - isDirectChat: boolean; - isPinned: boolean; - muteActive: boolean; - t: TranslationContextValue['t']; -}; - -export type BuildDefaultChannelActionItems = ( - channelActionItemsParams: ChannelActionItemsParams, -) => ChannelActionItem[]; - -const ChannelActionsIcon = ({ - Icon, - ...rest -}: { Icon: React.ComponentType<IconProps> } & IconProps) => { - const { - theme: { semantics }, - } = useTheme(); - - return <Icon stroke={semantics.textSecondary} width={20} height={20} {...rest} />; -}; - -export const buildDefaultChannelActionItems: BuildDefaultChannelActionItems = ( - channelActionItemsParams, -) => { - const { - actions: { - deleteChannel, - leave, - muteChannel, - unmuteChannel, - muteUser, - unmuteUser, - blockUser, - unblockUser, - }, - isDirectChat, - muteActive, - t, - channel, - } = channelActionItemsParams; - const ownUserId = channel.getClient().userID; - - const client = channel.getClient(); - - const isBlocked = isDirectChat - ? new Set(client.blockedUsers.getLatestValue().userIds).has( - getOtherUserInDirectChannel(channel)?.user?.id ?? '', - ) - : undefined; - - const actionItems: ChannelActionItem[] = [ - { - action: isDirectChat - ? muteActive - ? unmuteUser - : muteUser - : muteActive - ? unmuteChannel - : muteChannel, - Icon: (props) => - muteActive ? ( - <Sound width={20} height={20} {...props} /> - ) : ( - <Mute - width={20} - height={20} - {...props} - stroke={undefined} - fill={props.fill ?? props.stroke} - /> - ), - id: 'mute', - label: isDirectChat - ? muteActive - ? t('Unmute User') - : t('Mute User') - : muteActive - ? t('Unmute Group') - : t('Mute Group'), - placement: isDirectChat ? 'sheet' : 'swipe', - type: 'standard', - }, - ]; - - if (isDirectChat) { - actionItems.push({ - action: isBlocked ? unblockUser : blockUser, - Icon: (props) => <ChannelActionsIcon Icon={BlockUser} {...props} />, - id: 'block', - label: isBlocked ? t('Unblock User') : t('Block User'), - placement: 'sheet', - type: 'standard', - }); - } - - actionItems.push({ - action: leave, - Icon: (props) => <ChannelActionsIcon Icon={ArrowBoxLeft} {...props} />, - id: 'leave', - label: isDirectChat ? t('Leave Chat') : t('Leave Group'), - placement: 'sheet', - type: 'destructive', - }); - - if (channel.data?.created_by?.id === ownUserId) { - actionItems.push({ - action: () => { - const title = isDirectChat ? t('Delete chat') : t('Delete group'); - const message = isDirectChat - ? t("Are you sure you want to delete this chat? This can't be undone.") - : t("Are you sure you want to delete this group? This can't be undone."); - - Alert.alert(title, message, [ - { - style: 'cancel', - text: t('Cancel'), - }, - { - onPress: async () => { - await deleteChannel(); - }, - style: 'destructive', - text: t('Delete'), - }, - ]); - }, - Icon: (props) => <ChannelActionsIcon Icon={Delete} {...props} />, - id: 'deleteChannel', - label: isDirectChat ? t('Delete Chat') : t('Delete Group'), - placement: 'sheet', - type: 'destructive', - }); - } - - return actionItems; -}; - -export type GetChannelActionItems = (params: { - context: ChannelActionItemsParams; - defaultItems: ChannelActionItem[]; -}) => ChannelActionItem[]; - -export const getChannelActionItems: GetChannelActionItems = ({ defaultItems }) => defaultItems; - -type UseChannelActionItemsParams = { - channel: Channel; - getChannelActionItems?: GetChannelActionItems; -}; - -export const useChannelActionItems = ({ - channel, - getChannelActionItems: getChannelActionItemsProp = getChannelActionItems, -}: UseChannelActionItemsParams) => { - const { t } = useTranslationContext(); - const membership = useChannelMembershipState(channel); - const channelActions = useChannelActions(channel); - const isDirectChat = useIsDirectChat(channel); - const isPinned = Boolean(membership?.pinned_at); - const isArchived = Boolean(membership?.archived_at); - - const muteActive = useChannelMuteActive(channel); - - const channelActionItemsParams = useMemo( - () => ({ - actions: channelActions, - channel, - isArchived, - isDirectChat, - isPinned, - muteActive, - t, - }), - [channel, muteActive, channelActions, isArchived, isDirectChat, isPinned, t], - ); - - const defaultItems = useMemo( - () => buildDefaultChannelActionItems(channelActionItemsParams), - [channelActionItemsParams], - ); - - return useMemo( - () => - getChannelActionItemsProp({ - context: channelActionItemsParams, - defaultItems, - }), - [channelActionItemsParams, defaultItems, getChannelActionItemsProp], - ); -}; diff --git a/package/src/components/ChannelList/hooks/useChannelMuteActive.ts b/package/src/components/ChannelList/hooks/useChannelMuteActive.ts deleted file mode 100644 index 0e548f5ba8..0000000000 --- a/package/src/components/ChannelList/hooks/useChannelMuteActive.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Channel } from 'stream-chat'; - -import { getOtherUserInDirectChannel } from './useChannelActions'; -import { useIsDirectChat } from './useIsDirectChat'; -import { useMutedChannels } from './useMutedChannels'; -import { useMutedUsers } from './useMutedUsers'; - -export const useChannelMuteActive = (channel: Channel) => { - const isDirectChat = useIsDirectChat(channel); - const mutedChannels = useMutedChannels(channel); - const mutedUsers = useMutedUsers(channel); - - return isDirectChat - ? !!mutedUsers.find( - (mutedUser) => getOtherUserInDirectChannel(channel)?.user?.id === mutedUser.target.id, - ) - : !!mutedChannels.find((mutedChannel) => channel.cid === mutedChannel.channel?.cid); -}; diff --git a/package/src/components/ChannelList/hooks/useCreateChannelsContext.ts b/package/src/components/ChannelList/hooks/useCreateChannelsContext.ts index 8ef2683e30..1711fb4bbf 100644 --- a/package/src/components/ChannelList/hooks/useCreateChannelsContext.ts +++ b/package/src/components/ChannelList/hooks/useCreateChannelsContext.ts @@ -23,6 +23,7 @@ export const useCreateChannelsContext = ({ reloadList, setFlatListRef, mutedStatusPosition, + pinnedStatusPosition, }: ChannelsContextValue) => { const channelValueString = channels ?.map( @@ -56,6 +57,7 @@ export const useCreateChannelsContext = ({ reloadList, setFlatListRef, mutedStatusPosition, + pinnedStatusPosition, }), // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -69,6 +71,7 @@ export const useCreateChannelsContext = ({ swipeActionsEnabled, refreshing, mutedStatusPosition, + pinnedStatusPosition, ], ); diff --git a/package/src/components/ChannelList/hooks/useMutedUsers.ts b/package/src/components/ChannelList/hooks/useMutedUsers.ts index 686d292c5c..fdab2ff06b 100644 --- a/package/src/components/ChannelList/hooks/useMutedUsers.ts +++ b/package/src/components/ChannelList/hooks/useMutedUsers.ts @@ -1,13 +1,19 @@ import { Channel, EventTypes, Mute, StreamChat } from 'stream-chat'; import { useChatContext } from '../../../contexts'; -import { useSyncClientEventsToChannel } from '../../../hooks/useSyncClientEvents'; +import { useSyncClientEvents } from '../../../hooks/useSyncClientEvents'; -const selector = (_channel: Channel, client: StreamChat) => client.mutedUsers; +const selector = (client: StreamChat) => client.mutedUsers; const keys: EventTypes[] = ['health.check', 'notification.mutes_updated']; -export function useMutedUsers(channel: Channel): Array<Mute>; -export function useMutedUsers(channel?: Channel): Array<Mute> | undefined; -export function useMutedUsers(channel?: Channel) { + +export function useMutedUsers(): Array<Mute>; +/** + * + * @param @deprecated _channel - This parameter is deprecated because it is no longer necessary. It is kept for backwards compatibility only. + * @returns + */ +export function useMutedUsers(_channel: Channel): Array<Mute> | undefined; +export function useMutedUsers(_channel?: Channel): Array<Mute> { const { client } = useChatContext(); - return useSyncClientEventsToChannel({ channel, client, selector, stateChangeEventKeys: keys }); + return useSyncClientEvents({ client, selector, stateChangeEventKeys: keys }); } diff --git a/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx b/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx index e2fde979d4..bc50f9c96c 100644 --- a/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx +++ b/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx @@ -8,20 +8,19 @@ import type { Channel } from 'stream-chat'; import { ChannelPreviewMutedStatus } from './ChannelPreviewMutedStatus'; import { ChannelPreviewTitle } from './ChannelPreviewTitle'; -import { useIsChannelMuted } from './hooks/useIsChannelMuted'; import { useBottomSheetContext } from '../../contexts/bottomSheetContext/BottomSheetContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useSwipeRegistryContext } from '../../contexts/swipeableContext/SwipeRegistryContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; +import type { ChannelActionItem } from '../../hooks/actions/useChannelActionItems'; +import { useChannelMuteActive } from '../../hooks/useChannelMuteActive'; +import { useIsDirectChat } from '../../hooks/useIsDirectChat'; import { useStableCallback } from '../../hooks/useStableCallback'; import { primitives } from '../../theme'; -import type { ChannelActionItem } from '../ChannelList/hooks/useChannelActionItems'; import { useChannelMembersState } from '../ChannelList/hooks/useChannelMembersState'; -import { useChannelMuteActive } from '../ChannelList/hooks/useChannelMuteActive'; import { useChannelOnlineMemberCount } from '../ChannelList/hooks/useChannelOnlineMemberCount'; -import { useIsDirectChat } from '../ChannelList/hooks/useIsDirectChat'; import { ChannelAvatar } from '../ui/Avatar/ChannelAvatar'; import { StreamBottomSheetModalFlatList } from '../UIComponents/StreamBottomSheetModalFlatList'; @@ -40,9 +39,7 @@ export const ChannelDetailsHeader = ({ channel }: ChannelDetailsHeaderProps) => const memberCount = useMemo(() => Object.keys(members).length, [members]); const onlineCount = useChannelOnlineMemberCount(channel); const isDirectChat = useIsDirectChat(channel); - const { muted: channelMuted } = useIsChannelMuted(channel); - const directChatUserMuted = useChannelMuteActive(channel); - const muted = isDirectChat ? directChatUserMuted : channelMuted; + const muted = useChannelMuteActive(channel); const displayedMemberCount = memberCount > 9 ? '9+' : `${memberCount}`; const displayedOnlineCount = onlineCount > 9 ? '9+' : `${onlineCount}`; const membersAndOnlineLabel = useMemo( diff --git a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx index 928b0ab77b..1e545bcbad 100644 --- a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx +++ b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx @@ -107,7 +107,7 @@ const useStyles = () => { theme: { semantics, channelPreview: { - messageDeliveryStatus: { container, text }, + messageDeliveryStatus: { container, text, username }, }, }, } = useTheme(); @@ -132,7 +132,8 @@ const useStyles = () => { fontSize: primitives.typographyFontSizeSm, fontWeight: primitives.typographyFontWeightSemiBold, lineHeight: primitives.typographyLineHeightNormal, + ...username, }, }); - }, [semantics, text, container]); + }, [semantics, text, username, container]); }; diff --git a/package/src/components/ChannelPreview/ChannelPreview.tsx b/package/src/components/ChannelPreview/ChannelPreview.tsx index 79c0839ffc..7934094bce 100644 --- a/package/src/components/ChannelPreview/ChannelPreview.tsx +++ b/package/src/components/ChannelPreview/ChannelPreview.tsx @@ -30,19 +30,37 @@ export const ChannelPreview = (props: ChannelPreviewProps) => { const client = propClient || contextClient; - const { muted, unread, lastMessage } = useChannelPreviewData(channel, client, propForceUpdate); + const { muted, pinned, unread, lastMessage } = useChannelPreviewData( + channel, + client, + propForceUpdate, + ); const translatedLastMessage = useTranslatedMessage(lastMessage); const message = translatedLastMessage ? translatedLastMessage : lastMessage; if (!swipeActionsEnabled) { - return <ChannelPreview channel={channel} muted={muted} unread={unread} lastMessage={message} />; + return ( + <ChannelPreview + channel={channel} + muted={muted} + pinned={pinned} + unread={unread} + lastMessage={message} + /> + ); } return ( <ChannelSwipableWrapper channel={channel} getChannelActionItems={getChannelActionItems}> - <ChannelPreview channel={channel} muted={muted} unread={unread} lastMessage={message} /> + <ChannelPreview + channel={channel} + muted={muted} + pinned={pinned} + unread={unread} + lastMessage={message} + /> </ChannelSwipableWrapper> ); }; diff --git a/package/src/components/ChannelPreview/ChannelPreviewPinnedStatus.tsx b/package/src/components/ChannelPreview/ChannelPreviewPinnedStatus.tsx new file mode 100644 index 0000000000..4f75599751 --- /dev/null +++ b/package/src/components/ChannelPreview/ChannelPreviewPinnedStatus.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { useA11yLabel } from '../../a11y/hooks/useA11yLabel'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { Pin } from '../../icons'; +import { CompositeAccessibilityProbe } from '../Accessibility/CompositeAccessibilityProbe'; + +/** + * This UI component displays a pinned indicator for a particular channel. + */ +export const ChannelPreviewPinnedStatus = () => { + const { + theme: { + channelPreview: { pinnedStatus }, + semantics, + }, + } = useTheme(); + const accessibilityLabel = useA11yLabel('a11y/Pinned'); + + return ( + <CompositeAccessibilityProbe label={accessibilityLabel}> + <Pin height={20} stroke={semantics.textTertiary} width={20} {...pinnedStatus} /> + </CompositeAccessibilityProbe> + ); +}; diff --git a/package/src/components/ChannelPreview/ChannelPreviewView.tsx b/package/src/components/ChannelPreview/ChannelPreviewView.tsx index d8922068a6..aa48b36776 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewView.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewView.tsx @@ -17,7 +17,10 @@ import { useStableCallback } from '../../hooks'; import { primitives } from '../../theme'; export type ChannelPreviewViewPropsWithContext = Pick<ChannelPreviewProps, 'channel'> & - Pick<ChannelsContextValue, 'maxUnreadCount' | 'onSelect' | 'mutedStatusPosition'> & { + Pick< + ChannelsContextValue, + 'maxUnreadCount' | 'onSelect' | 'mutedStatusPosition' | 'pinnedStatusPosition' + > & { /** * Formatter function for date of latest message. * @param date Message date @@ -30,6 +33,8 @@ export type ChannelPreviewViewPropsWithContext = Pick<ChannelPreviewProps, 'chan formatLatestMessageDate?: (date: Date) => string; /** If the channel is muted. */ muted?: boolean; + /** If the channel is pinned for the current user. */ + pinned?: boolean; /** Number of unread messages on the channel */ unread?: number; lastMessage?: LastMessageType; @@ -42,14 +47,17 @@ const ChannelPreviewViewWithContext = (props: ChannelPreviewViewPropsWithContext maxUnreadCount, muted, onSelect, + pinned, unread, mutedStatusPosition, + pinnedStatusPosition, lastMessage, } = props; const { ChannelPreviewAvatar, ChannelPreviewMessage, ChannelPreviewMutedStatus, + ChannelPreviewPinnedStatus, ChannelPreviewStatus, ChannelPreviewTitle, ChannelPreviewUnreadCount, @@ -111,6 +119,9 @@ const ChannelPreviewViewWithContext = (props: ChannelPreviewViewPropsWithContext {muted && mutedStatusPosition === 'inlineTitle' ? ( <ChannelPreviewMutedStatus /> ) : null} + {pinned && pinnedStatusPosition === 'inlineTitle' ? ( + <ChannelPreviewPinnedStatus /> + ) : null} </View> <View style={[styles.statusContainer, statusContainer]}> @@ -132,6 +143,9 @@ const ChannelPreviewViewWithContext = (props: ChannelPreviewViewPropsWithContext {muted && mutedStatusPosition === 'trailingBottom' ? ( <ChannelPreviewMutedStatus /> ) : null} + {pinned && pinnedStatusPosition === 'trailingBottom' ? ( + <ChannelPreviewPinnedStatus /> + ) : null} </View> </View> </Pressable> @@ -151,7 +165,8 @@ const MemoizedChannelPreviewViewWithContext = React.memo( * from the ChannelPreview component. */ export const ChannelPreviewView = (props: ChannelPreviewViewProps) => { - const { forceUpdate, maxUnreadCount, onSelect, mutedStatusPosition } = useChannelsContext(); + const { forceUpdate, maxUnreadCount, onSelect, mutedStatusPosition, pinnedStatusPosition } = + useChannelsContext(); return ( <MemoizedChannelPreviewViewWithContext {...{ @@ -159,6 +174,7 @@ export const ChannelPreviewView = (props: ChannelPreviewViewProps) => { maxUnreadCount, onSelect, mutedStatusPosition, + pinnedStatusPosition, }} {...props} /> diff --git a/package/src/components/ChannelPreview/ChannelSwipableWrapper.tsx b/package/src/components/ChannelPreview/ChannelSwipableWrapper.tsx index aad88d5646..c23eb17c26 100644 --- a/package/src/components/ChannelPreview/ChannelSwipableWrapper.tsx +++ b/package/src/components/ChannelPreview/ChannelSwipableWrapper.tsx @@ -1,19 +1,19 @@ import React, { PropsWithChildren, useCallback, useMemo, useState } from 'react'; -import { StyleSheet } from 'react-native'; +import { type ColorValue, StyleSheet } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; import type { Channel } from 'stream-chat'; -import { useIsChannelMuted } from './hooks/useIsChannelMuted'; - import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useSwipeRegistryContext } from '../../contexts/swipeableContext/SwipeRegistryContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { MenuPointHorizontal, Mute, Sound } from '../../icons'; -import type { GetChannelActionItems } from '../ChannelList/hooks/useChannelActionItems'; -import { useChannelActionItems } from '../ChannelList/hooks/useChannelActionItems'; -import { useChannelActions } from '../ChannelList/hooks/useChannelActions'; +import type { + ChannelActionItem, + GetChannelActionItems, +} from '../../hooks/actions/useChannelActionItems'; +import { useChannelActionItems } from '../../hooks/actions/useChannelActionItems'; +import { MenuPointHorizontal } from '../../icons'; import { BottomSheetModal } from '../UIComponents/BottomSheetModal'; import { RightActions, @@ -41,12 +41,19 @@ export const ChannelSwipableWrapper = ({ }>) => { const { ChannelDetailsBottomSheet: ChannelDetailsBottomSheetComponent } = useComponentsContext(); const [channelDetailSheetOpen, setChannelDetailSheetOpen] = useState(false); - const { muteChannel, unmuteChannel } = useChannelActions(channel); - const channelActionItems = useChannelActionItems({ channel, getChannelActionItems }); + const channelActionItems = useChannelActionItems({ + channel, + getChannelActionItems, + surface: 'list', + }); const sheetItems = useMemo( () => channelActionItems.filter((item) => item.placement !== 'swipe'), [channelActionItems], ); + const swipeItems = useMemo( + () => channelActionItems.filter((item) => item.placement !== 'sheet'), + [channelActionItems], + ); const swipableRegistry = useSwipeRegistryContext(); const { @@ -54,19 +61,6 @@ export const ChannelSwipableWrapper = ({ } = useTheme(); const styles = useStyles(); - const channelMuteState = useIsChannelMuted(channel); - const channelMuteActive = channelMuteState.muted; - - const Icon = useCallback( - () => - channelMuteActive ? ( - <Sound width={20} height={20} stroke={semantics.textOnAccent} /> - ) : ( - <Mute width={20} height={20} fill={semantics.textOnAccent} /> - ), - [channelMuteActive, semantics.textOnAccent], - ); - const swipableActions = useMemo<SwipableActionItem[]>(() => { const items: SwipableActionItem[] = [ { @@ -77,15 +71,16 @@ export const ChannelSwipableWrapper = ({ }, ]; - items.push({ - id: 'mute', - action: () => { - const action = channelMuteActive ? unmuteChannel : muteChannel; - action(); - swipableRegistry?.closeAll(); - }, - Content: Icon, - contentContainerStyle: [styles.contentContainerStyle, styles.standard], + swipeItems.forEach((item) => { + items.push({ + id: item.id, + action: () => { + item.action(); + swipableRegistry?.closeAll(); + }, + Content: createSwipeContent(item, semantics.textOnAccent), + contentContainerStyle: [styles.contentContainerStyle, styles.standard], + }); }); return items; @@ -93,11 +88,9 @@ export const ChannelSwipableWrapper = ({ styles.contentContainerStyle, styles.elipsis, styles.standard, - channelMuteActive, - muteChannel, - unmuteChannel, - Icon, + swipeItems, swipableRegistry, + semantics.textOnAccent, ]); const renderRightActions = useCallback( @@ -129,6 +122,11 @@ export const ChannelSwipableWrapper = ({ ); }; +const createSwipeContent = (item: ChannelActionItem, color: ColorValue) => { + const SwipeContent = () => <item.Icon fill={color} stroke={color} />; + return SwipeContent; +}; + const useStyles = () => { const { theme: { semantics }, diff --git a/package/src/components/ChannelPreview/__tests__/ChannelDetailsBottomSheet.test.tsx b/package/src/components/ChannelPreview/__tests__/ChannelDetailsBottomSheet.test.tsx index a4bf1487ab..337bb57300 100644 --- a/package/src/components/ChannelPreview/__tests__/ChannelDetailsBottomSheet.test.tsx +++ b/package/src/components/ChannelPreview/__tests__/ChannelDetailsBottomSheet.test.tsx @@ -6,7 +6,7 @@ import type { Channel } from 'stream-chat'; import { ThemeProvider, defaultTheme } from '../../../contexts'; import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; -import type { ChannelActionItem } from '../../ChannelList/hooks/useChannelActionItems'; +import type { ChannelActionItem } from '../../../hooks/actions/useChannelActionItems'; import { StreamBottomSheetModalFlatList } from '../../UIComponents/StreamBottomSheetModalFlatList'; import type { ChannelDetailsHeaderProps } from '../ChannelDetailsBottomSheet'; import { ChannelDetailsBottomSheet } from '../ChannelDetailsBottomSheet'; diff --git a/package/src/components/ChannelPreview/__tests__/ChannelSwipableWrapper.test.tsx b/package/src/components/ChannelPreview/__tests__/ChannelSwipableWrapper.test.tsx index 180a32a952..530c04f0a0 100644 --- a/package/src/components/ChannelPreview/__tests__/ChannelSwipableWrapper.test.tsx +++ b/package/src/components/ChannelPreview/__tests__/ChannelSwipableWrapper.test.tsx @@ -5,12 +5,10 @@ import { act, render } from '@testing-library/react-native'; import type { Channel } from 'stream-chat'; import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; -import type { ChannelActionItem } from '../../ChannelList/hooks/useChannelActionItems'; -import * as ChannelActionItemsModule from '../../ChannelList/hooks/useChannelActionItems'; -import * as ChannelActionsModule from '../../ChannelList/hooks/useChannelActions'; +import type { ChannelActionItem } from '../../../hooks/actions/useChannelActionItems'; +import * as ChannelActionItemsModule from '../../../hooks/actions/useChannelActionItems'; import { SwipableWrapper } from '../../UIComponents/SwipableWrapper'; import { ChannelSwipableWrapper } from '../ChannelSwipableWrapper'; -import * as UseIsChannelMutedModule from '../hooks/useIsChannelMuted'; const rightActionsProbe = { items: [] as Array<{ action: () => void; id: string }>, @@ -73,49 +71,30 @@ describe('ChannelSwipableWrapper', () => { rightActionsProbe.items = []; }); - it('uses channel mute for direct-channel swipe actions and keeps mute user in the sheet', () => { - const muteChannel = jest.fn(); - const unmuteChannel = jest.fn(); + it('renders the swipe mute action from useChannelActionItems and keeps muteUser in the sheet', () => { const customBottomSheet = jest.fn(() => null); - const items: ChannelActionItem[] = [ - { - Icon: () => null, - action: jest.fn(), - id: 'mute', - label: 'Mute User', - placement: 'sheet', - type: 'standard', - }, - { - Icon: () => null, - action: jest.fn(), - id: 'archive', - label: 'Archive Chat', - placement: 'sheet', - type: 'standard', - }, - ]; - - jest.spyOn(ChannelActionsModule, 'useChannelActions').mockReturnValue({ - archive: jest.fn(), - blockUser: jest.fn(), - deleteChannel: jest.fn(), - leave: jest.fn(), - muteChannel, - muteUser: jest.fn(), - pin: jest.fn(), - unarchive: jest.fn(), - unblockUser: jest.fn(), - unmuteChannel, - unmuteUser: jest.fn(), - unpin: jest.fn(), - }); - jest.spyOn(ChannelActionItemsModule, 'useChannelActionItems').mockReturnValue(items); - jest.spyOn(UseIsChannelMutedModule, 'useIsChannelMuted').mockReturnValue({ - createdAt: null, - expiresAt: null, - muted: false, - }); + const muteAction = jest.fn(); + const muteUserAction = jest.fn(); + const muteItem: ChannelActionItem = { + Icon: () => null, + action: muteAction, + id: 'mute', + label: 'Mute Chat', + placement: 'swipe', + type: 'standard', + }; + const muteUserItem: ChannelActionItem = { + Icon: () => null, + action: muteUserAction, + id: 'muteUser', + label: 'Mute User', + placement: 'sheet', + type: 'standard', + }; + + jest + .spyOn(ChannelActionItemsModule, 'useChannelActionItems') + .mockReturnValue([muteItem, muteUserItem]); render( <WithComponents overrides={{ ChannelDetailsBottomSheet: customBottomSheet }}> @@ -128,7 +107,7 @@ describe('ChannelSwipableWrapper', () => { expect(customBottomSheet).toHaveBeenCalledWith( expect.objectContaining({ channel, - items, + items: [muteUserItem], }), undefined, ); @@ -138,52 +117,34 @@ describe('ChannelSwipableWrapper', () => { rightActionsProbe.items[1].action(); }); - expect(muteChannel).toHaveBeenCalledTimes(1); - expect(unmuteChannel).not.toHaveBeenCalled(); + expect(muteAction).toHaveBeenCalledTimes(1); + expect(muteUserAction).not.toHaveBeenCalled(); }); - it('removes mute group from the sheet while keeping mute as the quick swipe action', () => { - const muteChannel = jest.fn(); + it('removes swipe-only items from the sheet and keeps both-placed items in both surfaces', () => { const customBottomSheet = jest.fn(() => null); - const muteItem = { + const muteAction = jest.fn(); + const archiveAction = jest.fn(); + const muteItem: ChannelActionItem = { Icon: () => null, - action: jest.fn(), + action: muteAction, id: 'mute', label: 'Mute Group', placement: 'swipe', type: 'standard', - } as const satisfies ChannelActionItem; - const archiveItem = { + }; + const archiveItem: ChannelActionItem = { Icon: () => null, - action: jest.fn(), + action: archiveAction, id: 'archive', label: 'Archive Group', placement: 'both', type: 'standard', - } as const satisfies ChannelActionItem; - - jest.spyOn(ChannelActionsModule, 'useChannelActions').mockReturnValue({ - archive: jest.fn(), - blockUser: jest.fn(), - deleteChannel: jest.fn(), - leave: jest.fn(), - muteChannel, - muteUser: jest.fn(), - pin: jest.fn(), - unarchive: jest.fn(), - unblockUser: jest.fn(), - unmuteChannel: jest.fn(), - unmuteUser: jest.fn(), - unpin: jest.fn(), - }); + }; + jest .spyOn(ChannelActionItemsModule, 'useChannelActionItems') .mockReturnValue([muteItem, archiveItem]); - jest.spyOn(UseIsChannelMutedModule, 'useIsChannelMuted').mockReturnValue({ - createdAt: null, - expiresAt: null, - muted: false, - }); render( <WithComponents overrides={{ ChannelDetailsBottomSheet: customBottomSheet }}> @@ -200,12 +161,16 @@ describe('ChannelSwipableWrapper', () => { }), undefined, ); - expect(rightActionsProbe.items.map((item) => item.id)).toEqual(['openSheet', 'mute']); + expect(rightActionsProbe.items.map((item) => item.id)).toEqual([ + 'openSheet', + 'mute', + 'archive', + ]); act(() => { rightActionsProbe.items[1].action(); }); - expect(muteChannel).toHaveBeenCalledTimes(1); + expect(muteAction).toHaveBeenCalledTimes(1); }); }); diff --git a/package/src/components/ChannelPreview/hooks/__tests__/useIsChannelPinned.test.tsx b/package/src/components/ChannelPreview/hooks/__tests__/useIsChannelPinned.test.tsx new file mode 100644 index 0000000000..34b84b6d77 --- /dev/null +++ b/package/src/components/ChannelPreview/hooks/__tests__/useIsChannelPinned.test.tsx @@ -0,0 +1,35 @@ +import { renderHook } from '@testing-library/react-native'; +import { Channel } from 'stream-chat'; + +import { useIsChannelPinned } from '../useIsChannelPinned'; + +describe('useIsChannelPinned', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + const buildMockChannel = (membership: Record<string, unknown> = {}) => + ({ + initialized: true, + on: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }), + state: { membership }, + }) as unknown as Channel; + + it('returns false when membership has no pinned_at', () => { + const channel = buildMockChannel({ pinned_at: null }); + const { result } = renderHook(() => useIsChannelPinned(channel)); + expect(result.current).toBe(false); + }); + + it('returns true when membership has a pinned_at timestamp', () => { + const channel = buildMockChannel({ pinned_at: '2026-06-15T08:00:00.000Z' }); + const { result } = renderHook(() => useIsChannelPinned(channel)); + expect(result.current).toBe(true); + }); + + it('subscribes to member.updated events', () => { + const channel = buildMockChannel({ pinned_at: null }); + renderHook(() => useIsChannelPinned(channel)); + expect(channel.on).toHaveBeenCalledWith('member.updated', expect.any(Function)); + }); +}); diff --git a/package/src/components/ChannelPreview/hooks/index.ts b/package/src/components/ChannelPreview/hooks/index.ts index 84a62ce423..a431851b56 100644 --- a/package/src/components/ChannelPreview/hooks/index.ts +++ b/package/src/components/ChannelPreview/hooks/index.ts @@ -4,4 +4,5 @@ export * from './useChannelPreviewPollLabel'; export * from './useChannelPreviewDisplayName'; export * from './useChannelPreviewDisplayPresence'; export * from './useIsChannelMuted'; +export * from './useIsChannelPinned'; export * from './useChannelTypingState'; diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts index 4c2740d8a3..bd81574bbb 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts @@ -4,6 +4,7 @@ import throttle from 'lodash/throttle'; import type { Channel, Event, LocalMessage, MessageResponse, StreamChat } from 'stream-chat'; import { useIsChannelMuted } from './useIsChannelMuted'; +import { useIsChannelPinned } from './useIsChannelPinned'; import { useChannelsContext } from '../../../contexts'; import { useStableCallback } from '../../../hooks'; @@ -39,6 +40,7 @@ export const useChannelPreviewData = ( ); const [unread, setUnread] = useState(channel.countUnread()); const { muted } = useIsChannelMuted(channel); + const pinned = useIsChannelPinned(channel); const { forceUpdate: contextForceUpdate } = useChannelsContext(); const channelListForceUpdate = forceUpdateOverride ?? contextForceUpdate; @@ -170,5 +172,5 @@ export const useChannelPreviewData = ( return () => listeners.forEach((l) => l.unsubscribe()); }, [channel, refreshUnreadCount, forceUpdate, channelListForceUpdate, setLastMessage]); - return { lastMessage, muted, unread }; + return { lastMessage, muted, pinned, unread }; }; diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts index 7b51f56370..7200cd511c 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts @@ -4,6 +4,8 @@ import type { Channel, ChannelMemberResponse } from 'stream-chat'; import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useChannelName } from '../../../hooks/useChannelName'; +import { useChannelMembersState } from '../../ChannelList/hooks/useChannelMembersState'; const getMemberName = (member: ChannelMemberResponse) => member.user?.name || member.user?.id || 'Unknown User'; @@ -12,8 +14,8 @@ export const useChannelPreviewDisplayName = (channel?: Channel) => { const { client } = useChatContext(); const { t } = useTranslationContext(); const currentUserId = client?.userID; - const members = channel?.state?.members; - const channelName = channel?.data?.name; + const members = useChannelMembersState(channel); + const channelName = useChannelName(channel); const displayName = useMemo(() => { if (channelName) { diff --git a/package/src/components/ChannelPreview/hooks/useIsChannelPinned.ts b/package/src/components/ChannelPreview/hooks/useIsChannelPinned.ts new file mode 100644 index 0000000000..083f1bcf96 --- /dev/null +++ b/package/src/components/ChannelPreview/hooks/useIsChannelPinned.ts @@ -0,0 +1,8 @@ +import type { Channel } from 'stream-chat'; + +import { useChannelMembershipState } from '../../../hooks/useChannelMembershipState'; + +export const useIsChannelPinned = (channel: Channel) => { + const membership = useChannelMembershipState(channel); + return Boolean(membership?.pinned_at); +}; diff --git a/package/src/components/ImageGallery/ImageGallery.tsx b/package/src/components/ImageGallery/ImageGallery.tsx index 439d788339..fc7bb1e10a 100644 --- a/package/src/components/ImageGallery/ImageGallery.tsx +++ b/package/src/components/ImageGallery/ImageGallery.tsx @@ -1,12 +1,5 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { - AccessibilityInfo, - Image, - ImageStyle, - Platform, - StyleSheet, - ViewStyle, -} from 'react-native'; +import React, { useCallback, useEffect, useState } from 'react'; +import { AccessibilityInfo, ImageStyle, Platform, StyleSheet, ViewStyle } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { @@ -20,15 +13,16 @@ import Animated, { import { AnimatedGalleryImage } from './components/AnimatedGalleryImage'; import { AnimatedGalleryVideo } from './components/AnimatedGalleryVideo'; +import { ImageGalleryA11yProbe } from './components/ImageGalleryA11yProbe'; import type { ImageGalleryFooterProps, ImageGalleryGridProps, ImageGalleryHeaderProps, } from './components/types'; +import { useCurrentImageHeight } from './hooks/useCurrentImageHeight'; import { useImageGalleryGestures } from './hooks/useImageGalleryGestures'; -import { useA11yLabel } from '../../a11y/hooks/useA11yLabel'; import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { @@ -73,7 +67,6 @@ export enum IsSwiping { const imageGallerySelector = (state: ImageGalleryState) => ({ assets: state.assets, - currentIndex: state.currentIndex, }); type ImageGalleryWithContextProps = Pick< @@ -102,12 +95,7 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => }, } = useTheme(); const { imageGalleryStateStore } = useImageGalleryContext(); - const { assets, currentIndex } = useStateStore( - imageGalleryStateStore.state, - imageGallerySelector, - ); - const { videoPlayerPool } = imageGalleryStateStore; - + const { assets } = useStateStore(imageGalleryStateStore.state, imageGallerySelector); const { vh, vw } = useViewport(); const fullWindowHeight = vh(100); @@ -139,9 +127,17 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => }, [showScreen]); /** - * Image height from URL or default to full screen height + * Image height for the currently selected asset. SharedValue so worklet + * consumers (gesture math, header/footer opacity) read it directly on the + * UI thread so updating it doesn't trigger a parent rerender. The hook + * owns the value and updates it via a store subscription. */ - const [currentImageHeight, setCurrentImageHeight] = useState<number>(fullWindowHeight); + const currentImageHeight = useCurrentImageHeight({ + assets, + fullWindowHeight, + fullWindowWidth, + imageGalleryStateStore, + }); /** * Header visible value for animating in out @@ -155,53 +151,20 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => const translateY = useSharedValue(0); const offsetScale = useSharedValue(1); const scale = useSharedValue(1); - const translationX = useSharedValue(-(fullWindowWidth + MARGIN) * currentIndex); + const translationX = useSharedValue( + -(fullWindowWidth + MARGIN) * imageGalleryStateStore.state.getLatestValue().currentIndex, + ); + + const currentIndexShared = imageGalleryStateStore.currentIndexShared; useAnimatedReaction( - () => currentIndex, + () => currentIndexShared.value, (index) => { translationX.value = -(fullWindowWidth + MARGIN) * index; }, - [currentIndex, fullWindowWidth], + [fullWindowWidth], ); - /** - * Image heights are not provided and therefore need to be calculated. - * We start by allowing the image to be the full height then reduce it - * to the proper scaled height based on the width being restricted to the - * screen width when the dimensions are received. - */ - useEffect(() => { - let currentImageHeight = fullWindowHeight; - const photo = assets[currentIndex]; - const height = photo?.original_height; - const width = photo?.original_width; - - if (height && width) { - const imageHeight = Math.floor(height * (fullWindowWidth / width)); - currentImageHeight = imageHeight > fullWindowHeight ? fullWindowHeight : imageHeight; - } else if (photo?.uri) { - if (photo.type === FileTypes.Image) { - Image.getSize(photo.uri, (width, height) => { - const imageHeight = Math.floor(height * (fullWindowWidth / width)); - currentImageHeight = imageHeight > fullWindowHeight ? fullWindowHeight : imageHeight; - }); - } - } - - setCurrentImageHeight(currentImageHeight); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentIndex]); - - // If you change the current index, pause the active video player. - useEffect(() => { - const activePlayer = videoPlayerPool.getActivePlayer(); - - if (activePlayer) { - activePlayer.pause(); - } - }, [currentIndex, videoPlayerPool]); - const { doubleTap, pan, pinch, singleTap } = useImageGalleryGestures({ currentImageHeight, halfScreenHeight, @@ -219,20 +182,21 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => /** * If the header is visible we scale down the opacity of it as the - * image is swiped downward + * image is swiped downward. Reads currentImageHeight from a SharedValue so + * the worklet doesn't need to be re-registered when the image dimensions + * change between slides. */ - const headerFooterOpacity = useDerivedValue( - () => - currentImageHeight * scale.value < fullWindowHeight && translateY.value > 0 - ? 1 - translateY.value / quarterScreenHeight - : currentImageHeight * scale.value > fullWindowHeight && - translateY.value > (currentImageHeight / 2) * scale.value - halfScreenHeight - ? 1 - - (translateY.value - ((currentImageHeight / 2) * scale.value - halfScreenHeight)) / - quarterScreenHeight - : 1, - [currentImageHeight], - ); + const headerFooterOpacity = useDerivedValue(() => { + const imageHeight = currentImageHeight.value; + return imageHeight * scale.value < fullWindowHeight && translateY.value > 0 + ? 1 - translateY.value / quarterScreenHeight + : imageHeight * scale.value > fullWindowHeight && + translateY.value > (imageHeight / 2) * scale.value - halfScreenHeight + ? 1 - + (translateY.value - ((imageHeight / 2) * scale.value - halfScreenHeight)) / + quarterScreenHeight + : 1; + }, []); /** * This transition and scaleX reverse lets use scroll right @@ -290,45 +254,6 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => }; const { enabled: isAccessibilityEnabled } = useAccessibilityContext(); - const assetsCount = assets.length; - const isAdjustable = isAccessibilityEnabled; - const accessibilityValueParams = useMemo( - () => ({ count: assetsCount, position: currentIndex + 1 }), - [currentIndex, assetsCount], - ); - const accessibilityValueText = useA11yLabel( - 'a11y/{{position}} of {{count}}', - accessibilityValueParams, - ); - const accessibilityValue = useMemo( - () => (accessibilityValueText ? { text: accessibilityValueText } : undefined), - [accessibilityValueText], - ); - const adjustableActions = useMemo( - () => - isAdjustable ? [{ name: 'increment' as const }, { name: 'decrement' as const }] : undefined, - [isAdjustable], - ); - - const onAccessibilityAction = useCallback( - (event: { nativeEvent: { actionName: string } }) => { - if (!isAccessibilityEnabled) return; - const latest = imageGalleryStateStore.state.getLatestValue(); - const latestCount = latest.assets.length; - const latestIndex = latest.currentIndex; - if (latestCount <= 1) return; - if (event.nativeEvent.actionName === 'increment') { - if (latestIndex < latestCount - 1) { - imageGalleryStateStore.currentIndex = latestIndex + 1; - } - } else if (event.nativeEvent.actionName === 'decrement') { - if (latestIndex > 0) { - imageGalleryStateStore.currentIndex = latestIndex - 1; - } - } - }, - [imageGalleryStateStore, isAccessibilityEnabled], - ); useEffect(() => { return () => { @@ -356,15 +281,11 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => pointerEvents={'auto'} style={[StyleSheet.absoluteFill, showScreenStyle]} > - <Animated.View - accessible - accessibilityActions={adjustableActions} - accessibilityLabel='Image Gallery' - accessibilityRole={isAdjustable ? 'adjustable' : undefined} - accessibilityValue={isAdjustable ? accessibilityValue : undefined} - onAccessibilityAction={isAdjustable ? onAccessibilityAction : undefined} - style={[StyleSheet.absoluteFill, containerBackground]} - /> + {isAccessibilityEnabled ? ( + <ImageGalleryA11yProbe containerBackground={containerBackground} /> + ) : ( + <Animated.View style={[StyleSheet.absoluteFill, containerBackground]} /> + )} <GestureDetector gesture={Gesture.Simultaneous(singleTap, doubleTap, pinch, pan)}> <Animated.View style={StyleSheet.absoluteFill}> <Animated.View diff --git a/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx index ef5a9d4765..28f0f505ff 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGallery.test.tsx @@ -89,10 +89,13 @@ describe('ImageGallery', () => { render( <ImageGalleryComponent message={generateMessage({ + // Video is placed at index 1 so it sits within the video-slide + // window (`shouldRender < 2`) — at index 2 it would render as a + // spacer instead of <AnimatedGalleryVideo>. attachments: [ generateImageAttachment(), - generateGiphyAttachment(), generateVideoAttachment({ type: 'video' }), + generateGiphyAttachment(), ], })} />, diff --git a/package/src/components/ImageGallery/__tests__/ImageGalleryAdjustable.test.tsx b/package/src/components/ImageGallery/__tests__/ImageGalleryAdjustable.test.tsx index 9a1de6739a..18389dc846 100644 --- a/package/src/components/ImageGallery/__tests__/ImageGalleryAdjustable.test.tsx +++ b/package/src/components/ImageGallery/__tests__/ImageGalleryAdjustable.test.tsx @@ -95,13 +95,14 @@ describe('ImageGallery adjustable cycling', () => { }); }); - it('does not apply the adjustable role when accessibility is disabled', async () => { + it('does not mount the a11y probe when accessibility is disabled', async () => { renderWithAssets(3, false); + // The probe is the sole element with this label; the parent ImageGallery + // renders only the visual background View when a11y is off, with no + // accessibility attributes to expose. await waitFor(() => { - const root = findGalleryRoot(); - expect(root.props.accessibilityRole).toBeUndefined(); - expect(root.props.accessibilityActions).toBeUndefined(); + expect(screen.queryByLabelText('Image Gallery', { includeHiddenElements: true })).toBeNull(); }); }); diff --git a/package/src/components/ImageGallery/components/AnimatedGalleryImage.tsx b/package/src/components/ImageGallery/components/AnimatedGalleryImage.tsx index af2a7cfcb2..6b4e474bd9 100644 --- a/package/src/components/ImageGallery/components/AnimatedGalleryImage.tsx +++ b/package/src/components/ImageGallery/components/AnimatedGalleryImage.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; import type { ImageStyle, StyleProp } from 'react-native'; import Animated, { SharedValue } from 'react-native-reanimated'; @@ -30,10 +30,6 @@ type Props = { style?: StyleProp<ImageStyle>; }; -const imageGallerySelector = (state: ImageGalleryState) => ({ - currentIndex: state.currentIndex, -}); - export const AnimatedGalleryImage = React.memo( (props: Props) => { const { @@ -50,7 +46,13 @@ export const AnimatedGalleryImage = React.memo( } = props; const { imageGalleryStateStore } = useImageGalleryContext(); const { resizableCDNHosts } = useChatConfigContext(); - const { currentIndex } = useStateStore(imageGalleryStateStore.state, imageGallerySelector); + const shouldRenderSelector = useCallback( + (state: ImageGalleryState) => ({ + shouldRender: Math.abs(state.currentIndex - index) < 4, + }), + [index], + ); + const { shouldRender } = useStateStore(imageGalleryStateStore.state, shouldRenderSelector); const uri = useMemo(() => { return getResizedImageUrl({ @@ -62,17 +64,13 @@ export const AnimatedGalleryImage = React.memo( }, [photo.uri, resizableCDNHosts, screenHeight, screenWidth]); const isSvg = useIsSvg(uri); - const selected = currentIndex === index; - const previous = currentIndex > index; - const shouldRender = Math.abs(currentIndex - index) < 4; const animatedStyles = useAnimatedGalleryStyle({ + currentIndexShared: imageGalleryStateStore.currentIndexShared, index, offsetScale, - previous, scale, screenHeight, - selected, translateX, translateY, }); diff --git a/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx b/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx index 8737b421e7..8b5e8d4b7a 100644 --- a/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx +++ b/package/src/components/ImageGallery/components/AnimatedGalleryVideo.tsx @@ -1,4 +1,4 @@ -import React, { RefObject, useEffect, useRef, useState } from 'react'; +import React, { RefObject, useCallback, useEffect, useRef, useState } from 'react'; import { StyleSheet, View, ViewStyle } from 'react-native'; import type { StyleProp } from 'react-native'; import Animated, { SharedValue } from 'react-native-reanimated'; @@ -49,19 +49,14 @@ const styles = StyleSheet.create({ }, }); -const imageGallerySelector = (state: ImageGalleryState) => ({ - currentIndex: state.currentIndex, -}); - const videoPlayerSelector = (state: VideoPlayerState) => ({ currentPlaybackRate: state.currentPlaybackRate, isPlaying: state.isPlaying, }); -export const AnimatedGalleryVideo = (props: AnimatedGalleryVideoType) => { +const AnimatedGalleryVideoComponent = (props: AnimatedGalleryVideoType) => { const [opacity, setOpacity] = useState<number>(1); const { imageGalleryStateStore } = useImageGalleryContext(); - const { currentIndex } = useStateStore(imageGalleryStateStore.state, imageGallerySelector); const { attachmentId, @@ -75,6 +70,14 @@ export const AnimatedGalleryVideo = (props: AnimatedGalleryVideoType) => { translateY, } = props; + const shouldRenderSelector = useCallback( + (state: ImageGalleryState) => ({ + shouldRender: Math.abs(state.currentIndex - index) < 2, + }), + [index], + ); + const { shouldRender } = useStateStore(imageGalleryStateStore.state, shouldRenderSelector); + const videoRef = useRef<VideoType>(null); const videoPlayer = useImageGalleryVideoPlayer({ @@ -82,14 +85,15 @@ export const AnimatedGalleryVideo = (props: AnimatedGalleryVideoType) => { }); useEffect(() => { - if (videoRef.current) { + if (videoRef.current && shouldRender) { videoPlayer.initPlayer({ playerRef: videoRef.current }); } return () => { videoPlayer.playerRef = null; + videoPlayer.onRemove(); }; - }, [videoPlayer]); + }, [videoPlayer, shouldRender]); const { isPlaying, currentPlaybackRate } = useStateStore(videoPlayer.state, videoPlayerSelector); @@ -147,17 +151,12 @@ export const AnimatedGalleryVideo = (props: AnimatedGalleryVideoType) => { } }; - const selected = currentIndex === index; - const previous = currentIndex > index; - const shouldRender = Math.abs(currentIndex - index) < 4; - const animatedStyles = useAnimatedGalleryStyle({ + currentIndexShared: imageGalleryStateStore.currentIndexShared, index, offsetScale, - previous, scale, screenHeight, - selected, translateX, translateY, }); @@ -216,4 +215,18 @@ export const AnimatedGalleryVideo = (props: AnimatedGalleryVideoType) => { ); }; +export const AnimatedGalleryVideo = React.memo( + AnimatedGalleryVideoComponent, + (prevProps, nextProps) => { + if ( + prevProps.photo.uri === nextProps.photo.uri && + prevProps.index === nextProps.index && + prevProps.screenHeight === nextProps.screenHeight + ) { + return true; + } + return false; + }, +); + AnimatedGalleryVideo.displayName = 'AnimatedGalleryVideo'; diff --git a/package/src/components/ImageGallery/components/ImageGalleryA11yProbe.tsx b/package/src/components/ImageGallery/components/ImageGalleryA11yProbe.tsx new file mode 100644 index 0000000000..ba587286dd --- /dev/null +++ b/package/src/components/ImageGallery/components/ImageGalleryA11yProbe.tsx @@ -0,0 +1,82 @@ +import React, { useCallback, useMemo } from 'react'; +import { StyleSheet, ViewStyle } from 'react-native'; +import Animated, { AnimatedStyle } from 'react-native-reanimated'; + +import { useA11yLabel } from '../../../a11y/hooks/useA11yLabel'; +import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; +import { useStateStore } from '../../../hooks'; +import { ImageGalleryState } from '../../../state-store/image-gallery-state-store'; + +const a11ySelector = (state: ImageGalleryState) => ({ + assets: state.assets, + currentIndex: state.currentIndex, +}); + +type Props = { + containerBackground: AnimatedStyle<ViewStyle>; +}; + +/** + * Accessibility-enabled variant of the gallery's background fade view. Wires + * the "X of N" announcement and the VoiceOver/TalkBack increment/decrement + * actions. Subscribes locally to currentIndex + assets so the parent + * ImageGallery doesn't need that subscription itself — only this small sibling + * re-renders to refresh the accessibilityValue text on each swipe. + * + * Caller is responsible for gating this on whether accessibility is enabled. + * When disabled, the parent renders a plain Animated.View with the same + * background style and no a11y attributes. + */ +export const ImageGalleryA11yProbe = ({ containerBackground }: Props) => { + const { imageGalleryStateStore } = useImageGalleryContext(); + const { assets, currentIndex } = useStateStore(imageGalleryStateStore.state, a11ySelector); + const assetsCount = assets.length; + + const accessibilityValueParams = useMemo( + () => ({ count: assetsCount, position: currentIndex + 1 }), + [currentIndex, assetsCount], + ); + const accessibilityValueText = useA11yLabel( + 'a11y/{{position}} of {{count}}', + accessibilityValueParams, + ); + const accessibilityValue = useMemo( + () => (accessibilityValueText ? { text: accessibilityValueText } : undefined), + [accessibilityValueText], + ); + const adjustableActions = useMemo( + () => [{ name: 'increment' as const }, { name: 'decrement' as const }], + [], + ); + + const onAccessibilityAction = useCallback( + (event: { nativeEvent: { actionName: string } }) => { + const latest = imageGalleryStateStore.state.getLatestValue(); + const latestCount = latest.assets.length; + const latestIndex = latest.currentIndex; + if (latestCount <= 1) return; + if (event.nativeEvent.actionName === 'increment') { + if (latestIndex < latestCount - 1) { + imageGalleryStateStore.currentIndex = latestIndex + 1; + } + } else if (event.nativeEvent.actionName === 'decrement') { + if (latestIndex > 0) { + imageGalleryStateStore.currentIndex = latestIndex - 1; + } + } + }, + [imageGalleryStateStore], + ); + + return ( + <Animated.View + accessible + accessibilityActions={adjustableActions} + accessibilityLabel='Image Gallery' + accessibilityRole='adjustable' + accessibilityValue={accessibilityValue} + onAccessibilityAction={onAccessibilityAction} + style={[StyleSheet.absoluteFill, containerBackground]} + /> + ); +}; diff --git a/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx b/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx index a3dd6b8cbc..83ecb9929e 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useState } from 'react'; import { StyleSheet, Text, View, ViewStyle } from 'react-native'; import Animated, { Extrapolation, interpolate, useAnimatedStyle } from 'react-native-reanimated'; +import { SafeAreaInsetsContext } from 'react-native-safe-area-context'; import type { ImageGalleryHeaderProps } from './types'; @@ -17,11 +18,6 @@ import { ImageGalleryState } from '../../../state-store/image-gallery-state-stor import { primitives } from '../../../theme'; import { getDateString } from '../../../utils/i18n/getDateString'; import { Button } from '../../ui/Button/Button'; -import { SafeAreaView } from '../../UIComponents/SafeAreaViewWrapper'; - -const ReanimatedSafeAreaView = Animated.createAnimatedComponent - ? Animated.createAnimatedComponent(SafeAreaView) - : SafeAreaView; const imageGallerySelector = (state: ImageGalleryState) => ({ asset: state.assets[state.currentIndex], @@ -35,6 +31,7 @@ export const ImageGalleryHeader = (props: ImageGalleryHeaderProps) => { const { imageGalleryStateStore } = useImageGalleryContext(); const { asset } = useStateStore(imageGalleryStateStore.state, imageGallerySelector); const { setOverlay } = useOverlayContext(); + const topInset = useContext(SafeAreaInsetsContext)?.top ?? 0; const date = useMemo( () => @@ -71,17 +68,19 @@ export const ImageGalleryHeader = (props: ImageGalleryHeaderProps) => { onLayout={(event) => setHeight(event.nativeEvent.layout.height)} pointerEvents={'box-none'} > - <ReanimatedSafeAreaView edges={['top']} style={[styles.container, headerStyle]}> + <Animated.View style={[styles.container, { paddingTop: topInset }, headerStyle]}> <View style={styles.innerContainer}> - <Button - accessibilityLabelKey='a11y/Hide Overlay' - variant='secondary' - type='ghost' - size='md' - onPress={hideOverlay} - LeadingIcon={ChevronLeft} - iconOnly - /> + <View style={styles.leftContainer}> + <Button + accessibilityLabelKey='a11y/Hide Overlay' + variant='secondary' + type='ghost' + size='md' + onPress={hideOverlay} + LeadingIcon={ChevronLeft} + iconOnly + /> + </View> <View style={styles.centerContainer} accessibilityLabel='Center element'> <Text style={styles.userName}> {asset?.user?.name || asset?.user?.id || t('Unknown User')} @@ -90,7 +89,7 @@ export const ImageGalleryHeader = (props: ImageGalleryHeaderProps) => { </View> <View style={styles.rightContainer} accessibilityLabel='Right element' /> </View> - </ReanimatedSafeAreaView> + </Animated.View> </View> ); }; @@ -114,7 +113,7 @@ const useStyles = () => { }, centerContainer: { alignItems: 'center', - flex: 1, + flex: 2, justifyContent: 'center', gap: primitives.spacingXxs, ...header.centerContainer, @@ -134,9 +133,13 @@ const useStyles = () => { borderBottomColor: semantics.borderCoreSubtle, ...header.innerContainer, }, + leftContainer: { + flex: 1, + }, rightContainer: { width: 24, height: 24, + flex: 1, }, userName: { color: semantics.textPrimary, diff --git a/package/src/components/ImageGallery/hooks/useAnimatedGalleryStyle.tsx b/package/src/components/ImageGallery/hooks/useAnimatedGalleryStyle.tsx index ff9bfce0f9..95ccc19620 100644 --- a/package/src/components/ImageGallery/hooks/useAnimatedGalleryStyle.tsx +++ b/package/src/components/ImageGallery/hooks/useAnimatedGalleryStyle.tsx @@ -4,12 +4,11 @@ import { SharedValue, useAnimatedStyle } from 'react-native-reanimated'; import { useViewport } from '../../../hooks/useViewport'; type Props = { + currentIndexShared: SharedValue<number>; index: number; offsetScale: SharedValue<number>; - previous: boolean; scale: SharedValue<number>; screenHeight: number; - selected: boolean; translateX: SharedValue<number>; translateY: SharedValue<number>; }; @@ -17,12 +16,11 @@ type Props = { const oneEighth = 1 / 8; export const useAnimatedGalleryStyle = ({ + currentIndexShared, index, offsetScale, - previous, scale, screenHeight, - selected, translateX, translateY, }: Props) => { @@ -40,6 +38,8 @@ export const useAnimatedGalleryStyle = ({ * place as to not come into the screen when the image shrinks. */ const animatedGalleryStyle = useAnimatedStyle<ImageStyle>(() => { + const selected = currentIndexShared.value === index; + const previous = currentIndexShared.value > index; const xScaleOffset = -7 * screenWidth * (0.5 + index); const yScaleOffset = -screenHeight * 3.5; return { @@ -62,7 +62,7 @@ export const useAnimatedGalleryStyle = ({ { scaleX: 1 }, ], }; - }, [previous, selected]); + }, []); const animatedStyles = useAnimatedStyle(() => { const xScaleOffset = 7 * screenWidth * (0.5 + index); diff --git a/package/src/components/ImageGallery/hooks/useCurrentImageHeight.ts b/package/src/components/ImageGallery/hooks/useCurrentImageHeight.ts new file mode 100644 index 0000000000..3f05e4dcd6 --- /dev/null +++ b/package/src/components/ImageGallery/hooks/useCurrentImageHeight.ts @@ -0,0 +1,86 @@ +import { useEffect } from 'react'; +import { Image } from 'react-native'; +import { SharedValue, useSharedValue } from 'react-native-reanimated'; + +import type { + ImageGalleryAsset, + ImageGalleryStateStore, +} from '../../../state-store/image-gallery-state-store'; +import { FileTypes } from '../../../types/types'; + +/** + * Owns a SharedValue tracking the visible height of the currently selected + * gallery asset and keeps it in lockstep with the gallery store's + * currentIndex. Worklet consumers read `.value` on the UI thread. + * + * The store is subscribed via `subscribeWithSelector` (not `useStateStore`), + * so currentIndex changes never go through React state so no rerender is + * triggered in the calling component. The callback writes directly into the + * SharedValue. + * + * Sync path uses the attachment's intrinsic `original_height`/`original_width` + * when present. Async fallback via `Image.getSize` handles assets that don't + * carry dimensions; a token guards against the closure-race where the user + * swipes past a slide before its dimensions resolve. + */ +export const useCurrentImageHeight = ({ + assets, + fullWindowHeight, + fullWindowWidth, + imageGalleryStateStore, +}: { + assets: ImageGalleryAsset[]; + fullWindowHeight: number; + fullWindowWidth: number; + imageGalleryStateStore: ImageGalleryStateStore; +}): SharedValue<number> => { + const currentImageHeight = useSharedValue(fullWindowHeight); + + useEffect(() => { + let latestToken = 0; + + const compute = (index: number) => { + const currentToken = ++latestToken; + const photo = assets[index]; + + if (photo?.original_height && photo?.original_width) { + const h = Math.floor(photo.original_height * (fullWindowWidth / photo.original_width)); + currentImageHeight.value = h > fullWindowHeight ? fullWindowHeight : h; + return; + } + + if (photo?.uri && photo.type === FileTypes.Image) { + Image.getSize( + photo.uri, + (width, height) => { + // Stale result, currentIndex moved on before getSize resolved. + if (currentToken !== latestToken) return; + if (!width || !height) { + currentImageHeight.value = fullWindowHeight; + return; + } + const imageHeight = Math.floor(height * (fullWindowWidth / width)); + currentImageHeight.value = + imageHeight > fullWindowHeight ? fullWindowHeight : imageHeight; + }, + () => { + if (currentToken !== latestToken) return; + currentImageHeight.value = fullWindowHeight; + }, + ); + return; + } + + currentImageHeight.value = fullWindowHeight; + }; + + compute(imageGalleryStateStore.state.getLatestValue().currentIndex); + + return imageGalleryStateStore.state.subscribeWithSelector( + (state) => ({ currentIndex: state.currentIndex }), + ({ currentIndex }) => compute(currentIndex), + ); + }, [assets, fullWindowHeight, fullWindowWidth, currentImageHeight, imageGalleryStateStore]); + + return currentImageHeight; +}; diff --git a/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx b/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx index b7a400bec4..33e6f1fd43 100644 --- a/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx +++ b/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useRef } from 'react'; import { Platform } from 'react-native'; import { Gesture, GestureType } from 'react-native-gesture-handler'; import { @@ -14,9 +14,7 @@ import { import { useImageGalleryContext } from '../../../contexts/imageGalleryContext/ImageGalleryContext'; import { useOverlayContext } from '../../../contexts/overlayContext/OverlayContext'; -import { useStateStore } from '../../../hooks'; import { NativeHandlers } from '../../../native'; -import { ImageGalleryState } from '../../../state-store/image-gallery-state-store'; export enum HasPinched { FALSE = 0, @@ -31,10 +29,6 @@ export enum IsSwiping { const MARGIN = 32; -const imageGallerySelector = (state: ImageGalleryState) => ({ - currentIndex: state.currentIndex, -}); - export const useImageGalleryGestures = ({ currentImageHeight, halfScreenHeight, @@ -49,7 +43,7 @@ export const useImageGalleryGestures = ({ translateY, translationX, }: { - currentImageHeight: number; + currentImageHeight: SharedValue<number>; halfScreenHeight: number; halfScreenWidth: number; headerFooterVisible: SharedValue<number>; @@ -62,20 +56,8 @@ export const useImageGalleryGestures = ({ translateY: SharedValue<number>; translationX: SharedValue<number>; }) => { - /** - * if a specific image index > 0 has been passed in - * while creating the hook, set the value of the index - * reference to its value. - * - * This makes it possible to seelct an image in the list, - * and scroll/pan as normal. Prior to this, - * it was always assumed that one started at index 0 in the - * gallery. - * */ const { imageGalleryStateStore } = useImageGalleryContext(); - const { currentIndex } = useStateStore(imageGalleryStateStore.state, imageGallerySelector); - - const [index, setIndex] = useState(currentIndex); + const currentIndexShared = imageGalleryStateStore.currentIndexShared; /** * Gesture handler refs @@ -163,13 +145,16 @@ export const useImageGalleryGestures = ({ }; const moveToNextImage = () => { - runOnJS(setIndex)(index + 1); - imageGalleryStateStore.currentIndex = index + 1; + // Read fresh — both moveToNext/Previous are invoked via runOnJS from a + // worklet, so we'd otherwise be using a value captured at hook-creation + // time. The setter mirrors into currentIndexShared. + const currentIndex = imageGalleryStateStore.state.getLatestValue().currentIndex; + imageGalleryStateStore.currentIndex = currentIndex + 1; }; const moveToPreviousImage = () => { - runOnJS(setIndex)(index - 1); - imageGalleryStateStore.currentIndex = index - 1; + const currentIndex = imageGalleryStateStore.state.getLatestValue().currentIndex; + imageGalleryStateStore.currentIndex = currentIndex - 1; }; /** @@ -221,7 +206,7 @@ export const useImageGalleryGestures = ({ * true, or false and is reset on releasing the touch */ if (isSwiping.value === IsSwiping.UNDETERMINED) { - const maxXYRatio = isAndroid ? 1 : 0.25; + const maxXYRatio = 0.25; if ( Math.abs(event.translationX / event.translationY) > maxXYRatio && (Math.abs(-halfScreenWidth * (scale.value - 1) - offsetX.value) < 3 || @@ -261,16 +246,17 @@ export const useImageGalleryGestures = ({ * If swiping down start scaling down the image for swipe * away effect */ + const imageHeight = currentImageHeight.value; scale.value = - currentImageHeight * offsetScale.value < screenHeight && translateY.value > 0 + imageHeight * offsetScale.value < screenHeight && translateY.value > 0 ? offsetScale.value * (1 - (1 / 3) * (translateY.value / screenHeight)) - : currentImageHeight * offsetScale.value > screenHeight && - translateY.value > (currentImageHeight / 2) * offsetScale.value - halfScreenHeight + : imageHeight * offsetScale.value > screenHeight && + translateY.value > (imageHeight / 2) * offsetScale.value - halfScreenHeight ? offsetScale.value * (1 - (1 / 3) * ((translateY.value - - ((currentImageHeight / 2) * offsetScale.value - halfScreenHeight)) / + ((imageHeight / 2) * offsetScale.value - halfScreenHeight)) / screenHeight)) : scale.value; @@ -287,6 +273,8 @@ export const useImageGalleryGestures = ({ const finalXPosition = event.translationX - event.velocityX * 0.3; const finalYPosition = event.translationY + event.velocityY * 0.1; + const currentIndex = currentIndexShared.value; + /** * If there is a next photo, the image is lined up to the right * edge, the swipe is to the left, and the final position is more @@ -295,7 +283,7 @@ export const useImageGalleryGestures = ({ * As we move towards the left to move to next image, the translationX value will be negative on X axis. */ if ( - index < assetsLength - 1 && + currentIndex < assetsLength - 1 && Math.abs(halfScreenWidth * (scale.value - 1) + offsetX.value) < 3 && translateX.value < 0 && finalXPosition > halfScreenWidth && @@ -303,7 +291,7 @@ export const useImageGalleryGestures = ({ ) { cancelAnimation(translationX); translationX.value = withTiming( - -(screenWidth + MARGIN) * (index + 1), + -(screenWidth + MARGIN) * (currentIndex + 1), { duration: 200, easing: Easing.out(Easing.ease), @@ -322,7 +310,7 @@ export const useImageGalleryGestures = ({ * As we move towards the right to move to previous image, the translationX value will be positive on X axis. */ } else if ( - index > 0 && + currentIndex > 0 && Math.abs(-halfScreenWidth * (scale.value - 1) + offsetX.value) < 3 && translateX.value > 0 && finalXPosition < -halfScreenWidth && @@ -330,7 +318,7 @@ export const useImageGalleryGestures = ({ ) { cancelAnimation(translationX); translationX.value = withTiming( - -(screenWidth + MARGIN) * (index - 1), + -(screenWidth + MARGIN) * (currentIndex - 1), { duration: 200, easing: Easing.out(Easing.ease), @@ -376,19 +364,20 @@ export const useImageGalleryGestures = ({ * otherwise use decay with a clamping at the edges to give the effect * the image is sliding along using velocity and friction */ + const imageHeight = currentImageHeight.value; translateY.value = - currentImageHeight * scale.value < screenHeight + imageHeight * scale.value < screenHeight ? withTiming(0, { reduceMotion: ReduceMotion.Never }) - : translateY.value > (currentImageHeight / 2) * scale.value - halfScreenHeight - ? withTiming((currentImageHeight / 2) * scale.value - halfScreenHeight, { + : translateY.value > (imageHeight / 2) * scale.value - halfScreenHeight + ? withTiming((imageHeight / 2) * scale.value - halfScreenHeight, { reduceMotion: ReduceMotion.Never, }) - : translateY.value < (-currentImageHeight / 2) * scale.value + halfScreenHeight - ? withTiming((-currentImageHeight / 2) * scale.value + halfScreenHeight) + : translateY.value < (-imageHeight / 2) * scale.value + halfScreenHeight + ? withTiming((-imageHeight / 2) * scale.value + halfScreenHeight) : withDecay({ clamp: [ - (-currentImageHeight / 2) * scale.value + halfScreenHeight, - (currentImageHeight / 2) * scale.value - halfScreenHeight, + (-imageHeight / 2) * scale.value + halfScreenHeight, + (imageHeight / 2) * scale.value - halfScreenHeight, ], deceleration: 0.99, velocity: event.velocityY, @@ -411,7 +400,7 @@ export const useImageGalleryGestures = ({ */ if ( finalYPosition > halfScreenHeight && - offsetY.value + 8 >= (currentImageHeight / 2) * scale.value - halfScreenHeight && + offsetY.value + 8 >= (imageHeight / 2) * scale.value - halfScreenHeight && isSwiping.value !== IsSwiping.TRUE && translateY.value !== 0 && !( @@ -448,7 +437,7 @@ export const useImageGalleryGestures = ({ ? withDecay({ velocity: event.velocityY, }) - : withTiming(halfScreenHeight + (currentImageHeight / 2) * scale.value, { + : withTiming(halfScreenHeight + (imageHeight / 2) * scale.value, { duration: 200, easing: Easing.out(Easing.ease), }); @@ -662,13 +651,14 @@ export const useImageGalleryGestures = ({ * edges of the screen return the photo to line up with the edges, * otherwise leave the photo in its current position */ + const imageHeight = currentImageHeight.value; translateY.value = - currentImageHeight * scale.value < screenHeight + imageHeight * scale.value < screenHeight ? withTiming(0) - : translateY.value > (currentImageHeight / 2) * scale.value - screenHeight / 2 - ? withTiming((currentImageHeight / 2) * scale.value - screenHeight / 2) - : translateY.value < (-currentImageHeight / 2) * scale.value + screenHeight / 2 - ? withTiming((-currentImageHeight / 2) * scale.value + screenHeight / 2) + : translateY.value > (imageHeight / 2) * scale.value - screenHeight / 2 + ? withTiming((imageHeight / 2) * scale.value - screenHeight / 2) + : translateY.value < (-imageHeight / 2) * scale.value + screenHeight / 2 + ? withTiming((-imageHeight / 2) * scale.value + screenHeight / 2) : translateY.value; /** @@ -724,11 +714,12 @@ export const useImageGalleryGestures = ({ duration: 200, easing: Easing.out(Easing.ease), }); - if (currentImageHeight * 2 > screenHeight) { + const imageHeight = currentImageHeight.value; + if (imageHeight * 2 > screenHeight) { const translateYTopBottom = event.absoluteY > halfScreenHeight - ? -(currentImageHeight * 2 - screenHeight) / 2 - : (currentImageHeight * 2 - screenHeight) / 2; + ? -(imageHeight * 2 - screenHeight) / 2 + : (imageHeight * 2 - screenHeight) / 2; translateY.value = withTiming(translateYTopBottom, { duration: 200, easing: Easing.out(Easing.ease), diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 57768e813e..212386b269 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -11,7 +11,7 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Portal } from 'react-native-teleport'; -import type { Attachment, LocalMessage, UserResponse } from 'stream-chat'; +import type { Attachment, LocalMessage, MentionEntity, UserResponse } from 'stream-chat'; import { useCreateMessageContext } from './hooks/useCreateMessageContext'; import { useMessageActionHandlers } from './hooks/useMessageActionHandlers'; @@ -67,6 +67,7 @@ import { setOverlayTopH, useIsOverlayActive, } from '../../state-store'; +import { primitives } from '../../theme'; import { FileTypes } from '../../types/types'; import { checkMessageEquality, @@ -93,7 +94,19 @@ export type TouchableEmitter = | 'messageReplies' | 'reactionList'; -export type TextMentionTouchableHandlerAdditionalInfo = { user?: UserResponse }; +export type TextMentionTouchableHandlerAdditionalInfo = { + /** + * The typed mention entity for the pressed mention (user / channel / here / + * role / user_group). Always populated by the default renderText pipeline; + * undefined only when a custom renderer doesn't resolve a match. + */ + mentionedEntity?: MentionEntity; + /** + * Back-compat: still populated when the mention is a user, so existing + * integrators reading `additionalInfo.user` keep working. + */ + user?: UserResponse; +}; export type TextMentionTouchableHandlerPayload = { emitter: 'textMention'; @@ -830,8 +843,20 @@ const MessageWithContext = (props: MessagePropsWithContext) => { } }, [overlayActive, message]); + const groupKey: 'single' | 'top' | 'middle' | 'bottom' | undefined = + groupStyles?.[0] === 'single' || + groupStyles?.[0] === 'top' || + groupStyles?.[0] === 'middle' || + groupStyles?.[0] === 'bottom' + ? groupStyles[0] + : undefined; + const isVeryLastBubble = + messagesContext.enableMessageGroupingByUser && + channel?.state.messages[channel.state.messages.length - 1]?.id === message.id; const styles = useStyles({ + groupKey, highlightedMessage: (isTargetedMessage || message.pinned) && !isMessageTypeDeleted, + isVeryLastBubble, }); const rect = rectRef.current; const overlayItemsAnchorRect = bubbleRect.current ?? rect; @@ -1147,34 +1172,67 @@ export const Message = (props: MessageProps) => { ); }; -const useStyles = ({ highlightedMessage }: { highlightedMessage?: boolean }) => { +const useStyles = ({ + groupKey, + highlightedMessage, + isVeryLastBubble, +}: { + groupKey: 'single' | 'top' | 'middle' | 'bottom' | undefined; + highlightedMessage?: boolean; + isVeryLastBubble: boolean; +}) => { const { - theme: { - messageItemView: { wrapper, targetedMessageContainer, blockedMessageContainer }, - screenPadding, - semantics, - }, + theme: { messageItemView, screenPadding, semantics }, } = useTheme(); + return useMemo(() => { - return StyleSheet.create({ - wrapper: { - paddingHorizontal: screenPadding, - ...(highlightedMessage - ? { backgroundColor: semantics.backgroundCoreHighlight, ...targetedMessageContainer } - : {}), - ...wrapper, + const groupStylesMap: Record<'single' | 'top' | 'middle' | 'bottom', ViewStyle> = { + single: { + paddingVertical: primitives.spacingXs, + ...messageItemView.messageGroupedSingleStyles, + }, + top: { + paddingTop: primitives.spacingXs, + paddingBottom: primitives.spacingXxs, + ...messageItemView.messageGroupedTopStyles, }, + middle: { + paddingBottom: primitives.spacingXxs, + ...messageItemView.messageGroupedMiddleStyles, + }, + bottom: { + paddingBottom: primitives.spacingXs, + ...messageItemView.messageGroupedBottomStyles, + }, + }; + + let wrapper: ViewStyle = { + paddingHorizontal: screenPadding, + ...(highlightedMessage + ? { + backgroundColor: semantics.backgroundCoreHighlight, + ...messageItemView.targetedMessageContainer, + } + : {}), + ...messageItemView.wrapper, + }; + if (groupKey) { + wrapper = { ...wrapper, ...groupStylesMap[groupKey] }; + } + if (isVeryLastBubble) { + wrapper = { + ...wrapper, + marginBottom: primitives.spacingSm, + ...messageItemView.lastMessageContainer, + }; + } + + return StyleSheet.create({ + wrapper, blockedMessageContainer: { alignItems: 'center', - ...blockedMessageContainer, + ...messageItemView.blockedMessageContainer, }, }); - }, [ - wrapper, - screenPadding, - highlightedMessage, - semantics, - targetedMessageContainer, - blockedMessageContainer, - ]); + }, [messageItemView, screenPadding, semantics, highlightedMessage, groupKey, isVeryLastBubble]); }; diff --git a/package/src/components/Message/MessageItemView/MessageItemView.tsx b/package/src/components/Message/MessageItemView/MessageItemView.tsx index b9a8b42db0..28e7719d3a 100644 --- a/package/src/components/Message/MessageItemView/MessageItemView.tsx +++ b/package/src/components/Message/MessageItemView/MessageItemView.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { Dimensions, StyleSheet, View, ViewStyle } from 'react-native'; +import { Dimensions, StyleSheet, View } from 'react-native'; import { SwipableMessageWrapper } from './MessageBubble'; @@ -22,25 +22,7 @@ import { FileTypes } from '../../../types/types'; import { checkMessageEquality, checkQuotedMessageEquality } from '../../../utils/utils'; import { useMessageData } from '../hooks/useMessageData'; -type GroupType = 'single' | 'top' | 'middle' | 'bottom' | undefined; - -const useStyles = ({ - alignment, - isVeryLastMessage, - messageGroupedSingle, - messageGroupedBottom, - messageGroupedTop, - messageGroupedMiddle, - enableMessageGroupingByUser, -}: { - alignment: Alignment; - isVeryLastMessage: boolean; - messageGroupedSingle: boolean; - messageGroupedBottom: boolean; - messageGroupedTop: boolean; - messageGroupedMiddle: boolean; - enableMessageGroupingByUser: boolean; -}) => { +const useStyles = ({ alignment }: { alignment: Alignment }) => { const { theme: { messageItemView: { @@ -53,24 +35,11 @@ const useStyles = ({ repliesContainer, leftAlignItems, rightAlignItems, - messageGroupedSingleStyles, - messageGroupedBottomStyles, - messageGroupedTopStyles, - messageGroupedMiddleStyles, - lastMessageContainer, }, }, } = useTheme(); - const groupType: GroupType = useMemo(() => { - if (messageGroupedSingle) return 'single'; - if (messageGroupedTop) return 'top'; - if (messageGroupedMiddle) return 'middle'; - if (messageGroupedBottom) return 'bottom'; - return undefined; - }, [messageGroupedSingle, messageGroupedTop, messageGroupedMiddle, messageGroupedBottom]); - - const styles = useMemo( + return useMemo( () => StyleSheet.create({ baseContainer: { @@ -129,73 +98,6 @@ const useStyles = ({ rightAlignItems, ], ); - - const groupStylesMap = useMemo(() => { - return { - single: { - paddingVertical: primitives.spacingXs, - ...messageGroupedSingleStyles, - }, - top: { - paddingTop: primitives.spacingXs, - paddingBottom: primitives.spacingXxs, - ...messageGroupedTopStyles, - }, - middle: { - paddingBottom: primitives.spacingXxs, - ...messageGroupedMiddleStyles, - }, - bottom: { - paddingBottom: primitives.spacingXs, - ...messageGroupedBottomStyles, - }, - }; - }, [ - messageGroupedBottomStyles, - messageGroupedMiddleStyles, - messageGroupedSingleStyles, - messageGroupedTopStyles, - ]); - - const containerStyle = useMemo(() => { - let results: ViewStyle = styles.baseContainer; - - if (groupType) { - results = { - ...results, - ...groupStylesMap[groupType], - }; - } - - if (isVeryLastMessage && enableMessageGroupingByUser) { - results = { - ...results, - marginBottom: primitives.spacingSm, - ...lastMessageContainer, - }; - } - - return results; - }, [ - styles.baseContainer, - groupStylesMap, - groupType, - isVeryLastMessage, - enableMessageGroupingByUser, - lastMessageContainer, - ]); - - return { - container: containerStyle, - bubbleContentContainer: styles.bubbleContentContainer, - bubbleErrorContainer: styles.bubbleErrorContainer, - bubbleReactionListTopContainer: styles.bubbleReactionListTopContainer, - bubbleWrapper: styles.bubbleWrapper, - contentContainer: styles.contentContainer, - repliesContainer: styles.repliesContainer, - leftAlignItems: styles.leftAlignItems, - rightAlignItems: styles.rightAlignItems, - }; }; export type MessageItemViewPropsWithContext = Pick< @@ -232,7 +134,6 @@ const MessageItemViewWithContext = (props: MessageItemViewPropsWithContext) => { channel, contextMenuAnchorRef, customMessageSwipeAction, - enableMessageGroupingByUser, enableSwipeToReply, groupStyles, hasAttachmentActions, @@ -273,22 +174,10 @@ const MessageItemViewWithContext = (props: MessageItemViewPropsWithContext) => { isMessageReceivedOrErrorType, isMessageTypeDeleted, isVeryLastMessage, - messageGroupedSingle, - messageGroupedBottom, - messageGroupedTop, messageGroupedSingleOrBottom, - messageGroupedMiddle, } = useMessageData({}); - const styles = useStyles({ - alignment, - isVeryLastMessage, - messageGroupedSingle, - messageGroupedBottom, - messageGroupedTop, - messageGroupedMiddle, - enableMessageGroupingByUser, - }); + const styles = useStyles({ alignment }); const groupStyle = `${alignment}_${groupStyles?.[0]?.toLowerCase?.()}`; const hasVisibleQuotedReply = !!message.quoted_message && !hasAttachmentActions; @@ -324,7 +213,7 @@ const MessageItemViewWithContext = (props: MessageItemViewPropsWithContext) => { }); const itemViewContent = ( - <View pointerEvents='box-none' style={styles.container} testID='message-item-view-wrapper'> + <View pointerEvents='box-none' style={styles.baseContainer} testID='message-item-view-wrapper'> {alignment === 'left' ? <MessageAuthor /> : null} {isMessageTypeDeleted ? ( <MessageDeleted date={message.created_at} groupStyle={groupStyle} /> diff --git a/package/src/components/Message/MessageItemView/MessageTextContainer.tsx b/package/src/components/Message/MessageItemView/MessageTextContainer.tsx index b681283b92..ff01cc5d04 100644 --- a/package/src/components/Message/MessageItemView/MessageTextContainer.tsx +++ b/package/src/components/Message/MessageItemView/MessageTextContainer.tsx @@ -166,6 +166,31 @@ const areEqual = ( return false; } + // Enhanced mention sources (channel/here/roles/groups) — without these the + // renderText cache would refresh on a new entity but this comparator would + // short-circuit it away and the highlight would not appear until something + // else re-rendered the row. + const mentionedBroadcastEqual = + prevMessage.mentioned_channel === nextMessage.mentioned_channel && + prevMessage.mentioned_here === nextMessage.mentioned_here; + if (!mentionedBroadcastEqual) { + return false; + } + + const joinIds = (values?: string[]) => (values ?? []).join('|'); + const mentionedRolesEqual = + joinIds(prevMessage.mentioned_roles) === joinIds(nextMessage.mentioned_roles); + if (!mentionedRolesEqual) { + return false; + } + + const groupIds = (m: typeof prevMessage) => + joinIds(m.mentioned_groups?.map((g) => g.id) ?? m.mentioned_group_ids); + const mentionedGroupsEqual = groupIds(prevMessage) === groupIds(nextMessage); + if (!mentionedGroupsEqual) { + return false; + } + // stringify could be an expensive operation, so lets rule out the obvious // possibilities first such as different object reference or empty objects etc. // Also keeping markdown equality check at the last to make sure other less diff --git a/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.tsx b/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.tsx index 082fcaaabb..1bbd30bace 100644 --- a/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.tsx +++ b/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.tsx @@ -223,38 +223,47 @@ describe('MessageItemView', () => { }); }); - it('applies correct styles for when group styles are not single or bottom', async () => { + it('keeps message-item-view-wrapper free of group-positional padding', async () => { const user = generateUser(); const message = generateMessage({ user }); renderMessage({ groupStyles: ['top'], message }); await waitFor(() => { - expect(screen.getByTestId('message-item-view-wrapper').props.style).toMatchObject({ + const innerStyle = screen.getByTestId('message-item-view-wrapper').props.style; + expect(innerStyle).toMatchObject({ alignItems: 'flex-end', gap: 8, flexDirection: 'row', - paddingTop: 8, - paddingBottom: 4, }); + expect(innerStyle.paddingTop).toBeUndefined(); + expect(innerStyle.paddingBottom).toBeUndefined(); }); }); - it('applies correct styles for when group styles are single/bottom and not last message', async () => { + it('hoists the per-group padding delta onto message-wrapper for top messages', async () => { const user = generateUser(); const message = generateMessage({ user }); - renderMessage({ message }); + renderMessage({ groupStyles: ['top'], message }); await waitFor(() => { - const data = screen.getByTestId('message-item-view-wrapper').props.style; + expect(screen.getByTestId('message-wrapper').props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ paddingTop: 8 })]), + ); + }); + }); - expect(data).toMatchObject({ - alignItems: 'flex-end', - gap: 8, - flexDirection: 'row', - paddingBottom: 8, - }); + it('hoists the per-group padding delta onto message-wrapper for bottom messages', async () => { + const user = generateUser(); + const message = generateMessage({ user }); + + renderMessage({ message }); + + await waitFor(() => { + expect(screen.getByTestId('message-wrapper').props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ paddingBottom: 8 })]), + ); }); }); diff --git a/package/src/components/Message/MessageItemView/utils/renderText.tsx b/package/src/components/Message/MessageItemView/utils/renderText.tsx index 9061cca175..8a8edc2cdf 100644 --- a/package/src/components/Message/MessageItemView/utils/renderText.tsx +++ b/package/src/components/Message/MessageItemView/utils/renderText.tsx @@ -27,7 +27,7 @@ import { State, } from 'simple-markdown'; -import type { LocalMessage, UserResponse } from 'stream-chat'; +import type { LocalMessage, MentionEntity, UserResponse } from 'stream-chat'; import { generateMarkdownText } from './generateMarkdownText'; @@ -152,7 +152,7 @@ const defaultMarkdownStyles: MarkdownStyle = { flexDirection: 'row', }, mentions: { - fontWeight: '700', + fontWeight: primitives.typographyFontWeightRegular, fontSize: primitives.typographyFontSizeMd, lineHeight: primitives.typographyLineHeightNormal, }, @@ -286,7 +286,7 @@ export const renderText = (params: RenderTextParams) => { }, mentions: { ...defaultMarkdownStyles.mentions, - color: semantics.accentPrimary, + color: semantics.chatTextMention, ...markdownStyles?.mentions, }, table: { @@ -404,26 +404,96 @@ export const renderText = (params: RenderTextParams) => { ); }; - // take the @ mentions and turn them into markdown? - // translate links - const { mentioned_users } = message; - const mentionedUsernames = (mentioned_users || []) - .map((user) => user.name || user.id) - .filter(Boolean) + // Collect every mention type the server sent us into a single typed list so + // the markdown rule, the lookup, and the press payload all see the same shape. + const { + mentioned_channel, + mentioned_group_ids, + mentioned_groups, + mentioned_here, + mentioned_roles, + mentioned_users, + } = message; + + const mentionEntities: MentionEntity[] = [ + ...((mentioned_users ?? []) as UserResponse[]).map( + (user) => ({ ...user, mentionType: 'user' }) as MentionEntity, + ), + ...(mentioned_channel + ? ([{ id: 'channel', mentionType: 'channel', name: 'channel' }] as MentionEntity[]) + : []), + ...(mentioned_here + ? ([{ id: 'here', mentionType: 'here', name: 'here' }] as MentionEntity[]) + : []), + ...((mentioned_roles ?? []) as string[]).map( + (role) => ({ id: role, mentionType: 'role', name: role }) as MentionEntity, + ), + ...( + (mentioned_groups ?? (mentioned_group_ids ?? []).map((id) => ({ id, name: id }))) as Array<{ + id: string; + name?: string; + }> + ).map( + (group) => + ({ + id: group.id, + mentionType: 'user_group', + name: group.name ?? group.id, + }) as MentionEntity, + ), + ]; + + // Lookup keyed by the rendered mention text (sans `@`), lowercased so we + // resolve case-insensitively. First-write-wins: if a user shares a name with + // a role/group, the user entity is preferred — same precedence the React SDK + // applies via insertion order in its plugin. + const mentionLookup = new Map<string, MentionEntity>(); + for (const entity of mentionEntities) { + const key = (entity.name ?? entity.id).toLowerCase(); + if (!mentionLookup.has(key)) mentionLookup.set(key, entity); + } + + const mentionTokens = mentionEntities + .map((entity) => entity.name ?? entity.id) + .filter((value): value is string => Boolean(value)) .sort((a, b) => b.length - a.length) - .map(escapeRegExp); - const mentionedUsers = mentionedUsernames.map((username) => `@${username}`).join('|'); - const regEx = new RegExp(`^\\B(${mentionedUsers})`, 'g'); + .map((value) => `@${escapeRegExp(value)}`) + .join('|'); + const regEx = new RegExp(`^\\B(${mentionTokens})`, 'g'); const mentionsMatchFunction: MatchFunction = (source) => regEx.exec(source); + const colorForMentionType = (mentionType: MentionEntity['mentionType']) => { + switch (mentionType) { + case 'user': + return semantics.chatTextMentionUser; + case 'channel': + case 'here': + return semantics.chatTextMentionBroadcast; + case 'role': + return semantics.chatTextMentionRole; + case 'user_group': + return semantics.chatTextMentionGroup; + default: + return semantics.chatTextMention; + } + }; + const mentionsReact: ReactNodeOutput = (node, output, { ...state }) => { - /**removes the @ prefix of username */ - const userName = node.content[0]?.content?.substring(1); + const matchedText: string | undefined = node.content[0]?.content; + const matchedName = matchedText?.substring(1) ?? ''; + const matchedEntity = mentionLookup.get(matchedName.toLowerCase()); + const mentionedUser = + matchedEntity?.mentionType === 'user' ? (matchedEntity as UserResponse) : undefined; + const mentionColor = matchedEntity + ? colorForMentionType(matchedEntity.mentionType) + : semantics.chatTextMention; + const onPress = (event: GestureResponderEvent) => { if (!preventPress && onPressParam) { onPressParam({ additionalInfo: { - user: mentioned_users?.find((user: UserResponse) => userName === user.name), + mentionedEntity: matchedEntity, + user: mentionedUser, }, emitter: 'textMention', event, @@ -434,6 +504,10 @@ export const renderText = (params: RenderTextParams) => { const onLongPress = (event: GestureResponderEvent) => { if (!preventPress && onLongPressParam) { onLongPressParam({ + additionalInfo: { + mentionedEntity: matchedEntity, + user: mentionedUser, + }, emitter: 'textMention', event, }); @@ -441,7 +515,12 @@ export const renderText = (params: RenderTextParams) => { }; return ( - <Text key={state.key} onLongPress={onLongPress} onPress={onPress} style={styles.mentions}> + <Text + key={state.key} + onLongPress={onLongPress} + onPress={onPress} + style={[styles.mentions, { color: mentionColor }]} + > {Array.isArray(node.content) ? node.content.reduce((acc, current) => acc + current.content, '') || '' : output(node.content, state)} @@ -492,7 +571,7 @@ export const renderText = (params: RenderTextParams) => { // we have no react rendering support for reflinks reflink: { match: () => null }, sublist: { react: listReact }, - ...(mentionedUsers + ...(mentionTokens ? { mentions: { match: mentionsMatchFunction, @@ -507,7 +586,7 @@ export const renderText = (params: RenderTextParams) => { return ( <Markdown - key={`${JSON.stringify(mentioned_users)}-${onlyEmojis}-${ + key={`${JSON.stringify(mentionEntities)}-${onlyEmojis}-${ messageOverlay ? JSON.stringify(markdownStyles) : undefined }-${JSON.stringify(semantics)}`} onLink={onLink} diff --git a/package/src/components/Message/hooks/useUserMuteActive.ts b/package/src/components/Message/hooks/useUserMuteActive.ts index 7b2a47d517..bb60e84641 100644 --- a/package/src/components/Message/hooks/useUserMuteActive.ts +++ b/package/src/components/Message/hooks/useUserMuteActive.ts @@ -1,12 +1,11 @@ import { UserResponse } from 'stream-chat'; -import { useChannelContext, useChatContext } from '../../../contexts'; +import { useChatContext } from '../../../contexts'; import { useMutedUsers } from '../../ChannelList/hooks/useMutedUsers'; export const useUserMuteActive = (user: UserResponse | null | undefined) => { const { client } = useChatContext(); - const { channel } = useChannelContext(); - const mutedUsers = useMutedUsers(channel); + const mutedUsers = useMutedUsers(); if (!user) { return false; diff --git a/package/src/components/MessageInput/MessageComposer.tsx b/package/src/components/MessageInput/MessageComposer.tsx index 4e74adab7f..a725177e70 100644 --- a/package/src/components/MessageInput/MessageComposer.tsx +++ b/package/src/components/MessageInput/MessageComposer.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useMemo } from 'react'; -import { Modal, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; -import { GestureHandlerRootView } from 'react-native-gesture-handler'; import Animated, { Extrapolation, interpolate, @@ -52,9 +51,9 @@ import { MessageInputHeightState } from '../../state-store/message-input-height- import { primitives } from '../../theme'; import { transitions } from '../../utils/animations/transitions'; import { type TextInputOverrideComponent } from '../AutoCompleteInput/AutoCompleteInput'; +import { PollModal } from '../Poll/components/PollModal'; import { CreatePoll } from '../Poll/CreatePollContent'; import { PortalWhileClosingView } from '../UIComponents/PortalWhileClosingView'; -import { SafeAreaViewWrapper } from '../UIComponents/SafeAreaViewWrapper'; const useStyles = () => { const { @@ -67,16 +66,6 @@ const useStyles = () => { flexShrink: 1, minWidth: 0, }, - pollModalWrapper: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - backgroundColor: semantics.backgroundCoreElevation1, - }, - pollSafeArea: { - flex: 1, - backgroundColor: semantics.backgroundCoreElevation1, - }, container: { alignItems: 'center', flexDirection: 'row', @@ -129,7 +118,7 @@ const useStyles = () => { shadowRadius: 12, }, suggestionsListContainer: { - backgroundColor: semantics.backgroundCoreElevation1, + backgroundColor: 'transparent', position: 'absolute', width: '100%', }, @@ -211,7 +200,6 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { AudioRecordingInProgress, AudioRecordingLockIndicator, AudioRecordingPreview, - AutoCompleteSuggestionList, Input, InputView, MessageComposerLeadingView, @@ -238,7 +226,6 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { inputBoxWrapper, inputContainer, inputFloatingContainer, - suggestionsListContainer: { container: suggestionListContainer }, wrapper, }, }, @@ -366,7 +353,12 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { layout: { height: newHeight }, }, }) => { - messageInputHeightStore.setHeight(newHeight); + messageInputHeightStore.setHeight( + newHeight - + (selectedPicker && !isKeyboardVisible + ? attachmentPickerBottomSheetHeight - bottomInset + : 0), + ); }} style={ messageInputFloating @@ -448,32 +440,17 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { <MessageComposerTrailingView /> )} </View> - <View - style={[styles.suggestionsListContainer, { bottom: height }, suggestionListContainer]} - > - <AutoCompleteSuggestionList /> - </View> </PortalWhileClosingView> </Animated.View> {showPollCreationDialog ? ( - <View style={styles.pollModalWrapper}> - <Modal - animationType='slide' - onRequestClose={closePollCreationDialog} - visible={showPollCreationDialog} - > - <GestureHandlerRootView style={styles.pollSafeArea}> - <SafeAreaViewWrapper style={styles.pollSafeArea}> - <CreatePoll - closePollCreationDialog={closePollCreationDialog} - createPollOptionGap={createPollOptionGap} - sendMessage={sendMessage} - /> - </SafeAreaViewWrapper> - </GestureHandlerRootView> - </Modal> - </View> + <PollModal onRequestClose={closePollCreationDialog} visible={showPollCreationDialog}> + <CreatePoll + closePollCreationDialog={closePollCreationDialog} + createPollOptionGap={createPollOptionGap} + sendMessage={sendMessage} + /> + </PollModal> ) : null} </MicPositionProvider> ); diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 57ae443481..55dd181241 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -60,6 +60,7 @@ import { MessageInputHeightState } from '../../state-store/message-input-height- import { primitives } from '../../theme'; import { transitions } from '../../utils/animations/transitions'; import { MessageWrapper } from '../Message/MessageItemView/MessageWrapper'; +import { PortalWhileClosingView } from '../UIComponents/PortalWhileClosingView'; type FlashListContextApi = { getRef?: () => FlashListRef<LocalMessage> | null } | undefined; @@ -297,6 +298,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => threadList = false, } = props; const { + AutoCompleteSuggestionList, EmptyStateIndicator, MessageListLoadingIndicator: LoadingIndicator, NetworkDownIndicator, @@ -1135,6 +1137,22 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => /> </View> ) : null} + <Animated.View + layout={transitions.layout200} + style={[ + { + bottom: messageInputFloating ? messageInputHeight + 16 : 0, + }, + styles.suggestionsListContainer, + ]} + > + <PortalWhileClosingView + portalHostName='overlay-suggestion-list' + portalName='autocomplete-suggestion-list' + > + <AutoCompleteSuggestionList /> + </PortalWhileClosingView> + </Animated.View> <NotificationList bottomOffset={messageInputFloating ? messageInputHeight + 16 : undefined} /> </View> ); @@ -1279,6 +1297,9 @@ const useStyles = () => { scrollToBottomButtonContainer, unreadMessagesNotificationContainer, }, + messageComposer: { + suggestionsListContainer: { container: suggestionListContainer }, + }, }, } = useTheme(); @@ -1287,6 +1308,12 @@ const useStyles = () => { return useMemo( () => StyleSheet.create({ + suggestionsListContainer: { + backgroundColor: 'transparent', + position: 'absolute', + width: '100%', + ...suggestionListContainer, + }, container: { flex: 1, width: '100%', @@ -1338,6 +1365,7 @@ const useStyles = () => { scrollToBottomButtonContainer, stickyHeaderContainer, unreadMessagesNotificationContainer, + suggestionListContainer, ], ); }; diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 4c27fc5665..ffb77698ef 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -74,6 +74,7 @@ import { primitives } from '../../theme'; import { transitions } from '../../utils/animations/transitions'; import { useIncomingMessageAnnouncements } from '../Accessibility/hooks/useIncomingMessageAnnouncements'; import { MessageWrapper } from '../Message/MessageItemView/MessageWrapper'; +import { PortalWhileClosingView } from '../UIComponents'; // This is just to make sure that the scrolling happens in a different task queue. // TODO: Think if we really need this and strive to remove it if we can. @@ -92,6 +93,9 @@ const useStyles = () => { scrollToBottomButtonContainer, unreadMessagesNotificationContainer, }, + messageComposer: { + suggestionsListContainer: { container: suggestionListContainer }, + }, }, } = useTheme(); @@ -100,6 +104,12 @@ const useStyles = () => { return useMemo( () => StyleSheet.create({ + suggestionsListContainer: { + backgroundColor: 'transparent', + position: 'absolute', + width: '100%', + ...suggestionListContainer, + }, container: { flex: 1, width: '100%', @@ -151,6 +161,7 @@ const useStyles = () => { scrollToBottomButtonContainer, stickyHeaderContainer, unreadMessagesNotificationContainer, + suggestionListContainer, ], ); }; @@ -360,6 +371,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { TypingIndicator, TypingIndicatorContainer, UnreadMessagesNotification, + AutoCompleteSuggestionList, } = useComponentsContext(); const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState<boolean>(false); const { theme } = useTheme(); @@ -1363,6 +1375,22 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { /> </View> ) : null} + <Animated.View + layout={transitions.layout200} + style={[ + { + bottom: messageInputFloating ? messageInputHeight + 16 : 0, + }, + styles.suggestionsListContainer, + ]} + > + <PortalWhileClosingView + portalHostName='overlay-suggestion-list' + portalName='autocomplete-suggestion-list' + > + <AutoCompleteSuggestionList /> + </PortalWhileClosingView> + </Animated.View> <NotificationList bottomOffset={messageInputFloating ? messageInputHeight + 16 : undefined} /> </View> ); diff --git a/package/src/components/MessageMenu/MessageActionList.tsx b/package/src/components/MessageMenu/MessageActionList.tsx index 4a77f55a50..2296041a3c 100644 --- a/package/src/components/MessageMenu/MessageActionList.tsx +++ b/package/src/components/MessageMenu/MessageActionList.tsx @@ -78,7 +78,7 @@ const useStyles = () => { StyleSheet.create({ container: { borderRadius: primitives.radiusLg, - marginTop: 6, + marginTop: 8, backgroundColor: semantics.backgroundCoreElevation2, borderWidth: 1, borderColor: semantics.borderCoreDefault, diff --git a/package/src/components/Notifications/notificationTarget.ts b/package/src/components/Notifications/notificationTarget.ts index 2a4611e262..874345fded 100644 --- a/package/src/components/Notifications/notificationTarget.ts +++ b/package/src/components/Notifications/notificationTarget.ts @@ -1,6 +1,12 @@ import type { Notification } from 'stream-chat'; -const NOTIFICATION_TARGET_PANELS = ['channel', 'thread', 'channel-list', 'thread-list'] as const; +const NOTIFICATION_TARGET_PANELS = [ + 'channel', + 'thread', + 'channel-list', + 'thread-list', + 'channel-details', +] as const; const NOTIFICATION_TARGET_TAG_PREFIX = 'target:' as const; /** Built-in SDK surfaces that can host snackbar notifications. */ diff --git a/package/src/components/Poll/components/CreatePollHeader.tsx b/package/src/components/Poll/components/CreatePollHeader.tsx index 33c91a4e5e..fe5d31086b 100644 --- a/package/src/components/Poll/components/CreatePollHeader.tsx +++ b/package/src/components/Poll/components/CreatePollHeader.tsx @@ -4,7 +4,7 @@ import { StyleSheet, Text, View } from 'react-native'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; import { Check, IconProps } from '../../../icons'; -import { ArrowLeft } from '../../../icons/arrow-left'; +import { Cross } from '../../../icons/xmark-1'; import { primitives } from '../../../theme'; import { Button } from '../../ui'; import { useCanCreatePoll } from '../hooks/useCanCreatePoll'; @@ -43,9 +43,16 @@ export const CreatePollHeader = ({ const renderSendPollIcon = useCallback( (props: IconProps) => { - return <Check {...props} height={18} stroke={semantics.textOnAccent} width={18} />; + return ( + <Check + {...props} + height={18} + stroke={canCreatePoll ? semantics.textOnAccent : semantics.textDisabled} + width={18} + /> + ); }, - [semantics.textOnAccent], + [canCreatePoll, semantics.textOnAccent, semantics.textDisabled], ); return ( @@ -54,9 +61,9 @@ export const CreatePollHeader = ({ accessibilityLabelKey='a11y/Close poll creation' variant='secondary' onPress={onBackPressHandler} - type='ghost' + type='outline' size='md' - LeadingIcon={ArrowLeft} + LeadingIcon={Cross} iconOnly /> diff --git a/package/src/components/Poll/components/PollButtons.tsx b/package/src/components/Poll/components/PollButtons.tsx index 2fbb4298f1..cde38d1ffc 100644 --- a/package/src/components/Poll/components/PollButtons.tsx +++ b/package/src/components/Poll/components/PollButtons.tsx @@ -1,10 +1,10 @@ import React, { useCallback, useMemo } from 'react'; -import { Modal, StyleSheet, View } from 'react-native'; -import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { StyleSheet, View } from 'react-native'; import { GenericPollButton, PollButtonProps } from './Button'; import { PollAnswersList } from './PollAnswersList'; import { PollInputDialog } from './PollInputDialog'; +import { PollModal } from './PollModal'; import { PollModalHeader } from './PollModalHeader'; import { PollAllOptions } from './PollOption'; import { PollResults } from './PollResults'; @@ -12,7 +12,6 @@ import { PollResults } from './PollResults'; import { useChatContext, usePollContext, useTheme, useTranslationContext } from '../../../contexts'; import { primitives } from '../../../theme'; import { defaultPollOptionCount } from '../../../utils/constants'; -import { SafeAreaViewWrapper } from '../../UIComponents/SafeAreaViewWrapper'; import { useAddCommentOpen, useAllCommentsOpen, @@ -51,14 +50,10 @@ export const ViewResultsButton = (props: PollButtonProps) => { type='outline' /> {showResults ? ( - <Modal animationType='slide' onRequestClose={closeViewResults} visible={showResults}> - <GestureHandlerRootView style={styles.modalRoot}> - <SafeAreaViewWrapper style={styles.safeArea}> - <PollModalHeader onPress={closeViewResults} title={t('Poll Results')} /> - <PollResults message={message} poll={poll} /> - </SafeAreaViewWrapper> - </GestureHandlerRootView> - </Modal> + <PollModal onRequestClose={closeViewResults} visible={showResults}> + <PollModalHeader onPress={closeViewResults} title={t('Poll Results')} /> + <PollResults message={message} poll={poll} /> + </PollModal> ) : null} </> ); @@ -81,8 +76,6 @@ export const ShowAllOptionsButton = (props: PollButtonProps) => { openAllOptions(); }, [message, onPress, openAllOptions, poll]); - const styles = useStyles(); - return ( <> {options && options.length > defaultPollOptionCount ? ( @@ -92,14 +85,10 @@ export const ShowAllOptionsButton = (props: PollButtonProps) => { /> ) : null} {showAllOptions ? ( - <Modal animationType='slide' onRequestClose={closeAllOptions} visible={showAllOptions}> - <GestureHandlerRootView style={styles.modalRoot}> - <SafeAreaViewWrapper style={styles.safeArea}> - <PollModalHeader onPress={closeAllOptions} title={t('Poll Options')} /> - <PollAllOptions message={message} poll={poll} /> - </SafeAreaViewWrapper> - </GestureHandlerRootView> - </Modal> + <PollModal onRequestClose={closeAllOptions} visible={showAllOptions}> + <PollModalHeader onPress={closeAllOptions} title={t('Poll Options')} /> + <PollAllOptions message={message} poll={poll} /> + </PollModal> ) : null} </> ); @@ -122,8 +111,6 @@ export const ShowAllCommentsButton = (props: PollButtonProps) => { openAllComments(); }, [message, onPress, openAllComments, poll]); - const styles = useStyles(); - return ( <> {answersCount && answersCount > 0 ? ( @@ -133,14 +120,10 @@ export const ShowAllCommentsButton = (props: PollButtonProps) => { /> ) : null} {showAnswers ? ( - <Modal animationType='slide' onRequestClose={closeAllComments} visible={showAnswers}> - <GestureHandlerRootView style={styles.modalRoot}> - <SafeAreaViewWrapper style={styles.safeArea}> - <PollModalHeader onPress={closeAllComments} title={t('Poll Comments')} /> - <PollAnswersList message={message} poll={poll} /> - </SafeAreaViewWrapper> - </GestureHandlerRootView> - </Modal> + <PollModal onRequestClose={closeAllComments} visible={showAnswers}> + <PollModalHeader onPress={closeAllComments} title={t('Poll Comments')} /> + <PollAnswersList message={message} poll={poll} /> + </PollModal> ) : null} </> ); @@ -254,9 +237,6 @@ const useStyles = () => { return useMemo(() => { return StyleSheet.create({ buttonsContainer: { gap: primitives.spacingXs }, - modalRoot: { - flex: 1, - }, endVoteButton: { borderColor: isPollCreatedByClient ? semantics.chatBorderOnChatOutgoing @@ -267,10 +247,6 @@ const useStyles = () => { ? semantics.chatBorderOnChatOutgoing : semantics.chatBorderOnChatIncoming, }, - safeArea: { - backgroundColor: semantics.backgroundCoreElevation1, - flex: 1, - }, }); }, [semantics, isPollCreatedByClient]); }; diff --git a/package/src/components/Poll/components/PollInputDialog.tsx b/package/src/components/Poll/components/PollInputDialog.tsx index 2099aed082..dd74ebe8ce 100644 --- a/package/src/components/Poll/components/PollInputDialog.tsx +++ b/package/src/components/Poll/components/PollInputDialog.tsx @@ -72,19 +72,11 @@ export const PollInputDialog = ({ /> </View> <View style={[styles.buttonContainer, buttonContainer]}> - <Button - variant={'secondary'} - type={'ghost'} - label={t('Cancel')} - size='md' - onPress={closeDialog} - style={styles.button} - /> <Button variant={'primary'} type={'solid'} label={t('Send')} - size='md' + size='lg' onPress={() => { onSubmit(dialogInput); closeDialog(); @@ -92,6 +84,14 @@ export const PollInputDialog = ({ style={styles.button} disabled={!dialogInput} /> + <Button + variant={'secondary'} + type={'outline'} + label={t('Cancel')} + size='lg' + onPress={closeDialog} + style={styles.button} + /> </View> </Animated.View> </KeyboardAvoidingView> @@ -112,8 +112,8 @@ const useStyles = () => { return useMemo( () => StyleSheet.create({ - button: { flex: 1, width: undefined, ...button }, - buttonContainer: { flexDirection: 'row', gap: primitives.spacingXs }, + button: { width: undefined, ...button }, + buttonContainer: { gap: primitives.spacingXs }, container: { backgroundColor: semantics.backgroundCoreElevation1, borderRadius: primitives.radiusXl, @@ -132,7 +132,7 @@ const useStyles = () => { input: { alignItems: 'center', borderColor: semantics.borderUtilityActive, - borderRadius: primitives.radiusMd, + borderRadius: primitives.radiusLg, borderWidth: 1, fontSize: primitives.typographyFontSizeMd, padding: primitives.spacingSm, diff --git a/package/src/components/Poll/components/PollModal.tsx b/package/src/components/Poll/components/PollModal.tsx new file mode 100644 index 0000000000..4d995fce0f --- /dev/null +++ b/package/src/components/Poll/components/PollModal.tsx @@ -0,0 +1,55 @@ +import React, { PropsWithChildren, useMemo } from 'react'; +import { Modal, ModalProps, StyleSheet } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; + +import { useTheme } from '../../../contexts'; +import { SafeAreaViewWrapper } from '../../UIComponents/SafeAreaViewWrapper'; + +export type PollModalProps = PropsWithChildren<{ + animationType?: ModalProps['animationType']; + onRequestClose?: () => void; + visible?: boolean; +}>; + +export const PollModal = ({ + animationType = 'slide', + children, + onRequestClose, + visible, +}: PollModalProps) => { + const styles = useStyles(); + + return ( + <Modal + animationType={animationType} + navigationBarTranslucent + onRequestClose={onRequestClose} + presentationStyle='pageSheet' + statusBarTranslucent + visible={visible} + > + <GestureHandlerRootView style={styles.root}> + <SafeAreaViewWrapper style={styles.safeArea}>{children}</SafeAreaViewWrapper> + </GestureHandlerRootView> + </Modal> + ); +}; + +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + root: { + flex: 1, + }, + safeArea: { + flex: 1, + backgroundColor: semantics.backgroundCoreElevation1, + }, + }), + [semantics], + ); +}; diff --git a/package/src/components/Poll/components/PollModalHeader.tsx b/package/src/components/Poll/components/PollModalHeader.tsx index ed4d57e54a..9bf3a3c455 100644 --- a/package/src/components/Poll/components/PollModalHeader.tsx +++ b/package/src/components/Poll/components/PollModalHeader.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { useTheme } from '../../../contexts'; -import { ArrowLeft } from '../../../icons/arrow-left'; +import { Cross } from '../../../icons/xmark-1'; import { primitives } from '../../../theme'; import { Button } from '../../ui'; @@ -27,10 +27,10 @@ export const PollModalHeader = ({ onPress, title }: PollModalHeaderProps) => { <Button accessibilityLabelKey='a11y/Close poll' variant='secondary' - type='ghost' + type='outline' size='md' iconOnly - LeadingIcon={ArrowLeft} + LeadingIcon={Cross} onPress={onPress} testID='poll-results-close-button' /> @@ -56,8 +56,7 @@ const useStyles = () => { alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between', - paddingHorizontal: primitives.spacingMd, - paddingVertical: 10, + padding: primitives.spacingMd, backgroundColor: semantics.backgroundCoreElevation1, }, centerContainer: { diff --git a/package/src/components/Poll/components/PollResults/PollResultItem.tsx b/package/src/components/Poll/components/PollResults/PollResultItem.tsx index 5c8e02570f..ddddfd2dbb 100644 --- a/package/src/components/Poll/components/PollResults/PollResultItem.tsx +++ b/package/src/components/Poll/components/PollResults/PollResultItem.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useMemo, useState } from 'react'; -import { Modal, StyleSheet, Text, View } from 'react-native'; -import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { StyleSheet, Text, View } from 'react-native'; import { LocalMessage, Poll, PollOption, PollVote as PollVoteClass } from 'stream-chat'; @@ -15,9 +14,9 @@ import { } from '../../../../contexts'; import { primitives } from '../../../../theme'; -import { SafeAreaViewWrapper } from '../../../UIComponents/SafeAreaViewWrapper'; import { usePollState } from '../../hooks/usePollState'; import { GenericPollButton } from '../Button'; +import { PollModal } from '../PollModal'; import { PollModalHeader } from '../PollModalHeader'; export type ShowAllVotesButtonProps = { @@ -62,18 +61,14 @@ export const ShowAllVotesButton = (props: ShowAllVotesButtonProps) => { </View> ) : null} {showAllVotes ? ( - <Modal + <PollModal animationType='fade' onRequestClose={() => setShowAllVotes(false)} visible={showAllVotes} > - <GestureHandlerRootView style={styles.modalRoot}> - <SafeAreaViewWrapper style={styles.safeArea}> - <PollModalHeader onPress={() => setShowAllVotes(false)} title={t('Votes')} /> - <PollOptionFullResults message={message} option={option} poll={poll} /> - </SafeAreaViewWrapper> - </GestureHandlerRootView> - </Modal> + <PollModalHeader onPress={() => setShowAllVotes(false)} title={t('Votes')} /> + <PollOptionFullResults message={message} option={option} poll={poll} /> + </PollModal> ) : null} </> ); @@ -156,9 +151,6 @@ const useStyles = () => { alignItems: 'center', paddingBottom: primitives.spacingXs, }, - modalRoot: { - flex: 1, - }, title: { flex: 1, fontSize: primitives.typographyFontSizeLg, @@ -183,10 +175,6 @@ const useStyles = () => { marginStart: primitives.spacingMd, textAlign: 'left', }, - safeArea: { - backgroundColor: semantics.backgroundCoreElevation1, - flex: 1, - }, inlineButton: { borderColor: semantics.borderCoreDefault, borderTopWidth: 1, diff --git a/package/src/components/Poll/components/__tests__/CreatePollHeader.test.tsx b/package/src/components/Poll/components/__tests__/CreatePollHeader.test.tsx index ebb8459fa6..44f78eb8ec 100644 --- a/package/src/components/Poll/components/__tests__/CreatePollHeader.test.tsx +++ b/package/src/components/Poll/components/__tests__/CreatePollHeader.test.tsx @@ -43,39 +43,8 @@ const collectPathData = (node: unknown): string[] => { return [...(typeof props?.d === 'string' ? [props.d] : []), ...collectPathData(children)]; }; -const collectTransforms = (node: unknown): string[] => { - if (!node || typeof node !== 'object') { - return []; - } - - if (Array.isArray(node)) { - return node.reduce<string[]>((acc, child) => [...acc, ...collectTransforms(child)], []); - } - - const { children, props } = node as { - children?: unknown; - props?: { style?: StyleProp<ViewStyle> }; - }; - const style = StyleSheet.flatten(props?.style); - const styleTransforms = Array.isArray(style?.transform) - ? style.transform.flatMap((transform) => { - if ( - transform && - typeof transform === 'object' && - 'rotate' in transform && - typeof transform.rotate === 'string' - ) { - return [transform.rotate]; - } - return []; - }) - : []; - - return [...styleTransforms, ...collectTransforms(children)]; -}; - describe('CreatePollHeader', () => { - it('renders a secondary ghost arrow-left close button', () => { + it('renders a secondary outline cross close button', () => { render( <ThemeProvider> <CreatePollHeader onBackPressHandler={jest.fn()} onCreatePollPressHandler={jest.fn()} /> @@ -84,11 +53,10 @@ describe('CreatePollHeader', () => { const style = getCloseButtonWrapperStyle(); expect(style.backgroundColor).toBeUndefined(); - expect(style.borderWidth).toBeUndefined(); - expect(style.borderColor).toBeUndefined(); + expect(style.borderWidth).toBe(1); + expect(style.borderColor).toBeDefined(); expect(collectPathData(screen.toJSON())).toContain( - 'M10 16.875V3.125M10 3.125L4.375 8.75M10 3.125L15.625 8.75', + 'M15.625 4.375L4.375 15.625M15.625 15.625L4.375 4.375', ); - expect(collectTransforms(screen.toJSON())).toContain('-90deg'); }); }); diff --git a/package/src/components/Poll/components/__tests__/PollModalHeader.test.tsx b/package/src/components/Poll/components/__tests__/PollModalHeader.test.tsx index 3f0a6d3178..8f29d12377 100644 --- a/package/src/components/Poll/components/__tests__/PollModalHeader.test.tsx +++ b/package/src/components/Poll/components/__tests__/PollModalHeader.test.tsx @@ -47,41 +47,6 @@ const collectPathData = (node: unknown): string[] => { return [...(typeof props?.d === 'string' ? [props.d] : []), ...collectPathData(children)]; }; -const collectTransforms = (node: unknown): string[] => { - if (!node || typeof node !== 'object') { - return []; - } - - if (Array.isArray(node)) { - return node.reduce<string[]>((acc, child) => [...acc, ...collectTransforms(child)], []); - } - - const { children, props } = node as { - children?: unknown; - props?: { style?: StyleProp<ViewStyle>; transform?: unknown }; - }; - const style = StyleSheet.flatten(props?.style); - const styleTransforms = Array.isArray(style?.transform) - ? style.transform.flatMap((transform) => { - if ( - transform && - typeof transform === 'object' && - 'rotate' in transform && - typeof transform.rotate === 'string' - ) { - return [transform.rotate]; - } - return []; - }) - : []; - - return [ - ...(typeof props?.transform === 'string' ? [props.transform] : []), - ...styleTransforms, - ...collectTransforms(children), - ]; -}; - const renderPollModalHeader = () => render( <ThemeProvider> @@ -94,33 +59,31 @@ describe('PollModalHeader', () => { setPlatform(originalPlatform); }); - it('renders a secondary ghost arrow-left button outside Android', () => { + it('renders a secondary outline cross button outside Android', () => { setPlatform('ios'); renderPollModalHeader(); const style = getCloseButtonWrapperStyle(); expect(style.backgroundColor).toBeUndefined(); - expect(style.borderWidth).toBeUndefined(); - expect(style.borderColor).toBeUndefined(); + expect(style.borderWidth).toBe(1); + expect(style.borderColor).toBeDefined(); expect(collectPathData(screen.toJSON())).toContain( - 'M10 16.875V3.125M10 3.125L4.375 8.75M10 3.125L15.625 8.75', + 'M15.625 4.375L4.375 15.625M15.625 15.625L4.375 4.375', ); - expect(collectTransforms(screen.toJSON())).toContain('-90deg'); }); - it('renders a secondary ghost arrow-left button on Android', () => { + it('renders a secondary outline cross button on Android', () => { setPlatform('android'); renderPollModalHeader(); const style = getCloseButtonWrapperStyle(); expect(style.backgroundColor).toBeUndefined(); - expect(style.borderWidth).toBeUndefined(); - expect(style.borderColor).toBeUndefined(); + expect(style.borderWidth).toBe(1); + expect(style.borderColor).toBeDefined(); expect(collectPathData(screen.toJSON())).toContain( - 'M10 16.875V3.125M10 3.125L4.375 8.75M10 3.125L15.625 8.75', + 'M15.625 4.375L4.375 15.625M15.625 15.625L4.375 4.375', ); - expect(collectTransforms(screen.toJSON())).toContain('-90deg'); }); }); diff --git a/package/src/components/Poll/components/index.ts b/package/src/components/Poll/components/index.ts index 5cfc99b017..b64e450952 100644 --- a/package/src/components/Poll/components/index.ts +++ b/package/src/components/Poll/components/index.ts @@ -6,4 +6,5 @@ export * from './PollInputDialog'; export * from './MultipleVotesSettings'; export * from './PollOption'; export * from './PollResults'; +export * from './PollModal'; export * from './PollModalHeader'; diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap index f905a03e95..e6b6c694a3 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap @@ -337,6 +337,7 @@ exports[`Thread should match thread snapshot 1`] = ` ], { "paddingHorizontal": 16, + "paddingVertical": 8, }, ] } @@ -370,7 +371,6 @@ exports[`Thread should match thread snapshot 1`] = ` "alignItems": "flex-end", "flexDirection": "row", "gap": 8, - "paddingVertical": 8, "width": "100%", } } @@ -671,6 +671,7 @@ exports[`Thread should match thread snapshot 1`] = ` ], { "paddingHorizontal": 16, + "paddingVertical": 8, }, ] } @@ -704,7 +705,6 @@ exports[`Thread should match thread snapshot 1`] = ` "alignItems": "flex-end", "flexDirection": "row", "gap": 8, - "paddingVertical": 8, "width": "100%", } } @@ -1039,6 +1039,7 @@ exports[`Thread should match thread snapshot 1`] = ` ], { "paddingHorizontal": 16, + "paddingVertical": 8, }, ] } @@ -1072,7 +1073,6 @@ exports[`Thread should match thread snapshot 1`] = ` "alignItems": "flex-end", "flexDirection": "row", "gap": 8, - "paddingVertical": 8, "width": "100%", } } @@ -1365,7 +1365,9 @@ exports[`Thread should match thread snapshot 1`] = ` [ undefined, { + "marginBottom": 12, "paddingHorizontal": 16, + "paddingVertical": 8, }, ] } @@ -1399,8 +1401,6 @@ exports[`Thread should match thread snapshot 1`] = ` "alignItems": "flex-end", "flexDirection": "row", "gap": 8, - "marginBottom": 12, - "paddingVertical": 8, "width": "100%", } } @@ -1715,6 +1715,30 @@ exports[`Thread should match thread snapshot 1`] = ` } } /> + <View + layout={BaseAnimationMock {}} + style={ + [ + { + "bottom": 0, + }, + { + "backgroundColor": "transparent", + "position": "absolute", + "width": "100%", + }, + ] + } + > + <View + name="autocomplete-suggestion-list" + > + <View + collapsable={false} + onLayout={[Function]} + /> + </View> + </View> </View> <View layout={BaseAnimationMock {}} @@ -2327,21 +2351,6 @@ exports[`Thread should match thread snapshot 1`] = ` </View> </View> </View> - <View - style={ - [ - { - "backgroundColor": "#ffffff", - "position": "absolute", - "width": "100%", - }, - { - "bottom": 0, - }, - {}, - ] - } - /> </View> </View> </View> diff --git a/package/src/components/UIComponents/BottomSheetModal.tsx b/package/src/components/UIComponents/BottomSheetModal.tsx index 6346cc468f..84854be137 100644 --- a/package/src/components/UIComponents/BottomSheetModal.tsx +++ b/package/src/components/UIComponents/BottomSheetModal.tsx @@ -39,16 +39,16 @@ import { } from './BottomSheetModal.utils'; import { useA11yLabel } from '../../a11y/hooks/useA11yLabel'; +import { useAnnounceOnShow } from '../../a11y/hooks/useAnnounceOnShow'; import { useResolvedModalAccessibilityProps } from '../../a11y/hooks/useResolvedModalAccessibilityProps'; import { BottomSheetProvider } from '../../contexts/bottomSheetContext/BottomSheetContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useStableCallback } from '../../hooks'; import { primitives } from '../../theme'; -import { useAccessibilityAnnouncer } from '../Accessibility/useAccessibilityAnnouncer'; export type BottomSheetModalProps = { /** - * Function to call when the modal is closed. + * Function to call when the modal is closed or dismissed. * @returns void */ onClose: () => void; @@ -129,6 +129,14 @@ const BottomSheetModalInner = (props: PropsWithChildren<BottomSheetModalProps>) const wasVisibleRef = useRef(false); const [renderContent, setRenderContent] = useState(!lazy); + // We keep the underlying RN `<Modal>` mounted while it dismisses so we can + // listen for its `onDismiss` (iOS) and only then run the consumer's + // close-finished callback. Without this, callbacks that present another + // native modal (e.g. `UIImagePickerController` for camera capture) can race + // the still-dismissing view controller and crash with "Application tried to + // present modal view controller on top of itself" on iOS. + const [isDismissing, setIsDismissing] = useState(false); + const pendingCloseCallbackRef = useRef<(() => void) | undefined>(undefined); const showContent = useStableCallback(() => { if (lazy) { @@ -166,15 +174,37 @@ const BottomSheetModalInner = (props: PropsWithChildren<BottomSheetModalProps>) ); }); - const finishClose = useStableCallback((closeAnimationFinishedCallback?: () => void) => { + const handleNativeModalDismiss = useStableCallback(() => { + const callback = pendingCloseCallbackRef.current; + pendingCloseCallbackRef.current = undefined; onClose(); - if (closeAnimationFinishedCallback) { - Platform.OS === 'ios' - ? closeAnimationFinishedCallback() - : setTimeout(() => closeAnimationFinishedCallback(), 100); - } + callback?.(); }); + const finishClose = useStableCallback( + ({ + closeAnimationFinishedCallback, + shouldDismiss, + }: { + closeAnimationFinishedCallback?: () => void; + shouldDismiss?: boolean; + }) => { + pendingCloseCallbackRef.current = closeAnimationFinishedCallback; + if (Platform.OS !== 'ios') { + // RN's `Modal.onDismiss` is iOS-only, so synthesize the same signal on + // other platforms after a short delay that gives the Dialog time to + // detach. Matches the previous Android behavior. + setTimeout(handleNativeModalDismiss, 100); + } else { + if (shouldDismiss) { + setIsDismissing(true); + return; + } + handleNativeModalDismiss(); + } + }, + ); + const closeFromGesture = useStableCallback(() => { requestAnimationFrame(() => { isOpen.value = false; @@ -192,23 +222,39 @@ const BottomSheetModalInner = (props: PropsWithChildren<BottomSheetModalProps>) }); }); - const close = useStableCallback((closeAnimationFinishedCallback?: () => void) => { - if (!visible || !isOpen.value) { - return; - } + const closeInternal = useStableCallback( + ({ + closeAnimationFinishedCallback, + shouldDismiss, + }: { + closeAnimationFinishedCallback?: () => void; + shouldDismiss?: boolean; + }) => { + if (!visible || !isOpen.value) { + return; + } - isOpen.value = false; - isOpening.value = false; + isOpen.value = false; + isOpening.value = false; - sheetTranslateY.value = withTiming( - maxHeight, - { duration: 250, easing: Easing.out(Easing.cubic) }, - (finished) => { - if (finished) { - runOnJS(finishClose)(closeAnimationFinishedCallback); - } - }, - ); + sheetTranslateY.value = withTiming( + maxHeight, + { duration: 250, easing: Easing.out(Easing.cubic) }, + (finished) => { + if (finished) { + runOnJS(finishClose)({ closeAnimationFinishedCallback, shouldDismiss }); + } + }, + ); + }, + ); + + const close = useStableCallback((closeAnimationFinishedCallback?: () => void) => { + closeInternal({ closeAnimationFinishedCallback }); + }); + + const dismiss = useStableCallback((dismissFinishedCallback?: () => void) => { + closeInternal({ closeAnimationFinishedCallback: dismissFinishedCallback, shouldDismiss: true }); }); // modal opening layout effect - we make sure to only show the content @@ -498,25 +544,10 @@ const BottomSheetModalInner = (props: PropsWithChildren<BottomSheetModalProps>) const modalA11yProps = useResolvedModalAccessibilityProps(); - const announce = useAccessibilityAnnouncer(); const openAnnouncement = useA11yLabel( 'a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.', ); - const announcedOpenRef = useRef(false); - useEffect(() => { - if (!visible) { - announcedOpenRef.current = false; - return; - } - if (!openAnnouncement || announcedOpenRef.current) { - return; - } - const id = setTimeout(() => { - announce(openAnnouncement, 'polite'); - announcedOpenRef.current = true; - }, 800); - return () => clearTimeout(id); - }, [visible, openAnnouncement, announce]); + useAnnounceOnShow(visible, openAnnouncement, { delayMs: 800 }); const closeLabel = useA11yLabel('a11y/Close'); const closeAccessibilityActions = useMemo( @@ -531,15 +562,21 @@ const BottomSheetModalInner = (props: PropsWithChildren<BottomSheetModalProps>) const bottomSheetModalContextValue = useMemo( () => ({ + dismiss, close, currentSnapIndex, topSnapIndex, }), - [close, currentSnapIndex, topSnapIndex], + [dismiss, close, currentSnapIndex, topSnapIndex], ); return ( - <Modal onRequestClose={onClose} transparent visible={visible}> + <Modal + onDismiss={handleNativeModalDismiss} + onRequestClose={onClose} + transparent + visible={visible && !isDismissing} + > <GestureHandlerRootView style={styles.sheetContentContainer}> <View style={[styles.overlay, overlayTheme]}> <Animated.View diff --git a/package/src/components/UIComponents/ClippingFadeBottom.tsx b/package/src/components/UIComponents/ClippingFadeBottom.tsx new file mode 100644 index 0000000000..0c7f025d0e --- /dev/null +++ b/package/src/components/UIComponents/ClippingFadeBottom.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'; + +const CLIPPING_FADE_HEIGHT = 16; +const CLIPPING_FADE_GRADIENT_ID = 'sdk-clipping-fade-bottom'; + +export type ClippingFadeBottomProps = { + /** + * Color the fade ramps toward at the bottom edge. Typically the + * background color of the surface beneath the fade so the bottom edge of + * scrolling content visually melts into it. + */ + backgroundColor: string; +}; + +/** + * Bottom edge fade overlay. Draws a 16px tall SVG linear gradient that + * ramps from the supplied background's transparent variant at the top to + * fully opaque at the bottom - visually clipping any content that scrolls + * past the lower edge of its parent. `pointerEvents='none'` so it doesn't + * intercept taps/scrolls on the rows underneath. + */ +export const ClippingFadeBottom = ({ backgroundColor }: ClippingFadeBottomProps) => ( + <View pointerEvents='none' style={styles.fade}> + <Svg height='100%' width='100%'> + <Defs> + <LinearGradient id={CLIPPING_FADE_GRADIENT_ID} x1='0' x2='0' y1='0' y2='1'> + <Stop offset='0' stopColor={backgroundColor} stopOpacity='0' /> + <Stop offset='1' stopColor={backgroundColor} stopOpacity='1' /> + </LinearGradient> + </Defs> + <Rect fill={`url(#${CLIPPING_FADE_GRADIENT_ID})`} height='100%' width='100%' /> + </Svg> + </View> +); + +const styles = StyleSheet.create({ + fade: { + bottom: 0, + height: CLIPPING_FADE_HEIGHT, + left: 0, + position: 'absolute', + right: 0, + }, +}); diff --git a/package/src/components/UIComponents/EmptyList.tsx b/package/src/components/UIComponents/EmptyList.tsx new file mode 100644 index 0000000000..5fec4a3d71 --- /dev/null +++ b/package/src/components/UIComponents/EmptyList.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { I18nManager, StyleSheet, Text, View } from 'react-native'; + +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { IconProps } from '../../icons/utils/base'; +import { primitives } from '../../theme'; + +export type EmptyListProps = { + /** + * Icon component to render. Its size and color are set by `EmptyList`. + */ + icon: React.ComponentType<IconProps>; + /** + * Title text shown below the icon. + */ + title: string; + /** + * Optional supporting text shown below the title. + */ + subtitle?: string; +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const EmptyList = ({ icon: Icon, subtitle, title }: EmptyListProps) => { + const { + theme: { + emptyList: { container, subtitle: subtitleStyle, title: titleStyle }, + semantics, + }, + } = useTheme(); + + return ( + <View style={[styles.container, container]} testID='empty-list'> + <Icon stroke={semantics.textTertiary} size={32} /> + <View style={styles.content}> + <Text style={[styles.title, { color: semantics.textPrimary }, titleStyle]}>{title}</Text> + {subtitle ? ( + <Text style={[styles.subtitle, { color: semantics.textSecondary }, subtitleStyle]}> + {subtitle} + </Text> + ) : null} + </View> + </View> + ); +}; + +EmptyList.displayName = 'EmptyList{emptyList}'; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + gap: primitives.spacingSm, + height: '100%', + justifyContent: 'center', + paddingHorizontal: primitives.spacingMd, + paddingVertical: primitives.spacing3xl, + width: '100%', + }, + content: { + alignItems: 'center', + gap: primitives.spacingXs, + width: '100%', + }, + subtitle: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + textAlign: 'center', + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, + title: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightSemiBold, + lineHeight: primitives.typographyLineHeightNormal, + textAlign: 'center', + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, +}); diff --git a/package/src/components/UIComponents/EmptySearchResult.tsx b/package/src/components/UIComponents/EmptySearchResult.tsx new file mode 100644 index 0000000000..023feba9b6 --- /dev/null +++ b/package/src/components/UIComponents/EmptySearchResult.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { I18nManager, StyleSheet, Text, View } from 'react-native'; + +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { Search } from '../../icons/search'; +import { primitives } from '../../theme'; + +export type EmptySearchResultProps = { + label: string; +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const EmptySearchResult = ({ label }: EmptySearchResultProps) => { + const { + theme: { + emptySearchResult: { container, text }, + semantics, + }, + } = useTheme(); + + return ( + <View style={[styles.container, container]} testID='empty-search-result'> + <Search height={24} stroke={semantics.textTertiary} width={24} /> + <Text style={[styles.text, { color: semantics.textSecondary }, text]}>{label}</Text> + </View> + ); +}; + +EmptySearchResult.displayName = 'EmptySearchResult{emptySearchResult}'; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + gap: primitives.spacingSm, + height: '100%', + justifyContent: 'center', + paddingVertical: primitives.spacingXl, + width: '100%', + }, + text: { + fontSize: primitives.typographyFontSizeMd, + fontWeight: primitives.typographyFontWeightRegular, + lineHeight: primitives.typographyLineHeightNormal, + textAlign: 'center', + writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + }, +}); diff --git a/package/src/components/UIComponents/GenericListLoadingSkeleton.tsx b/package/src/components/UIComponents/GenericListLoadingSkeleton.tsx new file mode 100644 index 0000000000..bcf68c47eb --- /dev/null +++ b/package/src/components/UIComponents/GenericListLoadingSkeleton.tsx @@ -0,0 +1,121 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { NativeShimmerView } from './NativeShimmerView'; + +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { Theme } from '../../contexts/themeContext/utils/theme'; +import { primitives } from '../../theme'; + +const ROW_COUNT = 4; + +// `memberListSkeleton` and `userListSkeleton` share an identical shape. +export type ListLoadingSkeletonTheme = Theme['memberListSkeleton']; + +export type GenericListLoadingSkeletonProps = { + skeleton: ListLoadingSkeletonTheme; + testID: string; +}; + +const SkeletonBlock = ({ + animationTime, + style, +}: { + animationTime: number; + style: React.ComponentProps<typeof View>['style']; +}) => { + const { + theme: { semantics }, + } = useTheme(); + + return ( + <View style={style}> + <NativeShimmerView + baseColor={semantics.backgroundCoreSurfaceDefault} + duration={animationTime} + gradientColor={semantics.skeletonLoadingHighlight} + style={StyleSheet.absoluteFill} + /> + </View> + ); +}; + +const SkeletonRow = ({ skeleton }: { skeleton: ListLoadingSkeletonTheme }) => { + const styles = useStyles(skeleton); + + return ( + <View style={styles.container}> + <View style={styles.content}> + <SkeletonBlock animationTime={skeleton.animationTime} style={styles.avatar} /> + <View style={styles.textContainer}> + <SkeletonBlock animationTime={skeleton.animationTime} style={styles.title} /> + <SkeletonBlock animationTime={skeleton.animationTime} style={styles.subtitle} /> + </View> + </View> + </View> + ); +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const GenericListLoadingSkeleton = ({ + skeleton, + testID, +}: GenericListLoadingSkeletonProps) => ( + <View testID={testID}> + {Array.from({ length: ROW_COUNT }).map((_, index) => ( + <SkeletonRow key={index} skeleton={skeleton} /> + ))} + </View> +); + +GenericListLoadingSkeleton.displayName = 'GenericListLoadingSkeleton'; + +const useStyles = (skeleton: ListLoadingSkeletonTheme) => + useMemo( + () => + StyleSheet.create({ + avatar: { + borderRadius: primitives.radiusMax, + height: 40, + overflow: 'hidden', + width: 40, + ...skeleton.avatar, + }, + container: { + minHeight: 48, + paddingHorizontal: primitives.spacingXxs, + ...skeleton.container, + }, + content: { + alignItems: 'center', + flexDirection: 'row', + flex: 1, + gap: primitives.spacingSm, + paddingHorizontal: primitives.spacingSm, + paddingVertical: primitives.spacingMd, + ...skeleton.content, + }, + subtitle: { + borderRadius: primitives.radiusMax, + height: 12, + overflow: 'hidden', + width: 80, + ...skeleton.subtitle, + }, + textContainer: { + flex: 1, + gap: primitives.spacingXs, + ...skeleton.textContainer, + }, + title: { + borderRadius: primitives.radiusMax, + height: 16, + overflow: 'hidden', + width: 200, + ...skeleton.title, + }, + }), + [skeleton], + ); diff --git a/package/src/components/UIComponents/PortalWhileClosingView.tsx b/package/src/components/UIComponents/PortalWhileClosingView.tsx index ffe785a004..2ae6292bfa 100644 --- a/package/src/components/UIComponents/PortalWhileClosingView.tsx +++ b/package/src/components/UIComponents/PortalWhileClosingView.tsx @@ -130,10 +130,6 @@ const useSyncingApi = (portalHostName: string, registrationId: string) => { y: y + (Platform.OS === 'android' ? insets.top : 0), }; - if (!width || !height) { - return; - } - placeholderLayout.value = { h: height, w: width }; setClosingPortalLayout(portalHostName, registrationId, { diff --git a/package/src/components/UIComponents/SearchInput.tsx b/package/src/components/UIComponents/SearchInput.tsx new file mode 100644 index 0000000000..0f679ad824 --- /dev/null +++ b/package/src/components/UIComponents/SearchInput.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { StyleSheet, TextInput, View } from 'react-native'; + +import { Pressable } from 'react-native-gesture-handler'; + +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; +import { Search } from '../../icons/search'; +import { XCircle } from '../../icons/x-circle'; +import { primitives } from '../../theme'; +import type { IconRenderer } from '../ui/Button/Button'; +import { Input, InputProps } from '../ui/Input/Input'; + +export type SearchInputProps = Partial<InputProps>; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const SearchInput = ({ onChangeText, ...props }: SearchInputProps) => { + const { t } = useTranslationContext(); + const { + theme: { semantics }, + } = useTheme(); + + const inputRef = useRef<TextInput>(null); + const [hasText, setHasText] = useState(() => Boolean(props.value || props.defaultValue)); + + const handleChangeText = useCallback( + (text: string) => { + setHasText(text.length > 0); + onChangeText?.(text); + }, + [onChangeText], + ); + + const handleClear = useCallback(() => { + inputRef.current?.clear(); + setHasText(false); + onChangeText?.(''); + }, [onChangeText]); + + const LeadingIcon: IconRenderer = useCallback( + () => <Search height={20} stroke={semantics.textSecondary} width={20} />, + [semantics.textSecondary], + ); + + const ClearIcon: IconRenderer = useCallback( + () => ( + <Pressable + accessibilityLabel={t('a11y/Clear search')} + accessibilityRole='button' + hitSlop={30} + onPress={handleClear} + testID='clear-search' + > + <XCircle height={15} stroke={semantics.inputTextIcon} width={15} /> + </Pressable> + ), + [handleClear, semantics.inputTextIcon, t], + ); + + return ( + <View style={styles.container}> + <Input + ref={inputRef} + autoCapitalize='words' + autoCorrect={false} + containerStyle={styles.input} + helperText={false} + LeadingIcon={LeadingIcon} + placeholder={t('Search')} + testID='search-input' + TrailingIcon={hasText ? ClearIcon : undefined} + variant='outline' + {...props} + onChangeText={handleChangeText} + /> + </View> + ); +}; + +SearchInput.displayName = 'SearchInput{searchInput}'; + +const styles = StyleSheet.create({ + container: { + paddingBottom: primitives.spacingSm, + paddingHorizontal: primitives.spacingMd, + paddingTop: primitives.spacingXs, + }, + input: { + borderRadius: primitives.radiusMax, + }, +}); diff --git a/package/src/components/UIComponents/SelectionCircle.tsx b/package/src/components/UIComponents/SelectionCircle.tsx new file mode 100644 index 0000000000..f356a394f5 --- /dev/null +++ b/package/src/components/UIComponents/SelectionCircle.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { Checkmark } from '../../icons/checkmark-1'; +import { primitives } from '../../theme'; + +export type SelectionCircleProps = { + selected: boolean; +}; + +/** + * @experimental This component is experimental and is subject to change. + */ +export const SelectionCircle = ({ selected }: SelectionCircleProps) => { + const { + theme: { + selectionCircle: { circle, circleSelected }, + semantics, + }, + } = useTheme(); + + if (selected) { + return ( + <View + style={[ + styles.circle, + { backgroundColor: semantics.accentPrimary, borderColor: semantics.accentPrimary }, + circleSelected, + ]} + > + <Checkmark height={14} pathFill={semantics.textOnAccent} width={14} /> + </View> + ); + } + + return <View style={[styles.circle, { borderColor: semantics.borderCoreDefault }, circle]} />; +}; + +SelectionCircle.displayName = 'SelectionCircle{selectionCircle}'; + +const styles = StyleSheet.create({ + circle: { + alignItems: 'center', + borderRadius: primitives.radiusMax, + borderWidth: 1, + height: 24, + justifyContent: 'center', + width: 24, + }, +}); diff --git a/package/src/components/UIComponents/__tests__/EmptyList.test.tsx b/package/src/components/UIComponents/__tests__/EmptyList.test.tsx new file mode 100644 index 0000000000..8997452072 --- /dev/null +++ b/package/src/components/UIComponents/__tests__/EmptyList.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react-native'; + +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { Pin } from '../../../icons/pin'; +import { EmptyList } from '../EmptyList'; + +type EmptyListProps = React.ComponentProps<typeof EmptyList>; + +const renderComponent = (props: Partial<EmptyListProps> = {}) => + render( + <ThemeProvider theme={defaultTheme}> + <EmptyList icon={Pin} title='No pinned messages' {...props} /> + </ThemeProvider>, + ); + +describe('EmptyList', () => { + it('renders the empty-list testID', () => { + renderComponent(); + expect(screen.getByTestId('empty-list')).toBeTruthy(); + }); + + it('renders the title text', () => { + renderComponent({ title: 'Nothing here' }); + expect(screen.getByText('Nothing here')).toBeTruthy(); + }); + + it('renders the subtitle when provided', () => { + renderComponent({ subtitle: 'Long-press a message to pin it to the chat' }); + expect(screen.getByText('Long-press a message to pin it to the chat')).toBeTruthy(); + }); + + it('does not render a subtitle when omitted', () => { + renderComponent({ subtitle: undefined }); + expect(screen.queryByText('Long-press a message to pin it to the chat')).toBeNull(); + }); + + it('honors a custom container style override from the theme', () => { + const customTheme = { + ...defaultTheme, + emptyList: { + container: { backgroundColor: 'rgb(255, 0, 0)' }, + subtitle: {}, + title: {}, + }, + }; + + render( + <ThemeProvider theme={customTheme}> + <EmptyList icon={Pin} title='Empty' /> + </ThemeProvider>, + ); + + const container = screen.getByTestId('empty-list'); + const flattened = Array.isArray(container.props.style) + ? Object.assign({}, ...container.props.style.flat(Infinity).filter(Boolean)) + : container.props.style; + expect(flattened.backgroundColor).toBe('rgb(255, 0, 0)'); + }); + + it('honors custom title and subtitle style overrides from the theme', () => { + const customTheme = { + ...defaultTheme, + emptyList: { + container: {}, + subtitle: { color: 'rgb(0, 0, 255)' }, + title: { color: 'rgb(0, 255, 0)' }, + }, + }; + + render( + <ThemeProvider theme={customTheme}> + <EmptyList icon={Pin} subtitle='Subtitle' title='Title' /> + </ThemeProvider>, + ); + + const flatten = (node: { props: { style: unknown } }) => + Array.isArray(node.props.style) + ? Object.assign({}, ...(node.props.style as unknown[]).flat(Infinity).filter(Boolean)) + : node.props.style; + + const title = screen.getByText('Title') as unknown as { props: { style: unknown } }; + const subtitle = screen.getByText('Subtitle') as unknown as { props: { style: unknown } }; + + expect((flatten(title) as { color?: string }).color).toBe('rgb(0, 255, 0)'); + expect((flatten(subtitle) as { color?: string }).color).toBe('rgb(0, 0, 255)'); + }); +}); diff --git a/package/src/components/UIComponents/__tests__/EmptySearchResult.test.tsx b/package/src/components/UIComponents/__tests__/EmptySearchResult.test.tsx new file mode 100644 index 0000000000..8f262250f7 --- /dev/null +++ b/package/src/components/UIComponents/__tests__/EmptySearchResult.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react-native'; + +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { EmptySearchResult } from '../EmptySearchResult'; + +type EmptySearchResultProps = React.ComponentProps<typeof EmptySearchResult>; + +const renderComponent = (props: Partial<EmptySearchResultProps> = {}) => + render( + <ThemeProvider theme={defaultTheme}> + <EmptySearchResult label='No results' {...props} /> + </ThemeProvider>, + ); + +describe('EmptySearchResult', () => { + it('renders the empty-search-result testID', () => { + renderComponent(); + + expect(screen.getByTestId('empty-search-result')).toBeTruthy(); + }); + + it('renders the label text', () => { + renderComponent({ label: 'Nothing here' }); + + expect(screen.getByText('Nothing here')).toBeTruthy(); + }); + + it('honors a custom container style override from the theme', () => { + const customTheme = { + ...defaultTheme, + emptySearchResult: { + container: { backgroundColor: 'rgb(255, 0, 0)' }, + text: {}, + }, + }; + + render( + <ThemeProvider theme={customTheme}> + <EmptySearchResult label='Empty' /> + </ThemeProvider>, + ); + + const container = screen.getByTestId('empty-search-result'); + const flattened = Array.isArray(container.props.style) + ? Object.assign({}, ...container.props.style.flat(Infinity).filter(Boolean)) + : container.props.style; + expect(flattened.backgroundColor).toBe('rgb(255, 0, 0)'); + }); + + it('honors a custom text style override from the theme', () => { + const customTheme = { + ...defaultTheme, + emptySearchResult: { + container: {}, + text: { color: 'rgb(0, 255, 0)' }, + }, + }; + + render( + <ThemeProvider theme={customTheme}> + <EmptySearchResult label='Empty' /> + </ThemeProvider>, + ); + + const label = screen.getByText('Empty') as unknown as { props: { style: unknown } }; + const flattened = Array.isArray(label.props.style) + ? Object.assign({}, ...(label.props.style as unknown[]).flat(Infinity).filter(Boolean)) + : label.props.style; + expect((flattened as { color?: string }).color).toBe('rgb(0, 255, 0)'); + }); +}); diff --git a/package/src/components/UIComponents/__tests__/SearchInput.test.tsx b/package/src/components/UIComponents/__tests__/SearchInput.test.tsx new file mode 100644 index 0000000000..25c4ba594d --- /dev/null +++ b/package/src/components/UIComponents/__tests__/SearchInput.test.tsx @@ -0,0 +1,98 @@ +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 { SearchInput } from '../SearchInput'; + +type SearchInputProps = React.ComponentProps<typeof SearchInput>; + +const renderComponent = (props: Partial<SearchInputProps> = {}) => + render( + <ThemeProvider theme={defaultTheme}> + <TranslationProvider + value={{ + t: ((key: string) => key) as never, + tDateTimeParser: ((input: unknown) => input) as never, + userLanguage: 'en', + }} + > + <SearchInput accessibilityLabel='Search users' onChangeText={jest.fn()} {...props} /> + </TranslationProvider> + </ThemeProvider>, + ); + +describe('SearchInput', () => { + it('renders the search-input testID', () => { + renderComponent(); + + expect(screen.getByTestId('search-input')).toBeTruthy(); + }); + + it('forwards the accessibilityLabel prop to the underlying TextInput', () => { + renderComponent({ accessibilityLabel: 'Search anything' }); + + expect(screen.getByTestId('search-input').props.accessibilityLabel).toBe('Search anything'); + }); + + it('does not render the clear button when the input is empty', () => { + renderComponent(); + + expect(screen.queryByTestId('clear-search')).toBeNull(); + }); + + it('shows the clear button after typing and hides it after clearing', () => { + renderComponent(); + + fireEvent.changeText(screen.getByTestId('search-input'), 'abc'); + expect(screen.getByTestId('clear-search')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('clear-search')); + expect(screen.queryByTestId('clear-search')).toBeNull(); + }); + + it('calls onChangeText with each typed value', () => { + const onChangeText = jest.fn(); + renderComponent({ onChangeText }); + + fireEvent.changeText(screen.getByTestId('search-input'), 'foo'); + fireEvent.changeText(screen.getByTestId('search-input'), 'foobar'); + + expect(onChangeText).toHaveBeenNthCalledWith(1, 'foo'); + expect(onChangeText).toHaveBeenNthCalledWith(2, 'foobar'); + }); + + it('calls onChangeText with an empty string when the clear button is pressed', () => { + const onChangeText = jest.fn(); + renderComponent({ onChangeText }); + + fireEvent.changeText(screen.getByTestId('search-input'), 'x'); + fireEvent.press(screen.getByTestId('clear-search')); + + expect(onChangeText).toHaveBeenLastCalledWith(''); + }); + + it('shows the clear button on mount when an initial value is provided', () => { + renderComponent({ value: 'initial' }); + + expect(screen.getByTestId('clear-search')).toBeTruthy(); + }); + + it('shows the clear button on mount when an initial defaultValue is provided', () => { + renderComponent({ defaultValue: 'initial' }); + + expect(screen.getByTestId('clear-search')).toBeTruthy(); + }); + + it('hides the clear button when the text is deleted back to empty', () => { + renderComponent(); + + fireEvent.changeText(screen.getByTestId('search-input'), 'abc'); + expect(screen.getByTestId('clear-search')).toBeTruthy(); + + fireEvent.changeText(screen.getByTestId('search-input'), ''); + expect(screen.queryByTestId('clear-search')).toBeNull(); + }); +}); diff --git a/package/src/components/UIComponents/__tests__/SelectionCircle.test.tsx b/package/src/components/UIComponents/__tests__/SelectionCircle.test.tsx new file mode 100644 index 0000000000..e4864f150a --- /dev/null +++ b/package/src/components/UIComponents/__tests__/SelectionCircle.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { View } from 'react-native'; + +import { render } from '@testing-library/react-native'; + +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../contexts/themeContext/utils/theme'; +import { Checkmark } from '../../../icons/checkmark-1'; +import { SelectionCircle } from '../SelectionCircle'; + +const renderWithTheme = ( + ui: React.ReactElement, + style?: Parameters<typeof ThemeProvider>[0]['style'], +) => + render( + <ThemeProvider style={style} theme={defaultTheme}> + {ui} + </ThemeProvider>, + ); + +describe('SelectionCircle', () => { + it('renders the Checkmark child when selected', () => { + const { UNSAFE_queryAllByType } = renderWithTheme(<SelectionCircle selected />); + + expect(UNSAFE_queryAllByType(Checkmark)).toHaveLength(1); + }); + + it('renders an empty circle when not selected', () => { + const { UNSAFE_queryAllByType } = renderWithTheme(<SelectionCircle selected={false} />); + + expect(UNSAFE_queryAllByType(Checkmark)).toHaveLength(0); + }); + + it('applies theme.selectionCircle.circle override when not selected', () => { + const { UNSAFE_getByType } = renderWithTheme(<SelectionCircle selected={false} />, { + selectionCircle: { circle: { borderColor: 'red' } }, + }); + + const view = UNSAFE_getByType(View); + expect(JSON.stringify(view.props.style)).toContain('"borderColor":"red"'); + }); + + it('applies theme.selectionCircle.circleSelected override when selected', () => { + const { UNSAFE_getAllByType } = renderWithTheme(<SelectionCircle selected />, { + selectionCircle: { circleSelected: { borderColor: 'pink' } }, + }); + + const [view] = UNSAFE_getAllByType(View); + expect(JSON.stringify(view.props.style)).toContain('"borderColor":"pink"'); + }); +}); diff --git a/package/src/components/UIComponents/index.ts b/package/src/components/UIComponents/index.ts index b10b12011d..c5714921aa 100644 --- a/package/src/components/UIComponents/index.ts +++ b/package/src/components/UIComponents/index.ts @@ -1,6 +1,11 @@ export * from './BottomSheetModal'; +export * from './ClippingFadeBottom'; export * from './StreamBottomSheetModalFlatList'; +export * from './EmptyList'; +export * from './EmptySearchResult'; export * from './ImageBackground'; +export * from './SearchInput'; +export * from './SelectionCircle'; export * from './SvgAwareImage'; export * from './Spinner'; export * from './SwipableWrapper'; diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 7df045b924..58f07e9441 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -28,6 +28,7 @@ export * from './AutoCompleteInput/AutoCompleteSuggestionHeader'; export * from './AutoCompleteInput/AutoCompleteSuggestionItem'; export * from './AutoCompleteInput/AutoCompleteSuggestionList'; export * from './AutoCompleteInput/InputView'; +export * from './AutoCompleteInput/mentionItems'; export * from './Channel/Channel'; export * from './Channel/hooks/useCreateChannelContext'; @@ -56,6 +57,7 @@ export * from './ChannelPreview/ChannelPreview'; export * from './ChannelPreview/ChannelPreviewMessage'; export * from './ChannelPreview/ChannelPreviewView'; export * from './ChannelPreview/ChannelPreviewMutedStatus'; +export * from './ChannelPreview/ChannelPreviewPinnedStatus'; export * from './ChannelPreview/ChannelLastMessagePreview'; export * from './ChannelPreview/ChannelPreviewStatus'; export * from './ChannelPreview/ChannelPreviewTitle'; @@ -179,6 +181,7 @@ export * from './Notifications'; export * from './ProgressControl/ProgressControl'; export * from './ProgressControl/WaveProgressBar'; +export * from './ChannelDetails'; export * from './Poll'; export * from './Reply/Reply'; diff --git a/package/src/components/ui/Avatar/AvatarGroup.tsx b/package/src/components/ui/Avatar/AvatarGroup.tsx index 4c3f00109d..a56b1bece9 100644 --- a/package/src/components/ui/Avatar/AvatarGroup.tsx +++ b/package/src/components/ui/Avatar/AvatarGroup.tsx @@ -28,8 +28,8 @@ export type AvatarGroupProps = { // Sizes accounts for the border width as well const sizes = { '2xl': { - width: 64, - height: 64, + width: 80, + height: 80, }, xl: { width: 48, @@ -165,7 +165,7 @@ export type UserAvatarGroupProps = Pick<AvatarGroupProps, 'size'> & { }; const userAvatarSize: Record<UserAvatarGroupProps['size'], UserAvatarProps['size']> = { - '2xl': 'lg', + '2xl': 'xl', xl: 'md', lg: 'sm', }; diff --git a/package/src/components/ui/Avatar/ChannelAvatar.tsx b/package/src/components/ui/Avatar/ChannelAvatar.tsx index 255aefea0a..1048410650 100644 --- a/package/src/components/ui/Avatar/ChannelAvatar.tsx +++ b/package/src/components/ui/Avatar/ChannelAvatar.tsx @@ -12,11 +12,25 @@ import { useA11yLabel } from '../../../a11y/hooks/useA11yLabel'; import { useChannelPreviewDisplayPresence } from '../../../components/ChannelPreview/hooks/useChannelPreviewDisplayPresence'; import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; +import { useChannelImage } from '../../../hooks/useChannelImage'; +import { useChannelName } from '../../../hooks/useChannelName'; import { hashStringToNumber } from '../../../utils/utils'; import { CompositeAccessibilityProbe } from '../../Accessibility/CompositeAccessibilityProbe'; +import { useChannelMembersState } from '../../ChannelList/hooks/useChannelMembersState'; export type ChannelAvatarProps = { channel: Channel; + /** + * When true, the avatar renders based on `previewUri` instead of the + * channel's stored image. Useful for previewing a pending image change before + * it is saved. Defaults to false. + */ + isPreview?: boolean; + /** + * Image to display while in preview mode (`isPreview` is true). A `string` + * shows that image; `null` shows the no-image fallback (member/user avatar). + */ + previewUri?: string | null; showOnlineIndicator?: boolean; size?: 'lg' | 'xl' | '2xl'; showBorder?: boolean; @@ -26,7 +40,13 @@ export const ChannelAvatar = (props: ChannelAvatarProps) => { const { client } = useChatContext(); const { channel } = props; const online = useChannelPreviewDisplayPresence(channel); - const { showOnlineIndicator = online, size = 'xl', showBorder = true } = props; + const { + isPreview = false, + previewUri = null, + showOnlineIndicator = online, + size = 'xl', + showBorder = true, + } = props; const { theme: { semantics }, @@ -36,20 +56,22 @@ export const ChannelAvatar = (props: ChannelAvatarProps) => { const index = ((hashedValue % 5) + 1) as 1 | 2 | 3 | 4 | 5; const avatarBackgroundColor = semantics[`avatarPaletteBg${index}`]; - const channelImage = channel.data?.image; + const channelImage = useChannelImage(channel); + const imageToDisplay = isPreview ? previewUri : channelImage; + const members = useChannelMembersState(channel); const usersForGroup = useMemo( - () => Object.values(channel.state.members).map((member) => member.user as UserResponse), - [channel.state.members], + () => Object.values(members).map((member) => member.user as UserResponse), + [members], ); const usersWithoutSelf = useMemo( () => usersForGroup.filter((user) => user.id !== client.user?.id), [usersForGroup, client.user?.id], ); - const channelName = (channel.data?.name as string | undefined) ?? channel.cid; + const channelName = useChannelName(channel) ?? channel.cid; - const memberCount = Object.keys(channel.state.members).length; + const memberCount = Object.keys(members).length; const isGroup = !!channel.data?.name || memberCount > 2; const otherUserName = usersWithoutSelf[0]?.name || usersWithoutSelf[0]?.id; const labelParams = useMemo( @@ -63,10 +85,10 @@ export const ChannelAvatar = (props: ChannelAvatarProps) => { return ( <CompositeAccessibilityProbe label={accessibilityLabel}> - {channelImage ? ( + {imageToDisplay ? ( <Avatar backgroundColor={avatarBackgroundColor} - imageUrl={channelImage} + imageUrl={imageToDisplay} name={channelName} showBorder={showBorder} size={size} diff --git a/package/src/components/ui/Avatar/__tests__/ChannelAvatar.test.tsx b/package/src/components/ui/Avatar/__tests__/ChannelAvatar.test.tsx new file mode 100644 index 0000000000..98f70aeed7 --- /dev/null +++ b/package/src/components/ui/Avatar/__tests__/ChannelAvatar.test.tsx @@ -0,0 +1,150 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +import { ChatProvider } from '../../../../contexts/chatContext/ChatContext'; +import { ThemeProvider } from '../../../../contexts/themeContext/ThemeContext'; +import { defaultTheme } from '../../../../contexts/themeContext/utils/theme'; +import * as useChannelImageModule from '../../../../hooks/useChannelImage'; +import * as useChannelNameModule from '../../../../hooks/useChannelName'; +import * as useChannelMembersStateModule from '../../../ChannelList/hooks/useChannelMembersState'; +import * as useChannelPreviewDisplayPresenceModule from '../../../ChannelPreview/hooks/useChannelPreviewDisplayPresence'; +import { ChannelAvatar } from '../ChannelAvatar'; + +const avatarCalls: Array<{ imageUrl?: string }> = []; +jest.mock('../Avatar', () => { + const RN = jest.requireActual('react-native'); + const ReactActual = jest.requireActual('react'); + return { + Avatar: (props: { imageUrl?: string }) => { + avatarCalls.push({ imageUrl: props.imageUrl }); + return ReactActual.createElement(RN.View, { testID: 'avatar' }); + }, + }; +}); + +jest.mock('../AvatarGroup', () => { + const RN = jest.requireActual('react-native'); + const ReactActual = jest.requireActual('react'); + return { + UserAvatarGroup: () => ReactActual.createElement(RN.View, { testID: 'user-avatar-group' }), + }; +}); + +jest.mock('../UserAvatar', () => { + const RN = jest.requireActual('react-native'); + const ReactActual = jest.requireActual('react'); + return { + UserAvatar: () => ReactActual.createElement(RN.View, { testID: 'user-avatar' }), + }; +}); + +const OWN_USER_ID = 'me'; + +const buildChannel = (): Channel => + ({ + cid: 'messaging:test', + data: {}, + on: () => ({ unsubscribe: () => undefined }), + }) as unknown as Channel; + +const buildMembers = (...ids: string[]) => + Object.fromEntries(ids.map((id) => [id, { user: { id } }])); + +const renderAvatar = (props: Partial<React.ComponentProps<typeof ChannelAvatar>> = {}) => + render( + <ThemeProvider theme={defaultTheme}> + <ChatProvider value={{ client: { user: { id: OWN_USER_ID } } } as never}> + <ChannelAvatar channel={buildChannel()} {...props} /> + </ChatProvider> + </ThemeProvider>, + ); + +describe('ChannelAvatar', () => { + beforeEach(() => { + avatarCalls.length = 0; + jest + .spyOn(useChannelPreviewDisplayPresenceModule, 'useChannelPreviewDisplayPresence') + .mockReturnValue(false); + jest.spyOn(useChannelNameModule, 'useChannelName').mockReturnValue('Channel Name'); + jest.spyOn(useChannelImageModule, 'useChannelImage').mockReturnValue(undefined); + jest.spyOn(useChannelMembersStateModule, 'useChannelMembersState').mockReturnValue({}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('default (non-preview) mode', () => { + it('renders the stored channel image when present', () => { + jest + .spyOn(useChannelImageModule, 'useChannelImage') + .mockReturnValue('https://example.com/live.png'); + + renderAvatar(); + + expect(screen.getByTestId('avatar')).toBeTruthy(); + expect(avatarCalls[avatarCalls.length - 1].imageUrl).toBe('https://example.com/live.png'); + }); + + it('falls back to the user-avatar group when there is no image and 2+ other members', () => { + jest + .spyOn(useChannelMembersStateModule, 'useChannelMembersState') + .mockReturnValue(buildMembers(OWN_USER_ID, 'a', 'b') as never); + + renderAvatar(); + + expect(screen.getByTestId('user-avatar-group')).toBeTruthy(); + expect(screen.queryByTestId('avatar')).toBeNull(); + }); + + it('falls back to a single user avatar when there is no image and at most one other member', () => { + jest + .spyOn(useChannelMembersStateModule, 'useChannelMembersState') + .mockReturnValue(buildMembers(OWN_USER_ID, 'a') as never); + + renderAvatar(); + + expect(screen.getByTestId('user-avatar')).toBeTruthy(); + expect(screen.queryByTestId('avatar')).toBeNull(); + }); + + it('ignores previewUri when isPreview is false', () => { + jest + .spyOn(useChannelImageModule, 'useChannelImage') + .mockReturnValue('https://example.com/live.png'); + + renderAvatar({ isPreview: false, previewUri: 'file://picked.png' }); + + expect(avatarCalls[avatarCalls.length - 1].imageUrl).toBe('https://example.com/live.png'); + }); + }); + + describe('preview mode', () => { + it('renders the previewUri instead of the stored channel image', () => { + jest + .spyOn(useChannelImageModule, 'useChannelImage') + .mockReturnValue('https://example.com/live.png'); + + renderAvatar({ isPreview: true, previewUri: 'file://picked.png' }); + + expect(screen.getByTestId('avatar')).toBeTruthy(); + expect(avatarCalls[avatarCalls.length - 1].imageUrl).toBe('file://picked.png'); + }); + + it('falls back to the member avatars when previewUri is null (reset), ignoring the stored image', () => { + jest + .spyOn(useChannelImageModule, 'useChannelImage') + .mockReturnValue('https://example.com/live.png'); + jest + .spyOn(useChannelMembersStateModule, 'useChannelMembersState') + .mockReturnValue(buildMembers(OWN_USER_ID, 'a', 'b') as never); + + renderAvatar({ isPreview: true, previewUri: null }); + + expect(screen.getByTestId('user-avatar-group')).toBeTruthy(); + expect(screen.queryByTestId('avatar')).toBeNull(); + }); + }); +}); diff --git a/package/src/components/ui/Avatar/constants.ts b/package/src/components/ui/Avatar/constants.ts index 4f249de732..c51cb594a9 100644 --- a/package/src/components/ui/Avatar/constants.ts +++ b/package/src/components/ui/Avatar/constants.ts @@ -7,8 +7,8 @@ import { OnlineIndicatorProps } from '../Badge'; const avatarSizes = { '2xl': { - height: 64, - width: 64, + height: 80, + width: 80, }, xl: { height: 48, diff --git a/package/src/components/ui/Input/Input.tsx b/package/src/components/ui/Input/Input.tsx index a145043cf1..5b74f66fd6 100644 --- a/package/src/components/ui/Input/Input.tsx +++ b/package/src/components/ui/Input/Input.tsx @@ -52,23 +52,26 @@ export type InputProps = TextInputProps & { containerStyle?: StyleProp<ViewStyle>; }; -export const Input = ({ - title, - description, - variant = 'outline', - LeadingIcon, - TrailingIcon, - editable = true, - state = 'default', - helperText = true, - errorMessage, - successMessage, - infoText, - onFocus, - onBlur, - containerStyle, - ...props -}: InputProps) => { +export const Input = React.forwardRef<TextInput, InputProps>(function Input( + { + title, + description, + variant = 'outline', + LeadingIcon, + TrailingIcon, + editable = true, + state = 'default', + helperText = true, + errorMessage, + successMessage, + infoText, + onFocus, + onBlur, + containerStyle, + ...props + }, + ref, +) { const [isFocused, setIsFocused] = useState(false); const { theme: { semantics }, @@ -111,9 +114,8 @@ export const Input = ({ borderWidth: variant === 'outline' ? 1 : 0, borderColor: !editable ? semantics.borderUtilityDisabled - : // TODO: V9: This should go away as it's the same style. In a separate PR though. - isFocused - ? semantics.borderCoreDefault + : isFocused + ? semantics.borderUtilityActive : semantics.borderCoreDefault, }, containerStyle, @@ -128,6 +130,7 @@ export const Input = ({ /> ) : null} <TextInput + ref={ref} accessibilityHint={description} accessibilityLabel={props.accessibilityLabel ?? title} accessibilityState={accessibilityState} @@ -186,7 +189,7 @@ export const Input = ({ ) : null} </View> ); -}; +}); const useStyles = () => { const { diff --git a/package/src/contexts/bottomSheetContext/BottomSheetContext.tsx b/package/src/contexts/bottomSheetContext/BottomSheetContext.tsx index ad0bcd44bf..5bd7f1a0bb 100644 --- a/package/src/contexts/bottomSheetContext/BottomSheetContext.tsx +++ b/package/src/contexts/bottomSheetContext/BottomSheetContext.tsx @@ -6,7 +6,32 @@ import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; export type BottomSheetContextValue = { + /** + * Callback that will safely close the modal while preserving animation + * timing and respecting actions finishing before the close is invoked. + * + * @param closeAnimationFinishedCallback {function} - a callback that is + * going to be invoked when the closing animation finishes (but not necessarily + * when the modal is actually dismissed) + * @returns void + */ close: (closeAnimationFinishedCallback?: () => void) => void; + /** + * A callback that will safely dismiss the modal, while preserving animation timing + * and respecting actions finishing before the close is invoked. Very similar to `close`, + * however it takes an extra step to make sure that the modal is actually dismissed + * before its callback is invoked. This is mostly useful for iOS, where for certain + * libraries (i.e `react-native-image-picker`) you aren't able to open both a RN modal + * and its internal `UIImagePickerController` at the same time and you have to wait + * for `onDismiss` to be fired internally. + * + * It will work exactly the same as `close` for Android as this is not an issue there. + * + * @param closeAnimationFinishedCallback {function} - a callback that is + * going to be invoked when the dismissal of the modal finishes + * @returns void + */ + dismiss: (dismissFinishedCallback?: () => void) => void; currentSnapIndex: SharedValue<number>; topSnapIndex: SharedValue<number>; }; diff --git a/package/src/contexts/channelAddMembersContext/ChannelAddMembersContext.tsx b/package/src/contexts/channelAddMembersContext/ChannelAddMembersContext.tsx new file mode 100644 index 0000000000..20a6b5b7b1 --- /dev/null +++ b/package/src/contexts/channelAddMembersContext/ChannelAddMembersContext.tsx @@ -0,0 +1,73 @@ +import React, { PropsWithChildren, useContext, useState } from 'react'; + +import { UserSearchSource } from 'stream-chat'; + +import { useChatContext } from '..'; +import { SelectionStore } from '../../state-store/selection-store'; +import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; +import { isTestEnvironment } from '../utils/isTestEnvironment'; + +/** + * @experimental This API is experimental and is subject to change. + */ +export type ChannelAddMembersContextValue = { + selectionStore: SelectionStore; + searchSource: UserSearchSource; +}; + +export const ChannelAddMembersContext = React.createContext( + DEFAULT_BASE_CONTEXT_VALUE as ChannelAddMembersContextValue, +); + +/** + * @experimental This API is experimental and is subject to change. + */ +export const ChannelAddMembersProvider = ({ children }: PropsWithChildren<unknown>) => { + const { client } = useChatContext(); + const [selectionStore] = useState(() => new SelectionStore()); + const [searchSource] = useState(() => { + const source = new UserSearchSource( + client, + { pageSize: 25, allowEmptySearchString: true, resetOnNewSearchQuery: false }, + { + initialFilterConfig: { + $or: { + enabled: true, + generate: ({ searchQuery }) => + searchQuery + ? { + name: { $autocomplete: searchQuery }, + } + : {}, + }, + }, + }, + ); + source.activate(); + source.sort = [{ name: 1 }]; + return source; + }); + + return ( + <ChannelAddMembersContext.Provider value={{ selectionStore, searchSource }}> + {children} + </ChannelAddMembersContext.Provider> + ); +}; + +/** + * @experimental This API is experimental and is subject to change. + */ +export const useChannelAddMembersContext = () => { + const contextValue = useContext( + ChannelAddMembersContext, + ) as unknown as ChannelAddMembersContextValue; + + if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { + throw new Error( + 'The useChannelAddMembersContext hook was called outside of the ChannelAddMembersContext provider. Render the add-members UI inside a ChannelAddMembersProvider.', + ); + } + + return contextValue; +}; diff --git a/package/src/contexts/channelDetailsContext/channelDetailsContext.tsx b/package/src/contexts/channelDetailsContext/channelDetailsContext.tsx new file mode 100644 index 0000000000..2e741cc0a7 --- /dev/null +++ b/package/src/contexts/channelDetailsContext/channelDetailsContext.tsx @@ -0,0 +1,43 @@ +import React, { PropsWithChildren, useContext } from 'react'; + +import { ChannelDetailsProps } from '../../components'; + +import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; +import { isTestEnvironment } from '../utils/isTestEnvironment'; + +/** + * @experimental This API is experimental and is subject to change. + */ +export type ChannelDetailsContextValue = ChannelDetailsProps; +export const ChannelDetailsContext = React.createContext( + DEFAULT_BASE_CONTEXT_VALUE as ChannelDetailsContextValue, +); + +/** + * @experimental This API is experimental and is subject to change. + */ +export const ChannelDetailsContextProvider = ({ + children, + value, +}: PropsWithChildren<{ + value: ChannelDetailsContextValue; +}>) => ( + <ChannelDetailsContext.Provider value={value as unknown as ChannelDetailsContextValue}> + {children} + </ChannelDetailsContext.Provider> +); + +/** + * @experimental This API is experimental and is subject to change. + */ +export const useChannelDetailsContext = () => { + const contextValue = useContext(ChannelDetailsContext) as unknown as ChannelDetailsContextValue; + + if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { + throw new Error( + 'The useChannelDetailsContext hook was called outside of the ChannelDetailsContext provider. Render the ChannelDetails component (or its content) inside a ChannelDetailsContextProvider.', + ); + } + + return contextValue; +}; diff --git a/package/src/contexts/channelDetailsContext/index.ts b/package/src/contexts/channelDetailsContext/index.ts new file mode 100644 index 0000000000..51310de23d --- /dev/null +++ b/package/src/contexts/channelDetailsContext/index.ts @@ -0,0 +1 @@ +export * from './channelDetailsContext'; diff --git a/package/src/contexts/channelEditDetailsContext/ChannelEditDetailsContext.tsx b/package/src/contexts/channelEditDetailsContext/ChannelEditDetailsContext.tsx new file mode 100644 index 0000000000..079df04e30 --- /dev/null +++ b/package/src/contexts/channelEditDetailsContext/ChannelEditDetailsContext.tsx @@ -0,0 +1,56 @@ +import React, { PropsWithChildren, useContext, useState } from 'react'; + +import { Channel } from 'stream-chat'; + +import { EditChannelDetailsStore } from '../../state-store/edit-channel-details-store'; +import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; +import { isTestEnvironment } from '../utils/isTestEnvironment'; + +/** + * @experimental This API is experimental and is subject to change. + */ +export type ChannelEditDetailsContextValue = { + store: EditChannelDetailsStore; +}; + +export const ChannelEditDetailsContext = React.createContext( + DEFAULT_BASE_CONTEXT_VALUE as ChannelEditDetailsContextValue, +); + +/** + * Creates and provides an {@link EditChannelDetailsStore} snapshotted from the + * given channel. Mount this once per edit session — the store captures the + * channel's name/image at construction and does not track later WebSocket + * updates, so an inbound `channel.updated` does not clobber in-progress edits. + * + * @experimental This API is experimental and is subject to change. + */ +export const ChannelEditDetailsProvider = ({ + channel, + children, +}: PropsWithChildren<{ channel: Channel }>) => { + const [store] = useState(() => new EditChannelDetailsStore(channel)); + + return ( + <ChannelEditDetailsContext.Provider value={{ store }}> + {children} + </ChannelEditDetailsContext.Provider> + ); +}; + +/** + * @experimental This API is experimental and is subject to change. + */ +export const useChannelEditDetailsContext = () => { + const contextValue = useContext( + ChannelEditDetailsContext, + ) as unknown as ChannelEditDetailsContextValue; + + if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { + throw new Error( + 'The useChannelEditDetailsContext hook was called outside of the ChannelEditDetailsContext provider. Render the channel edit UI inside a ChannelEditDetailsProvider.', + ); + } + + return contextValue; +}; diff --git a/package/src/contexts/channelEditDetailsContext/index.ts b/package/src/contexts/channelEditDetailsContext/index.ts new file mode 100644 index 0000000000..bb8b5afd4a --- /dev/null +++ b/package/src/contexts/channelEditDetailsContext/index.ts @@ -0,0 +1 @@ +export * from './ChannelEditDetailsContext'; diff --git a/package/src/contexts/channelFileAttachmentListContext/ChannelFileAttachmentListContext.tsx b/package/src/contexts/channelFileAttachmentListContext/ChannelFileAttachmentListContext.tsx new file mode 100644 index 0000000000..e46e28b804 --- /dev/null +++ b/package/src/contexts/channelFileAttachmentListContext/ChannelFileAttachmentListContext.tsx @@ -0,0 +1,67 @@ +import React, { PropsWithChildren, useContext, useState } from 'react'; + +import { Channel, MessageSearchSource } from 'stream-chat'; + +import { useChatContext } from '..'; +import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; +import { isTestEnvironment } from '../utils/isTestEnvironment'; + +/** + * @experimental This API is experimental and is subject to change. + */ +export type ChannelFileAttachmentListContextValue = { + channel: Channel; + searchSource: MessageSearchSource; +}; + +export const ChannelFileAttachmentListContext = React.createContext( + DEFAULT_BASE_CONTEXT_VALUE as ChannelFileAttachmentListContextValue, +); + +/** + * @experimental This API is experimental and is subject to change. + */ +export const ChannelFileAttachmentListProvider = ({ + channel, + children, +}: PropsWithChildren<{ channel: Channel }>) => { + const { client } = useChatContext(); + const [searchSource] = useState(() => { + const source = new MessageSearchSource(client, { + allowEmptySearchString: true, + pageSize: 25, + resetOnNewSearchQuery: false, + }); + source.messageSearchChannelFilters = { cid: channel.cid, members: undefined }; + source.messageSearchFilters = { + $or: [{ 'attachments.type': 'file' }, { 'attachments.type': 'audio' }], + }; + // Newest first so the list groups cleanly under month section headers. + source.messageSearchSort = { created_at: -1 }; + source.activate(); + return source; + }); + + return ( + <ChannelFileAttachmentListContext.Provider value={{ channel, searchSource }}> + {children} + </ChannelFileAttachmentListContext.Provider> + ); +}; + +/** + * @experimental This API is experimental and is subject to change. + */ +export const useChannelFileAttachmentListContext = () => { + const contextValue = useContext( + ChannelFileAttachmentListContext, + ) as unknown as ChannelFileAttachmentListContextValue; + + if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { + throw new Error( + 'The useChannelFileAttachmentListContext hook was called outside of the ChannelFileAttachmentListContext provider. Render the file attachment list UI inside a ChannelFileAttachmentListProvider.', + ); + } + + return contextValue; +}; diff --git a/package/src/contexts/channelMediaListContext/ChannelMediaListContext.tsx b/package/src/contexts/channelMediaListContext/ChannelMediaListContext.tsx new file mode 100644 index 0000000000..f8019f3d80 --- /dev/null +++ b/package/src/contexts/channelMediaListContext/ChannelMediaListContext.tsx @@ -0,0 +1,68 @@ +import React, { PropsWithChildren, useContext, useState } from 'react'; + +import { Channel, MessageSearchSource } from 'stream-chat'; + +import { useChatContext } from '..'; +import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; +import { isTestEnvironment } from '../utils/isTestEnvironment'; + +/** + * @experimental This API is experimental and is subject to change. + */ +export type ChannelMediaListContextValue = { + channel: Channel; + searchSource: MessageSearchSource; +}; + +export const ChannelMediaListContext = React.createContext( + DEFAULT_BASE_CONTEXT_VALUE as ChannelMediaListContextValue, +); + +/** + * @experimental This API is experimental and is subject to change. + */ +export const ChannelMediaListProvider = ({ + channel, + children, +}: PropsWithChildren<{ channel: Channel }>) => { + const { client } = useChatContext(); + const [searchSource] = useState(() => { + const source = new MessageSearchSource(client, { + allowEmptySearchString: true, + pageSize: 25, + resetOnNewSearchQuery: false, + }); + source.messageSearchChannelFilters = { cid: channel.cid, members: undefined }; + source.messageSearchFilters = { + 'attachments.type': { $in: ['image', 'video'] }, + type: undefined, + }; + // Newest media first so the grid reads top-to-bottom from most recent. + source.messageSearchSort = { created_at: -1 }; + source.activate(); + return source; + }); + + return ( + <ChannelMediaListContext.Provider value={{ channel, searchSource }}> + {children} + </ChannelMediaListContext.Provider> + ); +}; + +/** + * @experimental This API is experimental and is subject to change. + */ +export const useChannelMediaListContext = () => { + const contextValue = useContext( + ChannelMediaListContext, + ) as unknown as ChannelMediaListContextValue; + + if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { + throw new Error( + 'The useChannelMediaListContext hook was called outside of the ChannelMediaListContext provider. Render the media list UI inside a ChannelMediaListProvider.', + ); + } + + return contextValue; +}; diff --git a/package/src/contexts/channelPinnedMessageListContext/ChannelPinnedMessageListContext.tsx b/package/src/contexts/channelPinnedMessageListContext/ChannelPinnedMessageListContext.tsx new file mode 100644 index 0000000000..7eeb179bb2 --- /dev/null +++ b/package/src/contexts/channelPinnedMessageListContext/ChannelPinnedMessageListContext.tsx @@ -0,0 +1,72 @@ +import React, { PropsWithChildren, useContext, useState } from 'react'; + +import { Channel, MessageSearchSource } from 'stream-chat'; + +import { useChatContext } from '..'; +import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; +import { isTestEnvironment } from '../utils/isTestEnvironment'; + +/** + * @experimental This API is experimental and is subject to change. + */ +export type ChannelPinnedMessageListContextValue = { + channel: Channel; + searchSource: MessageSearchSource; +}; + +export const ChannelPinnedMessageListContext = React.createContext( + DEFAULT_BASE_CONTEXT_VALUE as ChannelPinnedMessageListContextValue, +); + +/** + * @experimental This API is experimental and is subject to change. + */ +export const ChannelPinnedMessageListProvider = ({ + channel, + children, +}: PropsWithChildren<{ channel: Channel }>) => { + const { client } = useChatContext(); + const [searchSource] = useState(() => { + const source = new MessageSearchSource( + client, + { pageSize: 25, allowEmptySearchString: true, resetOnNewSearchQuery: false }, + { + messageSearch: { + initialFilterConfig: { + $or: { + enabled: true, + generate: ({ searchQuery }) => (searchQuery ? { text: { $q: searchQuery } } : {}), + }, + }, + }, + }, + ); + source.messageSearchChannelFilters = { cid: channel.cid, members: undefined }; + source.messageSearchFilters = { pinned: true, type: undefined }; + source.activate(); + return source; + }); + + return ( + <ChannelPinnedMessageListContext.Provider value={{ channel, searchSource }}> + {children} + </ChannelPinnedMessageListContext.Provider> + ); +}; + +/** + * @experimental This API is experimental and is subject to change. + */ +export const useChannelPinnedMessageListContext = () => { + const contextValue = useContext( + ChannelPinnedMessageListContext, + ) as unknown as ChannelPinnedMessageListContextValue; + + if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { + throw new Error( + 'The useChannelPinnedMessageListContext hook was called outside of the ChannelPinnedMessageListContext provider. Render the pinned message list UI inside a ChannelPinnedMessageListProvider.', + ); + } + + return contextValue; +}; diff --git a/package/src/contexts/channelsContext/ChannelsContext.tsx b/package/src/contexts/channelsContext/ChannelsContext.tsx index 0dd3b2738e..48e66f26aa 100644 --- a/package/src/contexts/channelsContext/ChannelsContext.tsx +++ b/package/src/contexts/channelsContext/ChannelsContext.tsx @@ -5,8 +5,8 @@ import type { FlatList } from 'react-native-gesture-handler'; import type { Channel } from 'stream-chat'; -import type { GetChannelActionItems } from '../../components/ChannelList/hooks/useChannelActionItems'; import type { QueryChannels } from '../../components/ChannelList/hooks/usePaginatedChannels'; +import type { GetChannelActionItems } from '../../hooks/actions/useChannelActionItems'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; @@ -118,6 +118,7 @@ export type ChannelsContextValue = { swipeActionsEnabled?: boolean; mutedStatusPosition?: 'trailingBottom' | 'inlineTitle'; + pinnedStatusPosition?: 'trailingBottom' | 'inlineTitle'; }; export const ChannelsContext = React.createContext( diff --git a/package/src/contexts/componentsContext/defaultComponents.ts b/package/src/contexts/componentsContext/defaultComponents.ts index 968c63d090..d6cb674696 100644 --- a/package/src/contexts/componentsContext/defaultComponents.ts +++ b/package/src/contexts/componentsContext/defaultComponents.ts @@ -24,9 +24,35 @@ import { AttachmentPickerContent } from '../../components/AttachmentPicker/compo import { AttachmentPickerSelectionBar } from '../../components/AttachmentPicker/components/AttachmentPickerSelectionBar'; import { ImageOverlaySelectedComponent } from '../../components/AttachmentPicker/components/ImageOverlaySelectedComponent'; import { AutoCompleteSuggestionHeader } from '../../components/AutoCompleteInput/AutoCompleteSuggestionHeader'; -import { AutoCompleteSuggestionItem } from '../../components/AutoCompleteInput/AutoCompleteSuggestionItem'; +import { + AutoCompleteSuggestionItem, + MentionSuggestionItem, +} from '../../components/AutoCompleteInput/AutoCompleteSuggestionItem'; import { AutoCompleteSuggestionList } from '../../components/AutoCompleteInput/AutoCompleteSuggestionList'; import { InputView } from '../../components/AutoCompleteInput/InputView'; +import { ChannelDetailsContent } from '../../components/ChannelDetails/ChannelDetails'; +import { + ChannelAddMembers, + ChannelDetailsActionsSection, + ChannelDetailsActionItem, + ChannelDetailsMemberSection, + ChannelDetailsNavigationSection, + ChannelDetailsProfile, + ChannelDetailsEditButton, + ChannelDetailsNavHeader, + ChannelEditDetails, + ChannelEditImageSheet, + ChannelEditName, + ChannelMemberActionsSheet, + ChannelMemberItem, + ChannelMemberList, + FileAttachmentItem, + FileAttachmentList, + MediaItem, + MediaList, + PinnedMessageItem, + PinnedMessageList, +} from '../../components/ChannelDetails/components'; import { ChannelListFooterLoadingIndicator } from '../../components/ChannelList/ChannelListFooterLoadingIndicator'; import { ChannelListHeaderErrorIndicator } from '../../components/ChannelList/ChannelListHeaderErrorIndicator'; import { ChannelListHeaderNetworkDownIndicator } from '../../components/ChannelList/ChannelListHeaderNetworkDownIndicator'; @@ -38,6 +64,7 @@ import { ChannelLastMessagePreview } from '../../components/ChannelPreview/Chann import { ChannelMessagePreviewDeliveryStatus } from '../../components/ChannelPreview/ChannelMessagePreviewDeliveryStatus'; import { ChannelPreviewMessage } from '../../components/ChannelPreview/ChannelPreviewMessage'; import { ChannelPreviewMutedStatus } from '../../components/ChannelPreview/ChannelPreviewMutedStatus'; +import { ChannelPreviewPinnedStatus } from '../../components/ChannelPreview/ChannelPreviewPinnedStatus'; import { ChannelPreviewStatus } from '../../components/ChannelPreview/ChannelPreviewStatus'; import { ChannelPreviewTitle } from '../../components/ChannelPreview/ChannelPreviewTitle'; import { ChannelPreviewTypingIndicator } from '../../components/ChannelPreview/ChannelPreviewTypingIndicator'; @@ -180,6 +207,7 @@ const components = { AutoCompleteSuggestionHeader, AutoCompleteSuggestionItem, AutoCompleteSuggestionList, + MentionSuggestionItem, ChannelDetailsBottomSheet, CooldownTimer, CircularProgressIndicator, @@ -256,6 +284,7 @@ const components = { ChannelPreviewMessage, ChannelPreviewMessageDeliveryStatus: ChannelMessagePreviewDeliveryStatus, ChannelPreviewMutedStatus, + ChannelPreviewPinnedStatus, ChannelPreviewStatus, ChannelPreviewTitle, ChannelPreviewTypingIndicator, @@ -289,6 +318,29 @@ const components = { // Channel details ChannelDetailsHeader, + // Channel Details Screen + ChannelAddMembers, + ChannelDetailsActionsSection, + ChannelDetailsActionItem, + ChannelDetailsMemberSection, + ChannelDetailsNavigationSection, + ChannelDetailsProfile, + ChannelDetailsContent, + ChannelDetailsEditButton, + ChannelDetailsNavHeader, + ChannelEditDetails, + ChannelEditImageSheet, + ChannelEditName, + ChannelMemberActionsSheet, + ChannelMemberItem, + ChannelMemberList, + FileAttachmentItem, + FileAttachmentList, + MediaItem, + MediaList, + PinnedMessageItem, + PinnedMessageList, + // Thread ThreadMessageComposer: MessageComposer, ThreadListComponent, diff --git a/package/src/contexts/index.ts b/package/src/contexts/index.ts index 09046c5685..c2bf09175d 100644 --- a/package/src/contexts/index.ts +++ b/package/src/contexts/index.ts @@ -29,6 +29,12 @@ export * from './threadsContext/ThreadListItemContext'; export * from './translationContext'; export * from './typingContext/TypingContext'; export * from './utils/getDisplayName'; +export * from './channelDetailsContext'; +export * from './channelAddMembersContext/ChannelAddMembersContext'; +export * from './channelEditDetailsContext'; +export * from './channelFileAttachmentListContext/ChannelFileAttachmentListContext'; +export * from './channelPinnedMessageListContext/ChannelPinnedMessageListContext'; +export * from './channelMediaListContext/ChannelMediaListContext'; export * from './pollContext'; export * from './liveLocationManagerContext'; export * from './audioPlayerContext/AudioPlayerContext'; diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts index 418d447213..fd1a908d7f 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts @@ -1,12 +1,10 @@ import { useEffect, useMemo } from 'react'; -import { FixedSizeQueueCache, MessageComposer } from 'stream-chat'; +import { MessageComposer } from 'stream-chat'; import { useChatContext } from '../../chatContext/ChatContext'; import { MessageComposerContextValue } from '../../messageComposerContext/MessageComposerContext'; -const queueCache = new FixedSizeQueueCache<string, MessageComposer>(64); - export const useCreateMessageComposer = ({ editing: editedMessage, thread: parentMessage, @@ -14,6 +12,7 @@ export const useCreateMessageComposer = ({ channel, }: Pick<MessageComposerContextValue, 'channel' | 'threadInstance' | 'thread' | 'editing'>) => { const { client } = useChatContext(); + const { messageComposerCache: queueCache } = client; const cachedEditedMessage = useMemo(() => { if (!editedMessage) return undefined; @@ -69,7 +68,14 @@ export const useCreateMessageComposer = ({ } else { return channel.messageComposer; } - }, [cachedEditedMessage, cachedParentMessage, channel, client, threadInstance]); + }, [ + cachedEditedMessage, + cachedParentMessage, + channel.messageComposer, + client, + queueCache, + threadInstance, + ]); if ( (['legacy_thread', 'message'] as MessageComposer['contextType'][]).includes( diff --git a/package/src/contexts/ownCapabilitiesContext/OwnCapabilitiesContext.tsx b/package/src/contexts/ownCapabilitiesContext/OwnCapabilitiesContext.tsx index b6662236f4..1d3ab06816 100644 --- a/package/src/contexts/ownCapabilitiesContext/OwnCapabilitiesContext.tsx +++ b/package/src/contexts/ownCapabilitiesContext/OwnCapabilitiesContext.tsx @@ -21,6 +21,7 @@ export const allOwnCapabilities = { sendReply: 'send-reply', sendTypingEvents: 'send-typing-events', updateAnyMessage: 'update-any-message', + updateChannelMembers: 'update-channel-members', updateOwnMessage: 'update-own-message', uploadFile: 'upload-file', }; diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 9a9fdb936d..978d5058d5 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -185,6 +185,104 @@ export type Theme = { standardText: TextStyle; }; }; + channelDetails: { + container: ViewStyle; + scrollContent: ViewStyle; + header: { + container: ViewStyle; + title: TextStyle; + }; + profile: { + container: ViewStyle; + heading: ViewStyle; + subtitle: TextStyle; + title: TextStyle; + }; + sectionCard: ViewStyle; + actionItem: { + container: ViewStyle; + destructiveLabel: TextStyle; + iconWrapper: ViewStyle; + label: TextStyle; + trailingValue: TextStyle; + }; + memberSection: { + confirmButton: ViewStyle; + footer: ViewStyle; + header: ViewStyle; + headerTitle: TextStyle; + viewAllLabel: TextStyle; + }; + memberItem: { + container: ViewStyle; + name: TextStyle; + role: TextStyle; + status: TextStyle; + }; + memberActionsSheet: { + actionsList: ViewStyle; + container: ViewStyle; + header: ViewStyle; + }; + editImageSheet: { + actionsList: ViewStyle; + container: ViewStyle; + header: ViewStyle; + headerTitle: TextStyle; + }; + modal: { + body: ViewStyle; + header: ViewStyle; + headerTitle: TextStyle; + }; + addMembers: { + searchResultItem: { + alreadyMemberInfo: ViewStyle; + memberLabel: TextStyle; + userName: TextStyle; + userRow: ViewStyle; + }; + }; + editChannel: { + avatarSection: ViewStyle; + container: ViewStyle; + nameInput: ViewStyle; + uploadButton: ViewStyle; + }; + pinnedMessageList: { + container: ViewStyle; + list: ViewStyle; + listContent: ViewStyle; + }; + pinnedMessageItem: { + container: ViewStyle; + content: ViewStyle; + name: TextStyle; + title: ViewStyle; + }; + fileAttachmentList: { + container: ViewStyle; + list: ViewStyle; + listContent: ViewStyle; + sectionHeader: ViewStyle; + sectionHeaderText: TextStyle; + }; + fileAttachmentItem: { + container: ViewStyle; + }; + mediaList: { + container: ViewStyle; + list: ViewStyle; + listContent: ViewStyle; + }; + mediaItem: { + avatar: ViewStyle; + container: ViewStyle; + thumbnail: ImageStyle; + videoBadge: ViewStyle; + videoBadgeText: TextStyle; + }; + }; channelListSkeleton: { animationTime: number; avatar: ViewStyle; @@ -210,17 +308,55 @@ export type Theme = { textContainer: ViewStyle; timestamp: ViewStyle; }; + memberListSkeleton: { + animationTime: number; + avatar: ViewStyle; + container: ViewStyle; + content: ViewStyle; + subtitle: ViewStyle; + textContainer: ViewStyle; + title: ViewStyle; + }; + userListSkeleton: { + animationTime: number; + avatar: ViewStyle; + container: ViewStyle; + content: ViewStyle; + subtitle: ViewStyle; + textContainer: ViewStyle; + title: ViewStyle; + }; + pinnedMessageListSkeleton: { + animationTime: number; + avatar: ViewStyle; + container: ViewStyle; + content: ViewStyle; + subtitle: ViewStyle; + textContainer: ViewStyle; + title: ViewStyle; + }; + fileAttachmentListSkeleton: { + animationTime: number; + avatar: ViewStyle; + container: ViewStyle; + content: ViewStyle; + subtitle: ViewStyle; + textContainer: ViewStyle; + title: ViewStyle; + }; channelPreview: { container: ViewStyle; contentContainer: ViewStyle; date: TextStyle; mutedStatus: IconProps; + pinnedStatus: IconProps; messageDeliveryStatus: { container: ViewStyle; text: TextStyle; checkAllIcon: IconProps; checkIcon: IconProps; timeIcon: IconProps; + username: TextStyle; }; lowerRow: ViewStyle; title: TextStyle; @@ -250,6 +386,15 @@ export type Theme = { container: ViewStyle; text: TextStyle; }; + emptyList: { + container: ViewStyle; + subtitle: TextStyle; + title: TextStyle; + }; + emptySearchResult: { + container: ViewStyle; + text: TextStyle; + }; emptyStateIndicator: { channelContainer: ViewStyle; channelDetails: TextStyle; @@ -473,6 +618,10 @@ export type Theme = { avatarSize: number; column: ViewStyle; container: ViewStyle; + enhancedMentionContainer: ViewStyle; + enhancedMentionIcon: ViewStyle; + enhancedMentionSubtitle: TextStyle; + enhancedMentionTitle: TextStyle; name: TextStyle; tag: TextStyle; }; @@ -480,6 +629,7 @@ export type Theme = { suggestionsListContainer: { container: ViewStyle; flatlist: ViewStyle; + flatlistContentContainer: ViewStyle; }; videoAttachmentUploadPreview: { durationContainer: ViewStyle; @@ -990,6 +1140,10 @@ export type Theme = { }; }; screenPadding: number; + selectionCircle: { + circle: ViewStyle; + circleSelected: ViewStyle; + }; spinner: ViewStyle; thread: { newThread: { @@ -1138,6 +1292,104 @@ export const defaultTheme: Theme = { standardText: {}, }, }, + channelDetails: { + container: {}, + scrollContent: {}, + header: { + container: {}, + title: {}, + }, + profile: { + container: {}, + heading: {}, + subtitle: {}, + title: {}, + }, + sectionCard: {}, + actionItem: { + container: {}, + destructiveLabel: {}, + iconWrapper: {}, + label: {}, + trailingValue: {}, + }, + memberSection: { + confirmButton: {}, + footer: {}, + header: {}, + headerTitle: {}, + viewAllLabel: {}, + }, + memberItem: { + container: {}, + name: {}, + role: {}, + status: {}, + }, + memberActionsSheet: { + actionsList: {}, + container: {}, + header: {}, + }, + editImageSheet: { + actionsList: {}, + container: {}, + header: {}, + headerTitle: {}, + }, + modal: { + body: {}, + header: {}, + headerTitle: {}, + }, + addMembers: { + searchResultItem: { + alreadyMemberInfo: {}, + memberLabel: {}, + userName: {}, + userRow: {}, + }, + }, + editChannel: { + avatarSection: {}, + container: {}, + nameInput: {}, + uploadButton: {}, + }, + pinnedMessageList: { + container: {}, + list: {}, + listContent: {}, + }, + pinnedMessageItem: { + container: {}, + content: {}, + name: {}, + title: {}, + }, + fileAttachmentList: { + container: {}, + list: {}, + listContent: {}, + sectionHeader: {}, + sectionHeaderText: {}, + }, + fileAttachmentItem: { + container: {}, + }, + mediaList: { + container: {}, + list: {}, + listContent: {}, + }, + mediaItem: { + avatar: {}, + container: {}, + thumbnail: {}, + videoBadge: {}, + videoBadgeText: {}, + }, + }, channelListSkeleton: { animationTime: 1000, // in milliseconds avatar: {}, @@ -1163,6 +1415,42 @@ export const defaultTheme: Theme = { textContainer: {}, timestamp: {}, }, + memberListSkeleton: { + animationTime: 1000, // in milliseconds + avatar: {}, + container: {}, + content: {}, + subtitle: {}, + textContainer: {}, + title: {}, + }, + userListSkeleton: { + animationTime: 1000, // in milliseconds + avatar: {}, + container: {}, + content: {}, + subtitle: {}, + textContainer: {}, + title: {}, + }, + pinnedMessageListSkeleton: { + animationTime: 1000, // in milliseconds + avatar: {}, + container: {}, + content: {}, + subtitle: {}, + textContainer: {}, + title: {}, + }, + fileAttachmentListSkeleton: { + animationTime: 1000, // in milliseconds + avatar: {}, + container: {}, + content: {}, + subtitle: {}, + textContainer: {}, + title: {}, + }, channelPreview: { container: {}, contentContainer: {}, @@ -1179,8 +1467,10 @@ export const defaultTheme: Theme = { checkAllIcon: {}, checkIcon: {}, timeIcon: {}, + username: {}, }, mutedStatus: {}, + pinnedStatus: {}, lowerRow: {}, title: {}, unreadContainer: {}, @@ -1203,6 +1493,15 @@ export const defaultTheme: Theme = { container: {}, text: {}, }, + emptyList: { + container: {}, + subtitle: {}, + title: {}, + }, + emptySearchResult: { + container: {}, + text: {}, + }, emptyStateIndicator: { channelContainer: {}, channelDetails: {}, @@ -1418,6 +1717,10 @@ export const defaultTheme: Theme = { avatarSize: 40, column: {}, container: {}, + enhancedMentionContainer: {}, + enhancedMentionIcon: {}, + enhancedMentionSubtitle: {}, + enhancedMentionTitle: {}, name: {}, tag: {}, }, @@ -1425,6 +1728,7 @@ export const defaultTheme: Theme = { suggestionsListContainer: { container: {}, flatlist: {}, + flatlistContentContainer: {}, }, wrapper: {}, linkPreviewList: { @@ -1921,6 +2225,10 @@ export const defaultTheme: Theme = { }, }, screenPadding: 16, + selectionCircle: { + circle: {}, + circleSelected: {}, + }, spinner: {}, thread: { newThread: { diff --git a/package/src/hooks/__tests__/useChannelMuteActive.test.tsx b/package/src/hooks/__tests__/useChannelMuteActive.test.tsx new file mode 100644 index 0000000000..fe93178130 --- /dev/null +++ b/package/src/hooks/__tests__/useChannelMuteActive.test.tsx @@ -0,0 +1,149 @@ +import React, { PropsWithChildren } from 'react'; + +import { renderHook } from '@testing-library/react-native'; +import type { Channel, ChannelMute, Mute } from 'stream-chat'; + +import { ChatProvider } from '../../contexts/chatContext/ChatContext'; +import { useChannelMuteActive } from '../useChannelMuteActive'; + +const CURRENT_USER_ID = 'current-user-id'; +const OTHER_USER_ID = 'other-user-id'; +const THIRD_USER_ID = 'third-user-id'; +const CHANNEL_CID = 'messaging:test'; + +const createClient = ({ + mutedChannels = [], + mutedUsers = [], +}: { mutedChannels?: ChannelMute[]; mutedUsers?: Mute[] } = {}) => + ({ + mutedChannels, + mutedUsers, + on: jest.fn(() => ({ unsubscribe: jest.fn() })), + userID: CURRENT_USER_ID, + }) as never; + +const createChannel = ( + client: ReturnType<typeof createClient>, + { isDirect = true, cid = CHANNEL_CID }: { isDirect?: boolean; cid?: string } = {}, +) => + ({ + cid, + getClient: () => client, + on: jest.fn(() => ({ unsubscribe: jest.fn() })), + state: { + members: isDirect + ? { + current: { user: { id: CURRENT_USER_ID } }, + other: { user: { id: OTHER_USER_ID } }, + } + : { + current: { user: { id: CURRENT_USER_ID } }, + other1: { user: { id: OTHER_USER_ID } }, + other2: { user: { id: THIRD_USER_ID } }, + }, + }, + }) as unknown as Channel; + +const mutedChannelEntry = (cid: string) => ({ channel: { cid } }) as unknown as ChannelMute; + +const mutedUserEntry = (userId: string) => + ({ target: { id: userId }, user: { id: CURRENT_USER_ID } }) as unknown as Mute; + +const createWrapper = + (client: unknown) => + ({ children }: PropsWithChildren) => ( + <ChatProvider value={{ client } as never}>{children}</ChatProvider> + ); + +describe('useChannelMuteActive', () => { + describe('direct chat (2 members)', () => { + it('returns false when neither channel nor other user is muted', () => { + const client = createClient(); + const channel = createChannel(client); + const { result } = renderHook(() => useChannelMuteActive(channel), { + wrapper: createWrapper(client), + }); + expect(result.current).toBe(false); + }); + + it('returns true when the channel is muted but the user is not', () => { + const client = createClient({ mutedChannels: [mutedChannelEntry(CHANNEL_CID)] }); + const channel = createChannel(client); + const { result } = renderHook(() => useChannelMuteActive(channel), { + wrapper: createWrapper(client), + }); + expect(result.current).toBe(true); + }); + + it('returns true when the other user is muted but the channel is not', () => { + const client = createClient({ mutedUsers: [mutedUserEntry(OTHER_USER_ID)] }); + const channel = createChannel(client); + const { result } = renderHook(() => useChannelMuteActive(channel), { + wrapper: createWrapper(client), + }); + expect(result.current).toBe(true); + }); + + it('returns true when both the channel and the other user are muted', () => { + const client = createClient({ + mutedChannels: [mutedChannelEntry(CHANNEL_CID)], + mutedUsers: [mutedUserEntry(OTHER_USER_ID)], + }); + const channel = createChannel(client); + const { result } = renderHook(() => useChannelMuteActive(channel), { + wrapper: createWrapper(client), + }); + expect(result.current).toBe(true); + }); + + it('ignores mutes targeting an unrelated user', () => { + const client = createClient({ mutedUsers: [mutedUserEntry('unrelated-user')] }); + const channel = createChannel(client); + const { result } = renderHook(() => useChannelMuteActive(channel), { + wrapper: createWrapper(client), + }); + expect(result.current).toBe(false); + }); + + it('ignores mutes targeting an unrelated channel', () => { + const client = createClient({ mutedChannels: [mutedChannelEntry('messaging:other')] }); + const channel = createChannel(client); + const { result } = renderHook(() => useChannelMuteActive(channel), { + wrapper: createWrapper(client), + }); + expect(result.current).toBe(false); + }); + }); + + describe('group chat (3+ members)', () => { + it('returns true when the channel is muted (even if a member is also muted)', () => { + const client = createClient({ + mutedChannels: [mutedChannelEntry(CHANNEL_CID)], + mutedUsers: [mutedUserEntry(OTHER_USER_ID)], + }); + const channel = createChannel(client, { isDirect: false }); + const { result } = renderHook(() => useChannelMuteActive(channel), { + wrapper: createWrapper(client), + }); + expect(result.current).toBe(true); + }); + + it('returns false when only a member is muted but the channel is not', () => { + const client = createClient({ mutedUsers: [mutedUserEntry(OTHER_USER_ID)] }); + const channel = createChannel(client, { isDirect: false }); + const { result } = renderHook(() => useChannelMuteActive(channel), { + wrapper: createWrapper(client), + }); + expect(result.current).toBe(false); + }); + + it('returns false when nothing is muted', () => { + const client = createClient(); + const channel = createChannel(client, { isDirect: false }); + const { result } = renderHook(() => useChannelMuteActive(channel), { + wrapper: createWrapper(client), + }); + expect(result.current).toBe(false); + }); + }); +}); diff --git a/package/src/hooks/__tests__/useIsChannelMember.test.tsx b/package/src/hooks/__tests__/useIsChannelMember.test.tsx new file mode 100644 index 0000000000..b9ebe01caa --- /dev/null +++ b/package/src/hooks/__tests__/useIsChannelMember.test.tsx @@ -0,0 +1,73 @@ +import { renderHook } from '@testing-library/react-native'; +import type { Channel, ChannelMemberResponse } from 'stream-chat'; + +import { useChannelMembersState } from '../../components/ChannelList/hooks/useChannelMembersState'; +import { useIsChannelMember } from '../useIsChannelMember'; + +jest.mock('../../components/ChannelList/hooks/useChannelMembersState'); + +const mockedUseChannelMembersState = useChannelMembersState as jest.MockedFunction< + typeof useChannelMembersState +>; + +const CURRENT_USER_ID = 'current-user-id'; +const OTHER_USER_ID = 'other-user-id'; + +const channel = {} as Channel; + +const setMembers = (members: Record<string, ChannelMemberResponse>) => { + mockedUseChannelMembersState.mockReturnValue(members); +}; + +describe('useIsChannelMember', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns true when the user is a member of the channel', () => { + setMembers({ + [CURRENT_USER_ID]: { user: { id: CURRENT_USER_ID } }, + [OTHER_USER_ID]: { user: { id: OTHER_USER_ID } }, + }); + + const { result } = renderHook(() => useIsChannelMember(channel, OTHER_USER_ID)); + + expect(result.current).toBe(true); + }); + + it('returns false when the user is not a member of the channel', () => { + setMembers({ + [CURRENT_USER_ID]: { user: { id: CURRENT_USER_ID } }, + }); + + const { result } = renderHook(() => useIsChannelMember(channel, 'unknown-user-id')); + + expect(result.current).toBe(false); + }); + + it('returns false when no userId is provided', () => { + setMembers({ + [CURRENT_USER_ID]: { user: { id: CURRENT_USER_ID } }, + }); + + const { result } = renderHook(() => useIsChannelMember(channel)); + + expect(result.current).toBe(false); + }); + + it('returns false when the channel has no members', () => { + setMembers({}); + + const { result } = renderHook(() => useIsChannelMember(channel, OTHER_USER_ID)); + + expect(result.current).toBe(false); + }); + + it('passes the channel through to useChannelMembersState', () => { + setMembers({}); + + renderHook(() => useIsChannelMember(channel, OTHER_USER_ID)); + + expect(mockedUseChannelMembersState).toHaveBeenCalledWith(channel); + }); +}); diff --git a/package/src/hooks/actions/__tests__/useChannelActionItems.test.tsx b/package/src/hooks/actions/__tests__/useChannelActionItems.test.tsx new file mode 100644 index 0000000000..229dcff90d --- /dev/null +++ b/package/src/hooks/actions/__tests__/useChannelActionItems.test.tsx @@ -0,0 +1,544 @@ +import { Alert, AlertButton } from 'react-native'; + +import { renderHook } from '@testing-library/react-native'; +import type { Channel, Mute } from 'stream-chat'; + +import * as useMutedUsersModule from '../../../components/ChannelList/hooks/useMutedUsers'; +import * as useIsChannelMutedModule from '../../../components/ChannelPreview/hooks/useIsChannelMuted'; +import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext'; +import * as TranslationContext from '../../../contexts/translationContext/TranslationContext'; +import * as useChannelMembershipStateModule from '../../useChannelMembershipState'; +import * as useIsDirectChatModule from '../../useIsDirectChat'; +import { + GetChannelActionItems, + buildDefaultChannelActionItems, + getChannelActionItems, + useChannelActionItems, +} from '../useChannelActionItems'; +import * as useChannelActionsModule from '../useChannelActions'; + +const createChannelActions = (): useChannelActionsModule.ChannelActions => ({ + addMembers: jest.fn(), + removeMembers: jest.fn(), + archive: jest.fn(), + blockUser: jest.fn(), + deleteChannel: jest.fn(), + leave: jest.fn(), + muteChannel: jest.fn(), + muteUser: jest.fn(), + pin: jest.fn(), + unarchive: jest.fn(), + unblockUser: jest.fn(), + unmuteChannel: jest.fn(), + unmuteUser: jest.fn(), + unpin: jest.fn(), + updateImage: jest.fn(), + updateName: jest.fn(), +}); + +const createChannelMock = (params?: { + blockedUserIds?: string[]; + createdById?: string; + ownUserId?: string; +}): Channel => { + const { + blockedUserIds = [], + createdById = 'current-user-id', + ownUserId = 'current-user-id', + } = params ?? {}; + return { + data: { + created_by: { + id: createdById, + }, + }, + getClient: () => ({ + blockedUsers: { + getLatestValue: () => ({ userIds: blockedUserIds }), + subscribeWithSelector: () => () => {}, + }, + userID: ownUserId, + }), + state: { + members: { + own: { user: { id: ownUserId } }, + other: { user: { id: 'other-user-id' } }, + }, + }, + } as unknown as Channel; +}; + +describe('useChannelActionItems', () => { + const channel = createChannelMock(); + + const channelActions = createChannelActions(); + + beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(TranslationContext, 'useTranslationContext') + .mockImplementation( + () => ({ t: (value: string) => value }) as unknown as TranslationContextValue, + ); + jest.spyOn(useChannelMembershipStateModule, 'useChannelMembershipState').mockReturnValue({ + archived_at: undefined, + pinned_at: undefined, + } as never); + jest.spyOn(useIsChannelMutedModule, 'useIsChannelMuted').mockReturnValue({ + createdAt: null, + expiresAt: null, + muted: false, + }); + jest.spyOn(useMutedUsersModule, 'useMutedUsers').mockReturnValue([] as Mute[]); + jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(false); + jest.spyOn(useChannelActionsModule, 'useChannelActions').mockReturnValue(channelActions); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns default channel action items', () => { + const { result } = renderHook(() => useChannelActionItems({ channel, surface: 'list' })); + + expect(result.current).toHaveLength(4); + expect(result.current.map((item) => item.action)).toEqual([ + channelActions.muteChannel, + channelActions.pin, + channelActions.leave, + expect.any(Function), + ]); + expect(result.current.map((item) => item.id)).toEqual([ + 'mute', + 'pin', + 'leave', + 'deleteChannel', + ]); + expect(result.current.map((item) => item.type)).toEqual([ + 'standard', + 'standard', + 'destructive', + 'destructive', + ]); + expect(result.current.map((item) => item.placement)).toEqual([ + 'swipe', + 'sheet', + 'sheet', + 'sheet', + ]); + }); + + it('returns muteUser only in direct chats', () => { + jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(true); + + const { result } = renderHook(() => useChannelActionItems({ channel, surface: 'list' })); + + expect(result.current.map((item) => item.id)).toEqual([ + 'mute', + 'pin', + 'muteUser', + 'block', + 'leave', + 'deleteChannel', + ]); + expect(result.current.find((item) => item.id === 'muteUser')?.action).toBe( + channelActions.muteUser, + ); + expect(result.current.find((item) => item.id === 'muteUser')?.placement).toBe('sheet'); + }); + + it('mute action always targets the channel and muteUser toggles independently', () => { + jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(true); + jest.spyOn(useIsChannelMutedModule, 'useIsChannelMuted').mockReturnValue({ + createdAt: null, + expiresAt: null, + muted: true, + }); + jest.spyOn(useMutedUsersModule, 'useMutedUsers').mockReturnValue([] as Mute[]); + + const { result } = renderHook(() => useChannelActionItems({ channel, surface: 'list' })); + + const muteItem = result.current.find((item) => item.id === 'mute'); + const muteUserItem = result.current.find((item) => item.id === 'muteUser'); + expect(muteItem?.action).toBe(channelActions.unmuteChannel); + expect(muteUserItem?.action).toBe(channelActions.muteUser); + }); + + it('forwards options from item.action to the underlying channel action', async () => { + const { result } = renderHook(() => useChannelActionItems({ channel, surface: 'list' })); + + const muteItem = result.current.find((item) => item.id === 'mute'); + expect(muteItem).toBeDefined(); + const onSuccess = jest.fn(); + await muteItem?.action({ onSuccess }); + + expect(channelActions.muteChannel).toHaveBeenCalledWith({ onSuccess }); + }); + + it('marks block as destructive when user is not blocked', () => { + jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(true); + + const { result } = renderHook(() => useChannelActionItems({ channel, surface: 'list' })); + + const blockItem = result.current.find((item) => item.id === 'block'); + expect(blockItem?.type).toBe('destructive'); + }); + + it('keeps block standard when user is already blocked', () => { + jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(true); + const blockedChannel = createChannelMock({ blockedUserIds: ['other-user-id'] }); + + const { result } = renderHook(() => + useChannelActionItems({ channel: blockedChannel, surface: 'list' }), + ); + + const blockItem = result.current.find((item) => item.id === 'block'); + expect(blockItem?.type).toBe('standard'); + }); + + it('uses custom getChannelActionItems with context and defaultItems when provided', () => { + const customGetChannelActionItems = jest.fn( + ({ defaultItems }: Parameters<GetChannelActionItems>[0]) => defaultItems.slice(0, 1), + ); + + const { result } = renderHook(() => + useChannelActionItems({ + channel, + getChannelActionItems: customGetChannelActionItems, + surface: 'list', + }), + ); + + expect(customGetChannelActionItems).toHaveBeenCalledWith({ + context: { + actions: channelActions, + channel, + channelMuteActive: false, + isArchived: false, + isBlocked: undefined, + isDirectChat: false, + isPinned: false, + surface: 'list', + t: expect.any(Function), + userMuteActive: false, + }, + defaultItems: expect.any(Array), + }); + expect(result.current).toHaveLength(1); + expect(result.current[0].action).toBe(channelActions.muteChannel); + expect(result.current[0].id).toBe('mute'); + expect(result.current[0].type).toBe('standard'); + }); +}); + +describe('getChannelActionItems', () => { + const channel = createChannelMock(); + + it('creates action items in default order', () => { + const channelActions = createChannelActions(); + + const defaultItems = buildDefaultChannelActionItems({ + actions: channelActions, + channel, + channelMuteActive: false, + isArchived: false, + isBlocked: undefined, + isDirectChat: false, + isPinned: false, + surface: 'list', + t: ((value: string) => value) as TranslationContextValue['t'], + userMuteActive: false, + }); + const actionItems = getChannelActionItems({ + context: { + actions: channelActions, + channel, + channelMuteActive: false, + isArchived: false, + isBlocked: undefined, + isDirectChat: false, + isPinned: false, + surface: 'list', + t: ((value: string) => value) as TranslationContextValue['t'], + userMuteActive: false, + }, + defaultItems, + }); + + expect(actionItems.map((item) => item.action)).toEqual([ + channelActions.muteChannel, + channelActions.pin, + channelActions.leave, + expect.any(Function), + ]); + expect(actionItems.map((item) => item.id)).toEqual(['mute', 'pin', 'leave', 'deleteChannel']); + expect(actionItems.map((item) => item.type)).toEqual([ + 'standard', + 'standard', + 'destructive', + 'destructive', + ]); + }); + + it('returns direct-chat variants for mute and block states', () => { + const channelActions = createChannelActions(); + const actionItems = buildDefaultChannelActionItems({ + actions: channelActions, + channel: createChannelMock({ blockedUserIds: ['other-user-id'] }), + channelMuteActive: true, + isArchived: true, + isBlocked: true, + isDirectChat: true, + isPinned: false, + surface: 'list', + t: ((value: string) => value) as TranslationContextValue['t'], + userMuteActive: true, + }); + + expect(actionItems.map((item) => item.id)).toEqual([ + 'mute', + 'pin', + 'muteUser', + 'block', + 'leave', + 'deleteChannel', + ]); + expect(actionItems.map((item) => item.action)).toEqual([ + channelActions.unmuteChannel, + channelActions.pin, + channelActions.unmuteUser, + channelActions.unblockUser, + channelActions.leave, + expect.any(Function), + ]); + expect(actionItems.map((item) => item.label)).toEqual([ + 'Unmute Chat', + 'Pin Chat', + 'Unmute User', + 'Unblock User', + 'Leave Chat', + 'Delete Chat', + ]); + expect(actionItems.map((item) => item.placement)).toEqual([ + 'swipe', + 'sheet', + 'sheet', + 'sheet', + 'sheet', + 'sheet', + ]); + }); + + it('omits muteUser when not a direct chat', () => { + const actionItems = buildDefaultChannelActionItems({ + actions: createChannelActions(), + channel, + channelMuteActive: false, + isArchived: false, + isBlocked: undefined, + isDirectChat: false, + isPinned: false, + surface: 'list', + t: ((value: string) => value) as TranslationContextValue['t'], + userMuteActive: false, + }); + + expect(actionItems.map((item) => item.id)).not.toContain('muteUser'); + }); + + it('omits delete action when current user is not the channel creator', () => { + const actionItems = buildDefaultChannelActionItems({ + actions: createChannelActions(), + channel: createChannelMock({ createdById: 'someone-else' }), + channelMuteActive: false, + isArchived: false, + isBlocked: undefined, + isDirectChat: false, + isPinned: false, + surface: 'list', + t: ((value: string) => value) as TranslationContextValue['t'], + userMuteActive: false, + }); + + expect(actionItems.map((item) => item.id)).toEqual(['mute', 'pin', 'leave']); + }); + + it('uses group mute variants for labels and placements', () => { + const channelActions = createChannelActions(); + const actionItems = buildDefaultChannelActionItems({ + actions: channelActions, + channel, + channelMuteActive: true, + isArchived: true, + isBlocked: undefined, + isDirectChat: false, + isPinned: false, + surface: 'list', + t: ((value: string) => value) as TranslationContextValue['t'], + userMuteActive: false, + }); + + expect(actionItems[0].action).toBe(channelActions.unmuteChannel); + expect(actionItems[0].label).toBe('Unmute Group'); + expect(actionItems[0].placement).toBe('swipe'); + + const leaveItem = actionItems.find((item) => item.id === 'leave'); + expect(leaveItem?.action).toBe(channelActions.leave); + expect(leaveItem?.label).toBe('Leave Group'); + expect(leaveItem?.placement).toBe('sheet'); + }); + + it('pin item toggles to unpin when channel is pinned', () => { + const channelActions = createChannelActions(); + const actionItems = buildDefaultChannelActionItems({ + actions: channelActions, + channel, + channelMuteActive: false, + isArchived: false, + isBlocked: undefined, + isDirectChat: false, + isPinned: true, + surface: 'list', + t: ((value: string) => value) as TranslationContextValue['t'], + userMuteActive: false, + }); + + const pinItem = actionItems.find((item) => item.id === 'pin'); + expect(pinItem?.action).toBe(channelActions.unpin); + expect(pinItem?.label).toBe('Unpin Group'); + expect(pinItem?.placement).toBe('sheet'); + }); + + it('pin item uses direct-chat label variant', () => { + const channelActions = createChannelActions(); + const actionItems = buildDefaultChannelActionItems({ + actions: channelActions, + channel, + channelMuteActive: false, + isArchived: false, + isBlocked: undefined, + isDirectChat: true, + isPinned: false, + surface: 'list', + t: ((value: string) => value) as TranslationContextValue['t'], + userMuteActive: false, + }); + + const pinItem = actionItems.find((item) => item.id === 'pin'); + expect(pinItem?.action).toBe(channelActions.pin); + expect(pinItem?.label).toBe('Pin Chat'); + }); + + it('omits the pin item when building for the details surface', () => { + const actionItems = buildDefaultChannelActionItems({ + actions: createChannelActions(), + channel, + channelMuteActive: false, + isArchived: false, + isBlocked: undefined, + isDirectChat: false, + isPinned: false, + surface: 'details', + t: ((value: string) => value) as TranslationContextValue['t'], + userMuteActive: false, + }); + + expect(actionItems.map((item) => item.id)).not.toContain('pin'); + }); + + it('includes the pin item when building for the list surface', () => { + const actionItems = buildDefaultChannelActionItems({ + actions: createChannelActions(), + channel, + channelMuteActive: false, + isArchived: false, + isBlocked: undefined, + isDirectChat: false, + isPinned: false, + surface: 'list', + t: ((value: string) => value) as TranslationContextValue['t'], + userMuteActive: false, + }); + + expect(actionItems.map((item) => item.id)).toContain('pin'); + }); + + it('applies no surface-specific filtering when surface is omitted', () => { + const actionItems = buildDefaultChannelActionItems({ + actions: createChannelActions(), + channel, + channelMuteActive: false, + isArchived: false, + isBlocked: undefined, + isDirectChat: false, + isPinned: false, + t: ((value: string) => value) as TranslationContextValue['t'], + userMuteActive: false, + }); + + expect(actionItems.map((item) => item.id)).toEqual(['mute', 'pin', 'leave', 'deleteChannel']); + }); + + it('mute and muteUser reflect their respective active states independently', () => { + const channelActions = createChannelActions(); + const actionItems = buildDefaultChannelActionItems({ + actions: channelActions, + channel, + channelMuteActive: false, + isArchived: false, + isBlocked: undefined, + isDirectChat: true, + isPinned: false, + surface: 'list', + t: ((value: string) => value) as TranslationContextValue['t'], + userMuteActive: true, + }); + + const muteItem = actionItems.find((item) => item.id === 'mute'); + const muteUserItem = actionItems.find((item) => item.id === 'muteUser'); + + expect(muteItem?.action).toBe(channelActions.muteChannel); + expect(muteItem?.label).toBe('Mute Chat'); + expect(muteUserItem?.action).toBe(channelActions.unmuteUser); + expect(muteUserItem?.label).toBe('Unmute User'); + }); + + it('shows delete confirmation and calls deleteChannel on destructive confirm', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(jest.fn()); + const channelActions = createChannelActions(); + + const actionItems = buildDefaultChannelActionItems({ + actions: channelActions, + channel, + channelMuteActive: false, + isArchived: false, + isBlocked: undefined, + isDirectChat: false, + isPinned: false, + surface: 'list', + t: ((value: string) => value) as TranslationContextValue['t'], + userMuteActive: false, + }); + + const deleteItem = actionItems.find((item) => item.id === 'deleteChannel'); + expect(deleteItem).toBeDefined(); + const onSuccess = jest.fn(); + await deleteItem?.action({ onSuccess }); + + expect(alertSpy).toHaveBeenCalledWith( + 'Delete group', + "Are you sure you want to delete this group? This can't be undone.", + expect.any(Array), + ); + + const buttons = (alertSpy.mock.calls[0]?.[2] ?? []) as AlertButton[]; + const destructiveButton = buttons.find((button) => button.style === 'destructive'); + + expect(destructiveButton?.text).toBe('Delete'); + await destructiveButton?.onPress?.(); + expect(channelActions.deleteChannel).toHaveBeenCalledTimes(1); + expect(channelActions.deleteChannel).toHaveBeenCalledWith({ onSuccess }); + + alertSpy.mockRestore(); + }); +}); diff --git a/package/src/components/ChannelList/hooks/__tests__/useChannelActionItemsById.test.tsx b/package/src/hooks/actions/__tests__/useChannelActionItemsById.test.tsx similarity index 95% rename from package/src/components/ChannelList/hooks/__tests__/useChannelActionItemsById.test.tsx rename to package/src/hooks/actions/__tests__/useChannelActionItemsById.test.tsx index 36c9fcdaf9..0a5fdb166a 100644 --- a/package/src/components/ChannelList/hooks/__tests__/useChannelActionItemsById.test.tsx +++ b/package/src/hooks/actions/__tests__/useChannelActionItemsById.test.tsx @@ -41,7 +41,7 @@ describe('useChannelActionItemsById', () => { .spyOn(useChannelActionItemsModule, 'useChannelActionItems') .mockReturnValue(channelActionItems); - const { result } = renderHook(() => useChannelActionItemsById({ channel })); + const { result } = renderHook(() => useChannelActionItemsById({ channel, surface: 'list' })); expect(result.current.pin).toBe(channelActionItems[0]); expect(result.current.deleteChannel).toBe(channelActionItems[1]); @@ -59,12 +59,14 @@ describe('useChannelActionItemsById', () => { useChannelActionItemsById({ channel, getChannelActionItems, + surface: 'list', }), ); expect(useChannelActionItemsSpy).toHaveBeenCalledWith({ channel, getChannelActionItems, + surface: 'list', }); }); }); diff --git a/package/src/hooks/actions/__tests__/useChannelActions.test.tsx b/package/src/hooks/actions/__tests__/useChannelActions.test.tsx new file mode 100644 index 0000000000..c436a7b07f --- /dev/null +++ b/package/src/hooks/actions/__tests__/useChannelActions.test.tsx @@ -0,0 +1,728 @@ +import React, { PropsWithChildren } from 'react'; + +import { act, renderHook } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +import { ChatProvider } from '../../../contexts/chatContext/ChatContext'; +import type { File } from '../../../types/types'; +import { useChannelActions } from '../useChannelActions'; + +const imageFile: File = { + name: 'avatar.png', + size: 1024, + type: 'image/png', + uri: 'file:///tmp/avatar.png', +}; + +const createWrapper = + (client: unknown) => + ({ children }: PropsWithChildren) => ( + <ChatProvider value={{ client } as never}>{children}</ChatProvider> + ); + +const createClient = () => ({ + blockUser: jest.fn(), + notifications: { + add: jest.fn(), + remove: jest.fn(), + startTimeout: jest.fn(), + }, + muteUser: jest.fn(), + unBlockUser: jest.fn(), + unmuteUser: jest.fn(), + uploadImage: jest.fn().mockResolvedValue({ file: 'https://cdn.example.com/uploaded.png' }), + userID: 'current-user-id', +}); + +const createChannel = (client: ReturnType<typeof createClient>) => + ({ + addMembers: jest.fn(), + archive: jest.fn(), + getClient: () => client, + mute: jest.fn(), + pin: jest.fn(), + removeMembers: jest.fn(), + state: { + members: { + current: { user: { id: 'current-user-id' } }, + other: { user: { id: 'other-user-id', name: 'Other User' } }, + }, + }, + unarchive: jest.fn(), + unmute: jest.fn(), + unpin: jest.fn(), + updatePartial: jest.fn().mockResolvedValue({}), + }) as unknown as Channel; + +describe('useChannelActions', () => { + it('notifies when channel mute succeeds', async () => { + const client = createClient(); + const channel = createChannel(client); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.muteChannel(); + }); + + expect(channel.mute).toHaveBeenCalledTimes(1); + expect(client.notifications.add).toHaveBeenCalledWith({ + message: 'Channel muted', + options: { + severity: 'success', + type: 'api:channel:mute:success', + }, + origin: { + context: { channel }, + emitter: 'ChannelActions', + }, + }); + }); + + it('notifies when channel mute fails', async () => { + const error = new Error('mute failed'); + const client = createClient(); + const channel = createChannel(client); + jest.mocked(channel.mute).mockRejectedValue(error); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.muteChannel(); + }); + + expect(client.notifications.add).toHaveBeenCalledWith({ + message: 'Failed to update channel mute status', + options: { + originalError: error, + severity: 'error', + type: 'api:channel:mute:failed', + }, + origin: { + context: { channel }, + emitter: 'ChannelActions', + }, + }); + }); + + it('notifies when a direct channel user is blocked', async () => { + const client = createClient(); + const channel = createChannel(client); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.blockUser(); + }); + + expect(client.blockUser).toHaveBeenCalledWith('other-user-id'); + expect(client.notifications.add).toHaveBeenCalledWith({ + message: 'User blocked', + options: { + severity: 'success', + type: 'api:user:block:success', + }, + origin: { + context: { channel }, + emitter: 'ChannelActions', + }, + }); + }); + + it('calls onSuccess after a channel action succeeds', async () => { + const client = createClient(); + const channel = createChannel(client); + const onSuccess = jest.fn(); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.muteChannel({ onSuccess }); + }); + + expect(channel.mute).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it('calls onSuccess after a direct-channel user action succeeds', async () => { + const client = createClient(); + const channel = createChannel(client); + const onSuccess = jest.fn(); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.blockUser({ onSuccess }); + }); + + expect(client.blockUser).toHaveBeenCalledWith('other-user-id'); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it('does not call onSuccess when the underlying action throws', async () => { + const client = createClient(); + const channel = createChannel(client); + jest.mocked(channel.mute).mockRejectedValue(new Error('mute failed')); + const onSuccess = jest.fn(); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.muteChannel({ onSuccess }); + }); + + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('calls onFailure with the thrown error when the underlying action throws', async () => { + const client = createClient(); + const channel = createChannel(client); + const error = new Error('mute failed'); + jest.mocked(channel.mute).mockRejectedValue(error); + const onSuccess = jest.fn(); + const onFailure = jest.fn(); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.muteChannel({ onFailure, onSuccess }); + }); + + expect(onSuccess).not.toHaveBeenCalled(); + expect(onFailure).toHaveBeenCalledTimes(1); + expect(onFailure).toHaveBeenCalledWith(error); + }); + + it('does not call onFailure when the underlying action succeeds', async () => { + const client = createClient(); + const channel = createChannel(client); + const onFailure = jest.fn(); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.muteChannel({ onFailure }); + }); + + expect(onFailure).not.toHaveBeenCalled(); + }); + + it('does not call onSuccess when a direct-channel action short-circuits with no other user', async () => { + const client = createClient(); + const channel = createChannel(client); + // Force getOtherUserInDirectChannel to return undefined by collapsing to a single member. + (channel.state.members as Record<string, unknown>) = { + current: { user: { id: 'current-user-id' } }, + }; + const onSuccess = jest.fn(); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.blockUser({ onSuccess }); + }); + + expect(client.blockUser).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('notifies and calls channel.addMembers when adding members succeeds', async () => { + const client = createClient(); + const channel = createChannel(client); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.addMembers(['u-1', 'u-2']); + }); + + expect(channel.addMembers).toHaveBeenCalledWith(['u-1', 'u-2']); + expect(client.notifications.add).toHaveBeenCalledWith({ + message: '{{count}} members added', + options: { + severity: 'success', + type: 'api:channel:add-members:success', + }, + origin: { + context: { channel }, + emitter: 'ChannelActions', + }, + }); + }); + + it('notifies with originalError when adding members fails', async () => { + const error = new Error('add failed'); + const client = createClient(); + const channel = createChannel(client); + jest.mocked(channel.addMembers).mockRejectedValue(error); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.addMembers(['u-1']); + }); + + expect(client.notifications.add).toHaveBeenCalledWith({ + message: 'Failed to add members', + options: { + originalError: error, + severity: 'error', + type: 'api:channel:add-members:failed', + }, + origin: { + context: { channel }, + emitter: 'ChannelActions', + }, + }); + }); + + it('calls onSuccess after addMembers succeeds', async () => { + const client = createClient(); + const channel = createChannel(client); + const onSuccess = jest.fn(); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.addMembers(['u-1'], { onSuccess }); + }); + + expect(channel.addMembers).toHaveBeenCalledWith(['u-1']); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it('does not call onSuccess when addMembers rejects', async () => { + const client = createClient(); + const channel = createChannel(client); + jest.mocked(channel.addMembers).mockRejectedValue(new Error('nope')); + const onSuccess = jest.fn(); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.addMembers(['u-1'], { onSuccess }); + }); + + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('notifies and calls channel.removeMembers when removing members succeeds', async () => { + const client = createClient(); + const channel = createChannel(client); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.removeMembers(['u-1', 'u-2']); + }); + + expect(channel.removeMembers).toHaveBeenCalledWith(['u-1', 'u-2']); + expect(client.notifications.add).toHaveBeenCalledWith({ + message: '{{count}} members removed', + options: { + severity: 'success', + type: 'api:channel:remove-members:success', + }, + origin: { + context: { channel }, + emitter: 'ChannelActions', + }, + }); + }); + + it('notifies with originalError when removing members fails', async () => { + const error = new Error('remove failed'); + const client = createClient(); + const channel = createChannel(client); + jest.mocked(channel.removeMembers).mockRejectedValue(error); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.removeMembers(['u-1']); + }); + + expect(client.notifications.add).toHaveBeenCalledWith({ + message: 'Failed to remove members', + options: { + originalError: error, + severity: 'error', + type: 'api:channel:remove-members:failed', + }, + origin: { + context: { channel }, + emitter: 'ChannelActions', + }, + }); + }); + + it('does not call onSuccess when removeMembers rejects', async () => { + const client = createClient(); + const channel = createChannel(client); + jest.mocked(channel.removeMembers).mockRejectedValue(new Error('nope')); + const onSuccess = jest.fn(); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.removeMembers(['u-1'], { onSuccess }); + }); + + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('notifies and calls channel.updatePartial when updateName succeeds', async () => { + const client = createClient(); + const channel = createChannel(client); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.updateName('New name'); + }); + + expect(channel.updatePartial).toHaveBeenCalledWith({ set: { name: 'New name' } }); + expect(client.notifications.add).toHaveBeenCalledWith({ + message: 'Channel name updated', + options: { + severity: 'success', + type: 'api:channel:update-name:success', + }, + origin: { + context: { channel }, + emitter: 'ChannelActions', + }, + }); + }); + + it('notifies with originalError when updateName fails', async () => { + const error = new Error('rename failed'); + const client = createClient(); + const channel = createChannel(client); + jest.mocked(channel.updatePartial).mockRejectedValue(error); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.updateName('New name'); + }); + + expect(client.notifications.add).toHaveBeenCalledWith({ + message: 'Failed to update channel name', + options: { + originalError: error, + severity: 'error', + type: 'api:channel:update-name:failed', + }, + origin: { + context: { channel }, + emitter: 'ChannelActions', + }, + }); + }); + + it('calls onSuccess after updateName succeeds and skips it on failure', async () => { + const client = createClient(); + const channel = createChannel(client); + const onSuccess = jest.fn(); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.updateName('New name', { onSuccess }); + }); + expect(onSuccess).toHaveBeenCalledTimes(1); + + jest.mocked(channel.updatePartial).mockRejectedValueOnce(new Error('boom')); + await act(async () => { + await result.current.updateName('Other name', { onSuccess }); + }); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it('unsets the name when updateName is called with an empty string', async () => { + const client = createClient(); + const channel = createChannel(client); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.updateName(''); + }); + + expect(channel.updatePartial).toHaveBeenCalledWith({ unset: ['name'] }); + expect(client.notifications.add).toHaveBeenCalledWith({ + message: 'Channel name updated', + options: { + severity: 'success', + type: 'api:channel:update-name:success', + }, + origin: { + context: { channel }, + emitter: 'ChannelActions', + }, + }); + }); + + it('unsets the name when updateName is called with a whitespace-only string', async () => { + const client = createClient(); + const channel = createChannel(client); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.updateName(' '); + }); + + expect(channel.updatePartial).toHaveBeenCalledWith({ unset: ['name'] }); + }); + + it('uploads image then patches channel and notifies on updateImage success', async () => { + const client = createClient(); + const channel = createChannel(client); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.updateImage(imageFile); + }); + + expect(client.uploadImage).toHaveBeenCalledWith( + 'file:///tmp/avatar.png', + 'avatar.png', + 'image/png', + ); + expect(channel.updatePartial).toHaveBeenCalledWith({ + set: { image: 'https://cdn.example.com/uploaded.png' }, + }); + expect(client.notifications.add).toHaveBeenCalledWith({ + message: 'Channel image updated', + options: { + severity: 'success', + type: 'api:channel:update-image:success', + }, + origin: { + context: { channel }, + emitter: 'ChannelActions', + }, + }); + }); + + it('uses doFileUploadRequest instead of client.uploadImage when provided', async () => { + const client = createClient(); + const channel = createChannel(client); + const doFileUploadRequest = jest + .fn() + .mockResolvedValue({ file: 'https://cdn.custom.com/avatar.png' }); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.updateImage(imageFile, undefined, doFileUploadRequest); + }); + + expect(doFileUploadRequest).toHaveBeenCalledWith(imageFile); + expect(client.uploadImage).not.toHaveBeenCalled(); + expect(channel.updatePartial).toHaveBeenCalledWith({ + set: { image: 'https://cdn.custom.com/avatar.png' }, + }); + }); + + it('notifies and skips channel.updatePartial when uploadImage rejects', async () => { + const error = new Error('upload failed'); + const client = createClient(); + client.uploadImage.mockRejectedValueOnce(error); + const channel = createChannel(client); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.updateImage(imageFile); + }); + + expect(channel.updatePartial).not.toHaveBeenCalled(); + expect(client.notifications.add).toHaveBeenCalledWith({ + message: 'Failed to update channel image', + options: { + originalError: error, + severity: 'error', + type: 'api:channel:update-image:failed', + }, + origin: { + context: { channel }, + emitter: 'ChannelActions', + }, + }); + }); + + it('notifies when uploadImage succeeds but channel.updatePartial rejects', async () => { + const error = new Error('patch failed'); + const client = createClient(); + const channel = createChannel(client); + jest.mocked(channel.updatePartial).mockRejectedValueOnce(error); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.updateImage(imageFile); + }); + + expect(client.uploadImage).toHaveBeenCalledTimes(1); + expect(client.notifications.add).toHaveBeenCalledWith({ + message: 'Failed to update channel image', + options: { + originalError: error, + severity: 'error', + type: 'api:channel:update-image:failed', + }, + origin: { + context: { channel }, + emitter: 'ChannelActions', + }, + }); + }); + + it('unsets the image and skips uploadImage when updateImage is called with null', async () => { + const client = createClient(); + const channel = createChannel(client); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.updateImage(null); + }); + + expect(client.uploadImage).not.toHaveBeenCalled(); + expect(channel.updatePartial).toHaveBeenCalledWith({ unset: ['image'] }); + expect(client.notifications.add).toHaveBeenCalledWith({ + message: 'Channel image updated', + options: { + severity: 'success', + type: 'api:channel:update-image:success', + }, + origin: { + context: { channel }, + emitter: 'ChannelActions', + }, + }); + }); + + it('notifies with originalError when updateImage(null) fails', async () => { + const error = new Error('unset failed'); + const client = createClient(); + const channel = createChannel(client); + jest.mocked(channel.updatePartial).mockRejectedValueOnce(error); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.updateImage(null); + }); + + expect(client.uploadImage).not.toHaveBeenCalled(); + expect(client.notifications.add).toHaveBeenCalledWith({ + message: 'Failed to update channel image', + options: { + originalError: error, + severity: 'error', + type: 'api:channel:update-image:failed', + }, + origin: { + context: { channel }, + emitter: 'ChannelActions', + }, + }); + }); + + it('calls onSuccess after updateImage(null) succeeds and skips it on failure', async () => { + const client = createClient(); + const channel = createChannel(client); + const onSuccess = jest.fn(); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.updateImage(null, { onSuccess }); + }); + expect(onSuccess).toHaveBeenCalledTimes(1); + + jest.mocked(channel.updatePartial).mockRejectedValueOnce(new Error('boom')); + await act(async () => { + await result.current.updateImage(null, { onSuccess }); + }); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it('calls onSuccess for updateImage only after both upload and patch succeed', async () => { + const client = createClient(); + const channel = createChannel(client); + const onSuccess = jest.fn(); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.updateImage(imageFile, { onSuccess }); + }); + expect(onSuccess).toHaveBeenCalledTimes(1); + + client.uploadImage.mockRejectedValueOnce(new Error('nope')); + await act(async () => { + await result.current.updateImage(imageFile, { onSuccess }); + }); + expect(onSuccess).toHaveBeenCalledTimes(1); + + jest.mocked(channel.updatePartial).mockRejectedValueOnce(new Error('nope')); + await act(async () => { + await result.current.updateImage(imageFile, { onSuccess }); + }); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it('resolves and notifies when called without options', async () => { + const client = createClient(); + const channel = createChannel(client); + const { result } = renderHook(() => useChannelActions(channel), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.muteChannel(); + }); + + expect(channel.mute).toHaveBeenCalledTimes(1); + expect(client.notifications.add).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Channel muted', + }), + ); + }); +}); diff --git a/package/src/hooks/actions/__tests__/useChannelMemberActionItems.test.tsx b/package/src/hooks/actions/__tests__/useChannelMemberActionItems.test.tsx new file mode 100644 index 0000000000..edf5ec747a --- /dev/null +++ b/package/src/hooks/actions/__tests__/useChannelMemberActionItems.test.tsx @@ -0,0 +1,372 @@ +import React, { PropsWithChildren } from 'react'; +import { Alert } from 'react-native'; + +import { renderHook } from '@testing-library/react-native'; +import type { Channel, ChannelMemberResponse, Mute } from 'stream-chat'; + +import * as useMutedUsersModule from '../../../components/ChannelList/hooks/useMutedUsers'; +import { ChatProvider } from '../../../contexts/chatContext/ChatContext'; +import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext'; +import * as TranslationContext from '../../../contexts/translationContext/TranslationContext'; +import * as useChannelOwnCapabilitiesModule from '../../useChannelOwnCapabilities'; +import * as useChannelActionsModule from '../useChannelActions'; +import { + buildDefaultChannelMemberActionItems, + getChannelMemberActionItems, + GetChannelMemberActionItems, + useChannelMemberActionItems, +} from '../useChannelMemberActionItems'; +import * as useUserActionsModule from '../useUserActions'; + +const createUserActions = (): useUserActionsModule.UserActions => ({ + blockUser: jest.fn(), + muteUser: jest.fn(), + unblockUser: jest.fn(), + unmuteUser: jest.fn(), +}); + +const removeMembers = jest.fn(); +const channelActions = { removeMembers } as unknown as useChannelActionsModule.ChannelActions; + +const createMemberMock = (userId = 'target-user-id'): ChannelMemberResponse => + ({ + user: { id: userId, name: 'Target Name' }, + user_id: userId, + }) as ChannelMemberResponse; + +const createChannelMock = (params?: { blockedUserIds?: string[]; userID?: string }): Channel => { + const { blockedUserIds = [], userID = 'current-user-id' } = params ?? {}; + return { + getClient: () => ({ + blockedUsers: { + getLatestValue: () => ({ userIds: blockedUserIds }), + subscribeWithSelector: () => () => {}, + }, + userID, + }), + } as unknown as Channel; +}; + +describe('useChannelMemberActionItems', () => { + const channel = createChannelMock(); + const member = createMemberMock(); + const userActions = createUserActions(); + + beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(TranslationContext, 'useTranslationContext') + .mockImplementation( + () => ({ t: (value: string) => value }) as unknown as TranslationContextValue, + ); + jest.spyOn(useMutedUsersModule, 'useMutedUsers').mockReturnValue([] as Mute[]); + jest.spyOn(useUserActionsModule, 'useUserActions').mockReturnValue(userActions); + jest.spyOn(useChannelActionsModule, 'useChannelActions').mockReturnValue(channelActions); + jest.spyOn(useChannelOwnCapabilitiesModule, 'useChannelOwnCapabilities').mockReturnValue([]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns default member action items', () => { + const { result } = renderHook(() => useChannelMemberActionItems({ channel, member })); + + expect(result.current).toHaveLength(2); + expect(result.current.map((item) => item.id)).toEqual(['muteUser', 'block']); + expect(result.current.map((item) => item.action)).toEqual([ + userActions.muteUser, + userActions.blockUser, + ]); + expect(result.current.map((item) => item.type)).toEqual(['standard', 'destructive']); + expect(result.current.map((item) => item.label)).toEqual(['Mute User', 'Block User']); + }); + + it('returns no action items when the member is the current user', () => { + const currentUserChannel = createChannelMock({ userID: 'target-user-id' }); + + const { result } = renderHook(() => + useChannelMemberActionItems({ channel: currentUserChannel, member }), + ); + + expect(result.current).toEqual([]); + }); + + it('toggles muteUser to unmuteUser when the member is already muted', () => { + jest + .spyOn(useMutedUsersModule, 'useMutedUsers') + .mockReturnValue([ + { target: { id: 'target-user-id' }, user: { id: 'current-user-id' } }, + ] as Mute[]); + + const wrapper = ({ children }: PropsWithChildren) => ( + <ChatProvider value={{ client: { userID: 'current-user-id' } } as never}> + {children} + </ChatProvider> + ); + const { result } = renderHook(() => useChannelMemberActionItems({ channel, member }), { + wrapper, + }); + + const muteItem = result.current.find((item) => item.id === 'muteUser'); + expect(muteItem?.action).toBe(userActions.unmuteUser); + expect(muteItem?.label).toBe('Unmute User'); + }); + + it('toggles block to unblockUser when the member is already blocked', () => { + const blockedChannel = createChannelMock({ blockedUserIds: ['target-user-id'] }); + + const { result } = renderHook(() => + useChannelMemberActionItems({ channel: blockedChannel, member }), + ); + + const blockItem = result.current.find((item) => item.id === 'block'); + expect(blockItem?.action).toBe(userActions.unblockUser); + expect(blockItem?.label).toBe('Unblock User'); + }); + + it('forwards options from item.action to the underlying user action', async () => { + const { result } = renderHook(() => useChannelMemberActionItems({ channel, member })); + + const muteItem = result.current.find((item) => item.id === 'muteUser'); + const onSuccess = jest.fn(); + await muteItem?.action({ onSuccess }); + + expect(userActions.muteUser).toHaveBeenCalledWith({ onSuccess }); + }); + + it('marks block as destructive when user is not blocked', () => { + const { result } = renderHook(() => useChannelMemberActionItems({ channel, member })); + + const blockItem = result.current.find((item) => item.id === 'block'); + expect(blockItem?.type).toBe('destructive'); + }); + + it('adds a destructive removeMember item when the user can update channel members', () => { + jest + .spyOn(useChannelOwnCapabilitiesModule, 'useChannelOwnCapabilities') + .mockReturnValue(['update-channel-members']); + + const { result } = renderHook(() => useChannelMemberActionItems({ channel, member })); + + const removeItem = result.current.find((item) => item.id === 'removeMember'); + expect(result.current.map((item) => item.id)).toEqual(['muteUser', 'block', 'removeMember']); + expect(removeItem?.label).toBe('Remove User'); + expect(removeItem?.type).toBe('destructive'); + }); + + it('does not add removeMember without the update-channel-members capability', () => { + const { result } = renderHook(() => useChannelMemberActionItems({ channel, member })); + + expect(result.current.find((item) => item.id === 'removeMember')).toBeUndefined(); + }); + + it('does not add removeMember for the current user even with the capability', () => { + jest + .spyOn(useChannelOwnCapabilitiesModule, 'useChannelOwnCapabilities') + .mockReturnValue(['update-channel-members']); + const currentUserChannel = createChannelMock({ userID: 'target-user-id' }); + + const { result } = renderHook(() => + useChannelMemberActionItems({ channel: currentUserChannel, member }), + ); + + expect(result.current).toEqual([]); + }); + + it('confirms via Alert before removing the member', () => { + jest + .spyOn(useChannelOwnCapabilitiesModule, 'useChannelOwnCapabilities') + .mockReturnValue(['update-channel-members']); + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + + const { result } = renderHook(() => useChannelMemberActionItems({ channel, member })); + const removeItem = result.current.find((item) => item.id === 'removeMember'); + removeItem?.action(); + + expect(alertSpy).toHaveBeenCalledTimes(1); + expect(removeMembers).not.toHaveBeenCalled(); + + // Invoke the destructive button's onPress to confirm removal. + const buttons = alertSpy.mock.calls[0][2] as { onPress?: () => void; style?: string }[]; + const confirmButton = buttons.find((button) => button.style === 'destructive'); + confirmButton?.onPress?.(); + + expect(removeMembers).toHaveBeenCalledWith(['target-user-id']); + + alertSpy.mockRestore(); + }); + + it('keeps block standard when user is already blocked', () => { + const blockedChannel = createChannelMock({ blockedUserIds: ['target-user-id'] }); + + const { result } = renderHook(() => + useChannelMemberActionItems({ channel: blockedChannel, member }), + ); + + const blockItem = result.current.find((item) => item.id === 'block'); + expect(blockItem?.type).toBe('standard'); + }); + + it('uses custom getChannelMemberActionItems with context and defaultItems when provided', () => { + const customGetItems = jest.fn(({ defaultItems }: Parameters<GetChannelMemberActionItems>[0]) => + defaultItems.slice(0, 1), + ); + + const { result } = renderHook(() => + useChannelMemberActionItems({ + channel, + getChannelMemberActionItems: customGetItems, + member, + }), + ); + + expect(customGetItems).toHaveBeenCalledWith({ + context: { + channel, + channelActions, + isBlocked: false, + isCurrentUser: false, + member, + ownCapabilities: [], + t: expect.any(Function), + userActions, + userMuteActive: false, + }, + defaultItems: expect.any(Array), + }); + expect(result.current).toHaveLength(1); + expect(result.current[0].id).toBe('muteUser'); + }); +}); + +describe('buildDefaultChannelMemberActionItems', () => { + const channel = createChannelMock(); + const member = createMemberMock(); + + it('creates default mute and block items', () => { + const actions = createUserActions(); + const items = buildDefaultChannelMemberActionItems({ + channel, + channelActions, + isBlocked: false, + isCurrentUser: false, + member, + ownCapabilities: undefined, + t: ((value: string) => value) as TranslationContextValue['t'], + userActions: actions, + userMuteActive: false, + }); + + expect(items.map((item) => item.id)).toEqual(['muteUser', 'block']); + expect(items.map((item) => item.action)).toEqual([actions.muteUser, actions.blockUser]); + expect(items.map((item) => item.label)).toEqual(['Mute User', 'Block User']); + expect(items.map((item) => item.type)).toEqual(['standard', 'destructive']); + }); + + it('returns no items when the member is the current user', () => { + const actions = createUserActions(); + const items = buildDefaultChannelMemberActionItems({ + channel, + channelActions, + isBlocked: false, + isCurrentUser: true, + member, + ownCapabilities: undefined, + t: ((value: string) => value) as TranslationContextValue['t'], + userActions: actions, + userMuteActive: false, + }); + + expect(items).toEqual([]); + }); + + it('appends a destructive removeMember item when own capabilities allow updating members', () => { + const actions = createUserActions(); + const items = buildDefaultChannelMemberActionItems({ + channel, + channelActions, + isBlocked: false, + isCurrentUser: false, + member, + ownCapabilities: ['update-channel-members'], + t: ((value: string) => value) as TranslationContextValue['t'], + userActions: actions, + userMuteActive: false, + }); + + expect(items.map((item) => item.id)).toEqual(['muteUser', 'block', 'removeMember']); + const removeItem = items.find((item) => item.id === 'removeMember'); + expect(removeItem?.label).toBe('Remove User'); + expect(removeItem?.type).toBe('destructive'); + }); + + it('returns unmute/unblock variants when toggles are active', () => { + const actions = createUserActions(); + const items = buildDefaultChannelMemberActionItems({ + channel, + channelActions, + isBlocked: true, + isCurrentUser: false, + member, + ownCapabilities: undefined, + t: ((value: string) => value) as TranslationContextValue['t'], + userActions: actions, + userMuteActive: true, + }); + + expect(items.map((item) => item.action)).toEqual([actions.unmuteUser, actions.unblockUser]); + expect(items.map((item) => item.label)).toEqual(['Unmute User', 'Unblock User']); + expect(items.map((item) => item.type)).toEqual(['standard', 'standard']); + }); + + it('mute and block reflect their respective active states independently', () => { + const actions = createUserActions(); + const items = buildDefaultChannelMemberActionItems({ + channel, + channelActions, + isBlocked: false, + isCurrentUser: false, + member, + ownCapabilities: undefined, + t: ((value: string) => value) as TranslationContextValue['t'], + userActions: actions, + userMuteActive: true, + }); + + expect(items.find((item) => item.id === 'muteUser')?.action).toBe(actions.unmuteUser); + expect(items.find((item) => item.id === 'block')?.action).toBe(actions.blockUser); + }); + + it('default getChannelMemberActionItems returns defaultItems unchanged', () => { + const actions = createUserActions(); + const defaultItems = buildDefaultChannelMemberActionItems({ + channel, + channelActions, + isBlocked: false, + isCurrentUser: false, + member, + ownCapabilities: undefined, + t: ((value: string) => value) as TranslationContextValue['t'], + userActions: actions, + userMuteActive: false, + }); + + const items = getChannelMemberActionItems({ + context: { + channel, + channelActions, + isBlocked: false, + isCurrentUser: false, + member, + ownCapabilities: undefined, + t: ((value: string) => value) as TranslationContextValue['t'], + userActions: actions, + userMuteActive: false, + }, + defaultItems, + }); + + expect(items).toBe(defaultItems); + }); +}); diff --git a/package/src/hooks/actions/__tests__/useUserActions.test.tsx b/package/src/hooks/actions/__tests__/useUserActions.test.tsx new file mode 100644 index 0000000000..84722823e6 --- /dev/null +++ b/package/src/hooks/actions/__tests__/useUserActions.test.tsx @@ -0,0 +1,251 @@ +import { renderHook, waitFor } from '@testing-library/react-native'; +import type { UserResponse } from 'stream-chat'; + +import * as useNotificationApiModule from '../../../components/Notifications/hooks/useNotificationApi'; +import * as ChatContext from '../../../contexts/chatContext/ChatContext'; +import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext'; +import * as TranslationContext from '../../../contexts/translationContext/TranslationContext'; +import { useUserActions } from '../useUserActions'; + +const createUser = (overrides?: Partial<UserResponse>): UserResponse => + ({ + id: 'target-user-id', + name: 'Target Name', + ...overrides, + }) as UserResponse; + +describe('useUserActions', () => { + const muteUser = jest.fn(); + const unmuteUser = jest.fn(); + const blockUser = jest.fn(); + const unBlockUser = jest.fn(); + const addNotification = jest.fn(); + + const setUpMocks = () => { + muteUser.mockResolvedValue(undefined); + unmuteUser.mockResolvedValue(undefined); + blockUser.mockResolvedValue(undefined); + unBlockUser.mockResolvedValue(undefined); + + jest.spyOn(ChatContext, 'useChatContext').mockImplementation( + () => + ({ + client: { + blockUser, + muteUser, + unBlockUser, + unmuteUser, + }, + }) as unknown as ChatContext.ChatContextValue, + ); + jest + .spyOn(useNotificationApiModule, 'useNotificationApi') + .mockReturnValue({ addNotification } as unknown as useNotificationApiModule.NotificationApi); + jest + .spyOn(TranslationContext, 'useTranslationContext') + .mockImplementation( + () => ({ t: (value: string) => value }) as unknown as TranslationContextValue, + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + setUpMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns stable references across re-renders', () => { + const user = createUser(); + const { result, rerender } = renderHook(() => useUserActions(user)); + + const firstResult = result.current; + rerender({}); + expect(result.current).toBe(firstResult); + }); + + describe('muteUser', () => { + it('calls client.muteUser with the user id and runs onSuccess', async () => { + const user = createUser(); + const onSuccess = jest.fn(); + const { result } = renderHook(() => useUserActions(user)); + + await result.current.muteUser({ onSuccess }); + + expect(muteUser).toHaveBeenCalledWith('target-user-id'); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: '{{ user }} has been muted', + options: expect.objectContaining({ + severity: 'success', + type: 'api:user:mute:success', + }), + }), + ); + }); + + it('emits an error notification when the client rejects', async () => { + muteUser.mockRejectedValueOnce(new Error('boom')); + const user = createUser(); + const onSuccess = jest.fn(); + const { result } = renderHook(() => useUserActions(user)); + + await result.current.muteUser({ onSuccess }); + + expect(onSuccess).not.toHaveBeenCalled(); + await waitFor(() => + expect(addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error muting a user ...', + options: expect.objectContaining({ + severity: 'error', + type: 'api:user:mute:failed', + }), + }), + ), + ); + }); + }); + + describe('unmuteUser', () => { + it('calls client.unmuteUser with the user id and runs onSuccess', async () => { + const user = createUser(); + const onSuccess = jest.fn(); + const { result } = renderHook(() => useUserActions(user)); + + await result.current.unmuteUser({ onSuccess }); + + expect(unmuteUser).toHaveBeenCalledWith('target-user-id'); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + severity: 'success', + type: 'api:user:unmute:success', + }), + }), + ); + }); + + it('emits an error notification when the client rejects', async () => { + unmuteUser.mockRejectedValueOnce(new Error('boom')); + const user = createUser(); + const { result } = renderHook(() => useUserActions(user)); + + await result.current.unmuteUser(); + + await waitFor(() => + expect(addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + severity: 'error', + type: 'api:user:unmute:failed', + }), + }), + ), + ); + }); + }); + + describe('blockUser', () => { + it('calls client.blockUser with the user id and runs onSuccess', async () => { + const user = createUser(); + const onSuccess = jest.fn(); + const { result } = renderHook(() => useUserActions(user)); + + await result.current.blockUser({ onSuccess }); + + expect(blockUser).toHaveBeenCalledWith('target-user-id'); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User blocked', + options: expect.objectContaining({ + severity: 'success', + type: 'api:user:block:success', + }), + }), + ); + }); + + it('emits an error notification when the client rejects', async () => { + blockUser.mockRejectedValueOnce(new Error('boom')); + const user = createUser(); + const { result } = renderHook(() => useUserActions(user)); + + await result.current.blockUser(); + + await waitFor(() => + expect(addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + severity: 'error', + type: 'api:user:block:failed', + }), + }), + ), + ); + }); + }); + + describe('unblockUser', () => { + it('calls client.unBlockUser with the user id and runs onSuccess', async () => { + const user = createUser(); + const onSuccess = jest.fn(); + const { result } = renderHook(() => useUserActions(user)); + + await result.current.unblockUser({ onSuccess }); + + expect(unBlockUser).toHaveBeenCalledWith('target-user-id'); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'User unblocked', + options: expect.objectContaining({ + severity: 'success', + type: 'api:user:unblock:success', + }), + }), + ); + }); + + it('emits an error notification when the client rejects', async () => { + unBlockUser.mockRejectedValueOnce(new Error('boom')); + const user = createUser(); + const { result } = renderHook(() => useUserActions(user)); + + await result.current.unblockUser(); + + await waitFor(() => + expect(addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + severity: 'error', + type: 'api:user:block:failed', + }), + }), + ), + ); + }); + }); + + describe('when the user is undefined', () => { + it('does not call the client or add notifications for any handler', async () => { + const { result } = renderHook(() => useUserActions(undefined)); + + await result.current.muteUser(); + await result.current.unmuteUser(); + await result.current.blockUser(); + await result.current.unblockUser(); + + expect(muteUser).not.toHaveBeenCalled(); + expect(unmuteUser).not.toHaveBeenCalled(); + expect(blockUser).not.toHaveBeenCalled(); + expect(unBlockUser).not.toHaveBeenCalled(); + expect(addNotification).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/package/src/hooks/actions/index.ts b/package/src/hooks/actions/index.ts new file mode 100644 index 0000000000..8bc1a927ba --- /dev/null +++ b/package/src/hooks/actions/index.ts @@ -0,0 +1,5 @@ +export * from './useChannelActions'; +export * from './useChannelActionItems'; +export * from './useChannelActionItemsById'; +export * from './useChannelMemberActionItems'; +export * from './useUserActions'; diff --git a/package/src/hooks/actions/types.ts b/package/src/hooks/actions/types.ts new file mode 100644 index 0000000000..64bd8fd713 --- /dev/null +++ b/package/src/hooks/actions/types.ts @@ -0,0 +1,20 @@ +import type React from 'react'; + +import type { IconProps } from '../../icons'; + +export type ActionOptions = { + onSuccess?: () => void; + onFailure?: (error: unknown) => void; +}; + +export type ActionHandler = (options?: ActionOptions) => Promise<void>; + +export type ActionItemHandler = (options?: ActionOptions) => Promise<void> | void; + +export type ActionItem<TId extends string = string> = { + action: ActionItemHandler; + Icon: React.ComponentType<IconProps>; + id: TId; + label: string; + type: 'destructive' | 'standard'; +}; diff --git a/package/src/hooks/actions/useChannelActionItems.tsx b/package/src/hooks/actions/useChannelActionItems.tsx new file mode 100644 index 0000000000..ec69e617fb --- /dev/null +++ b/package/src/hooks/actions/useChannelActionItems.tsx @@ -0,0 +1,315 @@ +import React, { useMemo } from 'react'; +import { Alert } from 'react-native'; + +import type { BlockedUsersState, Channel } from 'stream-chat'; + +import type { ActionItem } from './types'; +import { + ChannelActionHandler, + ChannelActions, + getOtherUserInDirectChannel, + useChannelActions, +} from './useChannelActions'; + +import { useIsChannelMuted } from '../../components/ChannelPreview/hooks/useIsChannelMuted'; +import { useUserMuteActive } from '../../components/Message/hooks/useUserMuteActive'; +import { useTheme, useTranslationContext } from '../../contexts'; +import type { TranslationContextValue } from '../../contexts/translationContext/TranslationContext'; +import { IconProps, Mute, BlockUser, Delete, Pin, Sound, Unpin } from '../../icons'; +import { ArrowBoxLeft } from '../../icons/leave'; +import { useChannelMembershipState } from '../useChannelMembershipState'; +import { useIsDirectChat } from '../useIsDirectChat'; +import { useStateStore } from '../useStateStore'; + +export type ChannelActionItem = ActionItem< + 'mute' | 'muteUser' | 'block' | 'leave' | 'deleteChannel' | 'pin' | string +> & { + /** + * Per item routing **within a channel preview interaction** (swipe row vs + * the sheet that opens from it). Only meaningful when the items are consumed + * from `<ChannelSwipableWrapper>`: + * + * - `'swipe'`: shown only in the swipe-row chips. + * - `'sheet'`: shown only in the swipe-triggered options sheet. + * - `'both'`: shown in both swipe row and sheet. + * + * The standalone Channel Details screen does **not** filter by `placement` - + * use {@link ChannelActionSurface} (`surface`) instead to vary items between + * the channel list and the Channel Details screen. + */ + placement: 'both' | 'sheet' | 'swipe'; +}; + +/** + * Identifies which top level UI surface is requesting channel action items. + * Passed verbatim into {@link ChannelActionItemsParams} so the default builder + * and any integrator supplied `getChannelActionItems` can branch on it - i.e. + * to drop or relabel an item on a specific surface or to provide an entirely + * different builder per surface. + * + * - `'list'`: anything driven by a ChannelList interaction, the swipe row + * chips on a channel preview and the bottom sheet that opens from them. + * Subrouting between the swipe row and the sheet is handled by the per item + * {@link ChannelActionItem.placement} field. + * - `'details'`: items for the standalone Channel Details screen. + * + * `surface` operates at the call site level (which UI is asking). It is + * optional and when omitted, the default builder applies no surface specific + * filtering and returns every item it would otherwise produce. + */ +export type ChannelActionSurface = 'list' | 'details'; + +export type ChannelActionItemsParams = { + actions: ChannelActions; + channel: Channel; + channelMuteActive: boolean; + isArchived: boolean; + isBlocked: boolean | undefined; + isDirectChat: boolean; + isPinned: boolean; + surface?: ChannelActionSurface; + t: TranslationContextValue['t']; + userMuteActive: boolean; +}; + +export type BuildDefaultChannelActionItems = ( + channelActionItemsParams: ChannelActionItemsParams, +) => ChannelActionItem[]; + +const ChannelActionsIcon = ({ + Icon, + ...rest +}: { Icon: React.ComponentType<IconProps> } & IconProps) => { + const { + theme: { semantics }, + } = useTheme(); + + return <Icon stroke={semantics.textSecondary} width={20} height={20} {...rest} />; +}; + +export const buildDefaultChannelActionItems: BuildDefaultChannelActionItems = ( + channelActionItemsParams, +) => { + const { + actions: { + deleteChannel, + leave, + muteChannel, + unmuteChannel, + muteUser, + unmuteUser, + blockUser, + unblockUser, + pin, + unpin, + }, + channelMuteActive, + isBlocked, + isDirectChat, + isPinned, + surface, + userMuteActive, + t, + channel, + } = channelActionItemsParams; + const ownUserId = channel.getClient().userID; + + const actionItems: ChannelActionItem[] = [ + { + action: channelMuteActive ? unmuteChannel : muteChannel, + Icon: (props) => + channelMuteActive ? ( + <Sound width={20} height={20} {...props} /> + ) : ( + <Mute + width={20} + height={20} + {...props} + stroke={undefined} + fill={props.fill ?? props.stroke} + /> + ), + id: 'mute', + label: isDirectChat + ? channelMuteActive + ? t('Unmute Chat') + : t('Mute Chat') + : channelMuteActive + ? t('Unmute Group') + : t('Mute Group'), + placement: 'swipe', + type: 'standard', + }, + ]; + + if (surface !== 'details') { + actionItems.push({ + action: isPinned ? unpin : pin, + Icon: (props) => <ChannelActionsIcon Icon={isPinned ? Unpin : Pin} {...props} />, + id: 'pin', + label: isDirectChat + ? isPinned + ? t('Unpin Chat') + : t('Pin Chat') + : isPinned + ? t('Unpin Group') + : t('Pin Group'), + placement: 'sheet', + type: 'standard', + }); + } + + if (isDirectChat) { + actionItems.push({ + action: userMuteActive ? unmuteUser : muteUser, + Icon: (props) => + userMuteActive ? ( + <ChannelActionsIcon Icon={Sound} {...props} /> + ) : ( + <ChannelActionsIcon + Icon={Mute} + {...props} + fill={props.fill ?? props.stroke} + stroke={undefined} + /> + ), + id: 'muteUser', + label: userMuteActive ? t('Unmute User') : t('Mute User'), + placement: 'sheet', + type: 'standard', + }); + + actionItems.push({ + action: isBlocked ? unblockUser : blockUser, + Icon: (props) => <ChannelActionsIcon Icon={BlockUser} {...props} />, + id: 'block', + label: isBlocked ? t('Unblock User') : t('Block User'), + placement: 'sheet', + type: isBlocked ? 'standard' : 'destructive', + }); + } + + actionItems.push({ + action: leave, + Icon: (props) => <ChannelActionsIcon Icon={ArrowBoxLeft} {...props} />, + id: 'leave', + label: isDirectChat ? t('Leave Chat') : t('Leave Group'), + placement: 'sheet', + type: 'destructive', + }); + + if (channel.data?.created_by?.id === ownUserId) { + actionItems.push({ + action: (...args: Parameters<ChannelActionHandler>) => { + const title = isDirectChat ? t('Delete chat') : t('Delete group'); + const message = isDirectChat + ? t("Are you sure you want to delete this chat? This can't be undone.") + : t("Are you sure you want to delete this group? This can't be undone."); + + Alert.alert(title, message, [ + { + style: 'cancel', + text: t('Cancel'), + }, + { + onPress: async () => { + await deleteChannel(...args); + }, + style: 'destructive', + text: t('Delete'), + }, + ]); + }, + Icon: (props) => <ChannelActionsIcon Icon={Delete} {...props} />, + id: 'deleteChannel', + label: isDirectChat ? t('Delete Chat') : t('Delete Group'), + placement: 'sheet', + type: 'destructive', + }); + } + + return actionItems; +}; + +export type GetChannelActionItems = (params: { + context: ChannelActionItemsParams; + defaultItems: ChannelActionItem[]; +}) => ChannelActionItem[]; + +export const getChannelActionItems: GetChannelActionItems = ({ defaultItems }) => defaultItems; + +type UseChannelActionItemsParams = { + channel: Channel; + surface?: ChannelActionSurface; + getChannelActionItems?: GetChannelActionItems; +}; + +const blockedUsersStateSelector = (state: BlockedUsersState) => + ({ userIds: state.userIds }) as const; + +export const useChannelActionItems = ({ + channel, + surface, + getChannelActionItems: getChannelActionItemsProp = getChannelActionItems, +}: UseChannelActionItemsParams) => { + const { t } = useTranslationContext(); + const membership = useChannelMembershipState(channel); + const channelActions = useChannelActions(channel); + const isDirectChat = useIsDirectChat(channel); + const isPinned = Boolean(membership?.pinned_at); + const isArchived = Boolean(membership?.archived_at); + + const { muted: channelMuteActive } = useIsChannelMuted(channel); + const otherUser = isDirectChat ? getOtherUserInDirectChannel(channel)?.user : undefined; + const userMuteActive = useUserMuteActive(otherUser); + + const { userIds: blockedUserIds } = useStateStore( + channel.getClient().blockedUsers, + blockedUsersStateSelector, + ); + + const isBlocked = isDirectChat + ? blockedUserIds.includes(getOtherUserInDirectChannel(channel)?.user?.id ?? '') + : undefined; + + const channelActionItemsParams = useMemo( + () => ({ + actions: channelActions, + channel, + channelMuteActive, + isArchived, + isBlocked, + isDirectChat, + isPinned, + surface, + t, + userMuteActive, + }), + [ + channel, + channelActions, + channelMuteActive, + isArchived, + isBlocked, + isDirectChat, + isPinned, + surface, + t, + userMuteActive, + ], + ); + + const defaultItems = useMemo( + () => buildDefaultChannelActionItems(channelActionItemsParams), + [channelActionItemsParams], + ); + + return useMemo( + () => + getChannelActionItemsProp({ + context: channelActionItemsParams, + defaultItems, + }), + [channelActionItemsParams, defaultItems, getChannelActionItemsProp], + ); +}; diff --git a/package/src/components/ChannelList/hooks/useChannelActionItemsById.ts b/package/src/hooks/actions/useChannelActionItemsById.ts similarity index 82% rename from package/src/components/ChannelList/hooks/useChannelActionItemsById.ts rename to package/src/hooks/actions/useChannelActionItemsById.ts index 85d79ceea3..e2adfb46bd 100644 --- a/package/src/components/ChannelList/hooks/useChannelActionItemsById.ts +++ b/package/src/hooks/actions/useChannelActionItemsById.ts @@ -2,23 +2,30 @@ import { useMemo } from 'react'; import type { Channel } from 'stream-chat'; -import type { ChannelActionItem, GetChannelActionItems } from './useChannelActionItems'; +import type { + ChannelActionItem, + ChannelActionSurface, + GetChannelActionItems, +} from './useChannelActionItems'; import { useChannelActionItems } from './useChannelActionItems'; export type ChannelActionItemsById = Partial<Record<ChannelActionItem['id'], ChannelActionItem>>; type UseChannelActionItemsByIdParams = { channel: Channel; + surface?: ChannelActionSurface; getChannelActionItems?: GetChannelActionItems; }; export const useChannelActionItemsById = ({ channel, + surface, getChannelActionItems, }: UseChannelActionItemsByIdParams) => { const channelActionItems = useChannelActionItems({ channel, getChannelActionItems, + surface, }); return useMemo( diff --git a/package/src/components/ChannelList/hooks/useChannelActions.ts b/package/src/hooks/actions/useChannelActions.ts similarity index 54% rename from package/src/components/ChannelList/hooks/useChannelActions.ts rename to package/src/hooks/actions/useChannelActions.ts index 921c2b02b0..03a38d3fd7 100644 --- a/package/src/components/ChannelList/hooks/useChannelActions.ts +++ b/package/src/hooks/actions/useChannelActions.ts @@ -2,23 +2,38 @@ import { useMemo } from 'react'; import { Channel } from 'stream-chat'; -import { useChatContext, useTranslationContext } from '../../../contexts'; -import { useStableCallback } from '../../../hooks'; -import { useNotificationApi } from '../../Notifications'; +import type { ActionHandler, ActionOptions } from './types'; + +import { useNotificationApi } from '../../components/Notifications/hooks'; +import { useChatContext, useTranslationContext } from '../../contexts'; +import { File, GlobalFileUploadRequest } from '../../types/types'; +import { useStableCallback } from '../useStableCallback'; + +export type ChannelActionOptions = ActionOptions; + +export type ChannelActionHandler = ActionHandler; export type ChannelActions = { - archive: () => Promise<void>; - deleteChannel: () => Promise<void>; - leave: () => Promise<void>; - pin: () => Promise<void>; - unarchive: () => Promise<void>; - unpin: () => Promise<void>; - muteUser: () => Promise<void>; - unmuteUser: () => Promise<void>; - muteChannel: () => Promise<void>; - unmuteChannel: () => Promise<void>; - blockUser: () => Promise<void>; - unblockUser: () => Promise<void>; + addMembers: (memberIds: string[], options?: ChannelActionOptions) => Promise<void>; + removeMembers: (memberIds: string[], options?: ChannelActionOptions) => Promise<void>; + archive: ChannelActionHandler; + deleteChannel: ChannelActionHandler; + leave: ChannelActionHandler; + pin: ChannelActionHandler; + unarchive: ChannelActionHandler; + unpin: ChannelActionHandler; + updateImage: ( + image: File | null, + options?: ChannelActionOptions, + doFileUploadRequest?: GlobalFileUploadRequest, + ) => Promise<void>; + updateName: (name: string, options?: ChannelActionOptions) => Promise<void>; + muteUser: ChannelActionHandler; + unmuteUser: ChannelActionHandler; + muteChannel: ChannelActionHandler; + unmuteChannel: ChannelActionHandler; + blockUser: ChannelActionHandler; + unblockUser: ChannelActionHandler; }; export const getOtherUserInDirectChannel = (channel: Channel) => { @@ -38,7 +53,7 @@ const getNotificationError = (error: unknown): Error | undefined => { return undefined; }; -const getNotificationErrorOptions = (error: unknown) => { +export const getNotificationErrorOptions = (error: unknown) => { const originalError = getNotificationError(error); return originalError ? { originalError } : {}; }; @@ -49,7 +64,7 @@ export const useChannelActions = (channel: Channel) => { const { t } = useTranslationContext(); const ownUserId = client.userID; - const pin = useStableCallback(async () => { + const pin = useStableCallback(async (options?: ChannelActionOptions) => { try { if (!channel) { return; @@ -60,6 +75,7 @@ export const useChannelActions = (channel: Channel) => { options: { severity: 'success', type: 'api:channel:pin:success' }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onSuccess?.(); } catch (error) { addNotification({ message: t('Failed to update channel pinned status'), @@ -70,10 +86,11 @@ export const useChannelActions = (channel: Channel) => { }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onFailure?.(error); } }); - const unpin = useStableCallback(async () => { + const unpin = useStableCallback(async (options?: ChannelActionOptions) => { try { if (!channel) { return; @@ -84,6 +101,7 @@ export const useChannelActions = (channel: Channel) => { options: { severity: 'success', type: 'api:channel:unpin:success' }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onSuccess?.(); } catch (error) { addNotification({ message: t('Failed to update channel pinned status'), @@ -94,10 +112,11 @@ export const useChannelActions = (channel: Channel) => { }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onFailure?.(error); } }); - const archive = useStableCallback(async () => { + const archive = useStableCallback(async (options?: ChannelActionOptions) => { try { if (!channel) { return; @@ -108,6 +127,7 @@ export const useChannelActions = (channel: Channel) => { options: { severity: 'success', type: 'api:channel:archive:success' }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onSuccess?.(); } catch (error) { addNotification({ message: t('Failed to update channel archive status'), @@ -118,10 +138,11 @@ export const useChannelActions = (channel: Channel) => { }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onFailure?.(error); } }); - const unarchive = useStableCallback(async () => { + const unarchive = useStableCallback(async (options?: ChannelActionOptions) => { try { if (!channel) { return; @@ -132,6 +153,7 @@ export const useChannelActions = (channel: Channel) => { options: { severity: 'success', type: 'api:channel:unarchive:success' }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onSuccess?.(); } catch (error) { addNotification({ message: t('Failed to update channel archive status'), @@ -142,10 +164,11 @@ export const useChannelActions = (channel: Channel) => { }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onFailure?.(error); } }); - const leave = useStableCallback(async () => { + const leave = useStableCallback(async (options?: ChannelActionOptions) => { if (!channel) { return; } @@ -157,6 +180,7 @@ export const useChannelActions = (channel: Channel) => { options: { severity: 'success', type: 'api:channel:leave:success' }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onSuccess?.(); } catch (error) { addNotification({ message: t('Failed to leave channel'), @@ -167,23 +191,95 @@ export const useChannelActions = (channel: Channel) => { }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onFailure?.(error); } } }); - const deleteChannel = useStableCallback(async () => { + const removeMembers = useStableCallback( + async (memberIds: string[], options?: ChannelActionOptions) => { + if (!channel) { + return; + } + try { + await channel.removeMembers(memberIds); + addNotification({ + message: t('{{count}} members removed', { count: memberIds.length }), + options: { severity: 'success', type: 'api:channel:remove-members:success' }, + origin: { context: { channel }, emitter: 'ChannelActions' }, + }); + options?.onSuccess?.(); + } catch (error) { + addNotification({ + message: t('Failed to remove members'), + options: { + ...getNotificationErrorOptions(error), + severity: 'error', + type: 'api:channel:remove-members:failed', + }, + origin: { context: { channel }, emitter: 'ChannelActions' }, + }); + options?.onFailure?.(error); + } + }, + ); + + const addMembers = useStableCallback( + async (memberIds: string[], options?: ChannelActionOptions) => { + if (!channel) { + return; + } + try { + await channel.addMembers(memberIds); + addNotification({ + message: t('{{count}} members added', { count: memberIds.length }), + options: { severity: 'success', type: 'api:channel:add-members:success' }, + origin: { context: { channel }, emitter: 'ChannelActions' }, + }); + options?.onSuccess?.(); + } catch (error) { + addNotification({ + message: t('Failed to add members'), + options: { + ...getNotificationErrorOptions(error), + severity: 'error', + type: 'api:channel:add-members:failed', + }, + origin: { context: { channel }, emitter: 'ChannelActions' }, + }); + options?.onFailure?.(error); + } + }, + ); + + const deleteChannel = useStableCallback(async (options?: ChannelActionOptions) => { if (!channel) { return; } try { await channel.delete(); + addNotification({ + message: t('Channel deleted'), + options: { severity: 'success', type: 'api:channel:delete:success' }, + origin: { context: { channel }, emitter: 'ChannelActions' }, + }); + options?.onSuccess?.(); } catch (error) { - console.log('Error deleting channel', error); + addNotification({ + message: t('Failed to delete channel'), + options: { + ...getNotificationErrorOptions(error), + severity: 'error', + type: 'api:channel:delete:failed', + }, + origin: { context: { channel }, emitter: 'ChannelActions' }, + }); + options?.onFailure?.(error); } }); - const muteUser = useStableCallback(async () => { + const muteUser = useStableCallback(async (options?: ChannelActionOptions) => { if (!channel) { return; } @@ -200,6 +296,7 @@ export const useChannelActions = (channel: Channel) => { options: { severity: 'success', type: 'api:user:mute:success' }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onSuccess?.(); } } catch (error) { addNotification({ @@ -211,10 +308,11 @@ export const useChannelActions = (channel: Channel) => { }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onFailure?.(error); } }); - const unmuteUser = useStableCallback(async () => { + const unmuteUser = useStableCallback(async (options?: ChannelActionOptions) => { if (!channel) { return; } @@ -231,6 +329,7 @@ export const useChannelActions = (channel: Channel) => { options: { severity: 'success', type: 'api:user:unmute:success' }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onSuccess?.(); } } catch (error) { addNotification({ @@ -242,10 +341,11 @@ export const useChannelActions = (channel: Channel) => { }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onFailure?.(error); } }); - const muteChannel = useStableCallback(async () => { + const muteChannel = useStableCallback(async (options?: ChannelActionOptions) => { if (!channel) { return; } @@ -257,6 +357,7 @@ export const useChannelActions = (channel: Channel) => { options: { severity: 'success', type: 'api:channel:mute:success' }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onSuccess?.(); } catch (error) { addNotification({ message: t('Failed to update channel mute status'), @@ -267,10 +368,11 @@ export const useChannelActions = (channel: Channel) => { }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onFailure?.(error); } }); - const unmuteChannel = useStableCallback(async () => { + const unmuteChannel = useStableCallback(async (options?: ChannelActionOptions) => { if (!channel) { return; } @@ -282,6 +384,7 @@ export const useChannelActions = (channel: Channel) => { options: { severity: 'success', type: 'api:channel:unmute:success' }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onSuccess?.(); } catch (error) { addNotification({ message: t('Failed to update channel mute status'), @@ -292,10 +395,11 @@ export const useChannelActions = (channel: Channel) => { }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onFailure?.(error); } }); - const blockUser = useStableCallback(async () => { + const blockUser = useStableCallback(async (options?: ChannelActionOptions) => { if (!channel) { return; } @@ -310,6 +414,7 @@ export const useChannelActions = (channel: Channel) => { options: { severity: 'success', type: 'api:user:block:success' }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onSuccess?.(); } } catch (error) { addNotification({ @@ -321,10 +426,82 @@ export const useChannelActions = (channel: Channel) => { }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onFailure?.(error); + } + }); + + const updateName = useStableCallback(async (name: string, options?: ChannelActionOptions) => { + if (!channel) { + return; + } + + try { + if (name.trim() === '') { + await channel.updatePartial({ unset: ['name'] }); + } else { + await channel.updatePartial({ set: { name } }); + } + addNotification({ + message: t('Channel name updated'), + options: { severity: 'success', type: 'api:channel:update-name:success' }, + origin: { context: { channel }, emitter: 'ChannelActions' }, + }); + options?.onSuccess?.(); + } catch (error) { + addNotification({ + message: t('Failed to update channel name'), + options: { + ...getNotificationErrorOptions(error), + severity: 'error', + type: 'api:channel:update-name:failed', + }, + origin: { context: { channel }, emitter: 'ChannelActions' }, + }); + options?.onFailure?.(error); } }); - const unblockUser = useStableCallback(async () => { + const updateImage = useStableCallback( + async ( + image: File | null, + options?: ChannelActionOptions, + doFileUploadRequest?: GlobalFileUploadRequest, + ) => { + if (!channel) { + return; + } + + try { + if (image === null) { + await channel.updatePartial({ unset: ['image'] }); + } else { + const { file } = doFileUploadRequest + ? await doFileUploadRequest(image) + : await client.uploadImage(image.uri, image.name, image.type); + await channel.updatePartial({ set: { image: file } }); + } + addNotification({ + message: t('Channel image updated'), + options: { severity: 'success', type: 'api:channel:update-image:success' }, + origin: { context: { channel }, emitter: 'ChannelActions' }, + }); + options?.onSuccess?.(); + } catch (error) { + addNotification({ + message: t('Failed to update channel image'), + options: { + ...getNotificationErrorOptions(error), + severity: 'error', + type: 'api:channel:update-image:failed', + }, + origin: { context: { channel }, emitter: 'ChannelActions' }, + }); + options?.onFailure?.(error); + } + }, + ); + + const unblockUser = useStableCallback(async (options?: ChannelActionOptions) => { if (!channel) { return; } @@ -339,6 +516,7 @@ export const useChannelActions = (channel: Channel) => { options: { severity: 'success', type: 'api:user:unblock:success' }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onSuccess?.(); } } catch (error) { addNotification({ @@ -350,17 +528,22 @@ export const useChannelActions = (channel: Channel) => { }, origin: { context: { channel }, emitter: 'ChannelActions' }, }); + options?.onFailure?.(error); } }); return useMemo<ChannelActions>( () => ({ + addMembers, + removeMembers, pin, unpin, archive, unarchive, leave, deleteChannel, + updateName, + updateImage, muteUser, unmuteUser, muteChannel, @@ -369,12 +552,16 @@ export const useChannelActions = (channel: Channel) => { unblockUser, }), [ + addMembers, + removeMembers, pin, unpin, archive, unarchive, leave, deleteChannel, + updateName, + updateImage, muteUser, unmuteUser, muteChannel, diff --git a/package/src/hooks/actions/useChannelMemberActionItems.tsx b/package/src/hooks/actions/useChannelMemberActionItems.tsx new file mode 100644 index 0000000000..27d35bcedf --- /dev/null +++ b/package/src/hooks/actions/useChannelMemberActionItems.tsx @@ -0,0 +1,204 @@ +import React, { useMemo } from 'react'; +import { Alert } from 'react-native'; + +import type { BlockedUsersState, Channel, ChannelMemberResponse } from 'stream-chat'; + +import type { ActionItem } from './types'; +import { ChannelActions, useChannelActions } from './useChannelActions'; +import { useUserActions, UserActions } from './useUserActions'; + +import { useUserMuteActive } from '../../components/Message/hooks/useUserMuteActive'; +import { useTheme, useTranslationContext } from '../../contexts'; +import type { TranslationContextValue } from '../../contexts/translationContext/TranslationContext'; +import { BlockUser, IconProps, Mute, Sound, UserDelete } from '../../icons'; +import { useChannelOwnCapabilities } from '../useChannelOwnCapabilities'; +import { useStateStore } from '../useStateStore'; + +export type ChannelMemberActionItem = ActionItem<'muteUser' | 'block' | string>; + +export type ChannelMemberActionItemsParams = { + channel: Channel; + channelActions: ChannelActions; + isBlocked: boolean; + isCurrentUser: boolean; + member: ChannelMemberResponse; + ownCapabilities: string[] | undefined; + t: TranslationContextValue['t']; + userActions: UserActions; + userMuteActive: boolean; +}; + +export type BuildDefaultChannelMemberActionItems = ( + channelMemberActionItemsParams: ChannelMemberActionItemsParams, +) => ChannelMemberActionItem[]; + +const ChannelMemberActionsIcon = ({ + Icon, + ...rest +}: { Icon: React.ComponentType<IconProps> } & IconProps) => { + const { + theme: { semantics }, + } = useTheme(); + + return <Icon stroke={semantics.textSecondary} width={20} height={20} {...rest} />; +}; + +export const buildDefaultChannelMemberActionItems: BuildDefaultChannelMemberActionItems = ( + channelMemberActionItemsParams, +) => { + const { + channelActions: { removeMembers }, + isBlocked, + isCurrentUser, + member, + ownCapabilities, + t, + userActions: { blockUser, muteUser, unblockUser, unmuteUser }, + userMuteActive, + } = channelMemberActionItemsParams; + + const canRemoveMember = ownCapabilities?.includes('update-channel-members') ?? false; + + const actionItems: ChannelMemberActionItem[] = []; + + // Muting or blocking yourself is meaningless, so these actions are only + // added for other members. + if (!isCurrentUser) { + actionItems.push( + { + action: userMuteActive ? unmuteUser : muteUser, + Icon: (props) => + userMuteActive ? ( + <ChannelMemberActionsIcon Icon={Sound} {...props} /> + ) : ( + <ChannelMemberActionsIcon + Icon={Mute} + {...props} + fill={props.fill ?? props.stroke} + stroke={undefined} + /> + ), + id: 'muteUser', + label: userMuteActive ? t('Unmute User') : t('Mute User'), + type: 'standard', + }, + { + action: isBlocked ? unblockUser : blockUser, + Icon: (props) => <ChannelMemberActionsIcon Icon={BlockUser} {...props} />, + id: 'block', + label: isBlocked ? t('Unblock User') : t('Block User'), + type: isBlocked ? 'standard' : 'destructive', + }, + ); + + if (canRemoveMember) { + actionItems.push({ + action: () => { + const memberId = member.user?.id; + if (!memberId) { + return; + } + Alert.alert( + t('Remove User'), + t('Are you sure you want to remove this member from the channel?'), + [ + { style: 'cancel', text: t('Cancel') }, + { + onPress: async () => { + await removeMembers([memberId]); + }, + style: 'destructive', + text: t('Remove'), + }, + ], + ); + }, + Icon: (props) => <ChannelMemberActionsIcon Icon={UserDelete} {...props} />, + id: 'removeMember', + label: t('Remove User'), + type: 'destructive', + }); + } + } + + return actionItems; +}; + +export type GetChannelMemberActionItems = (params: { + context: ChannelMemberActionItemsParams; + defaultItems: ChannelMemberActionItem[]; +}) => ChannelMemberActionItem[]; + +export const getChannelMemberActionItems: GetChannelMemberActionItems = ({ defaultItems }) => + defaultItems; + +type UseChannelMemberActionItemsParams = { + channel: Channel; + member: ChannelMemberResponse; + getChannelMemberActionItems?: GetChannelMemberActionItems; +}; + +const blockedUsersStateSelector = (state: BlockedUsersState) => + ({ userIds: state.userIds }) as const; + +export const useChannelMemberActionItems = ({ + channel, + member, + getChannelMemberActionItems: getChannelMemberActionItemsProp = getChannelMemberActionItems, +}: UseChannelMemberActionItemsParams) => { + const { t } = useTranslationContext(); + const userActions = useUserActions(member.user); + const channelActions = useChannelActions(channel); + + const ownCapabilities = useChannelOwnCapabilities(channel); + + const userMuteActive = useUserMuteActive(member.user); + + const { userIds: blockedUserIds } = useStateStore( + channel.getClient().blockedUsers, + blockedUsersStateSelector, + ); + + const isBlocked = blockedUserIds.includes(member.user?.id ?? ''); + + const isCurrentUser = member.user?.id === channel.getClient().userID; + + const channelMemberActionItemsParams = useMemo<ChannelMemberActionItemsParams>( + () => ({ + channel, + channelActions, + isBlocked, + isCurrentUser, + member, + ownCapabilities, + t, + userActions, + userMuteActive, + }), + [ + channel, + channelActions, + isBlocked, + isCurrentUser, + member, + ownCapabilities, + t, + userActions, + userMuteActive, + ], + ); + + const defaultItems = useMemo( + () => buildDefaultChannelMemberActionItems(channelMemberActionItemsParams), + [channelMemberActionItemsParams], + ); + + return useMemo( + () => + getChannelMemberActionItemsProp({ + context: channelMemberActionItemsParams, + defaultItems, + }), + [channelMemberActionItemsParams, defaultItems, getChannelMemberActionItemsProp], + ); +}; diff --git a/package/src/hooks/actions/useUserActions.ts b/package/src/hooks/actions/useUserActions.ts new file mode 100644 index 0000000000..5ee5f2dc5f --- /dev/null +++ b/package/src/hooks/actions/useUserActions.ts @@ -0,0 +1,141 @@ +import { useMemo } from 'react'; + +import { UserResponse } from 'stream-chat'; + +import type { ActionHandler, ActionOptions } from './types'; +import { getNotificationErrorOptions } from './useChannelActions'; + +import { useNotificationApi } from '../../components/Notifications/hooks'; +import { useChatContext, useTranslationContext } from '../../contexts'; +import { useStableCallback } from '../useStableCallback'; + +export type UserActionOptions = ActionOptions; + +export type UserActionHandler = ActionHandler; + +export type UserActions = { + blockUser: UserActionHandler; + muteUser: UserActionHandler; + unblockUser: UserActionHandler; + unmuteUser: UserActionHandler; +}; + +export const useUserActions = (user: UserResponse | undefined): UserActions => { + const { client } = useChatContext(); + const { addNotification } = useNotificationApi(); + const { t } = useTranslationContext(); + + const muteUser = useStableCallback(async (options?: UserActionOptions) => { + if (!user?.id) { + return; + } + + try { + await client.muteUser(user.id); + addNotification({ + message: t('{{ user }} has been muted', { user: user.name || user.id }), + options: { severity: 'success', type: 'api:user:mute:success' }, + origin: { context: { user }, emitter: 'UserActions' }, + }); + await options?.onSuccess?.(); + } catch (error) { + addNotification({ + message: t('Error muting a user ...'), + options: { + ...getNotificationErrorOptions(error), + severity: 'error', + type: 'api:user:mute:failed', + }, + origin: { context: { user }, emitter: 'UserActions' }, + }); + } + }); + + const unmuteUser = useStableCallback(async (options?: UserActionOptions) => { + if (!user?.id) { + return; + } + + try { + await client.unmuteUser(user.id); + addNotification({ + message: t('{{ user }} has been unmuted', { user: user.name || user.id }), + options: { severity: 'success', type: 'api:user:unmute:success' }, + origin: { context: { user }, emitter: 'UserActions' }, + }); + await options?.onSuccess?.(); + } catch (error) { + addNotification({ + message: t('Error unmuting a user ...'), + options: { + ...getNotificationErrorOptions(error), + severity: 'error', + type: 'api:user:unmute:failed', + }, + origin: { context: { user }, emitter: 'UserActions' }, + }); + } + }); + + const blockUser = useStableCallback(async (options?: UserActionOptions) => { + if (!user?.id) { + return; + } + + try { + await client.blockUser(user.id); + addNotification({ + message: t('User blocked'), + options: { severity: 'success', type: 'api:user:block:success' }, + origin: { context: { user }, emitter: 'UserActions' }, + }); + await options?.onSuccess?.(); + } catch (error) { + addNotification({ + message: t('Failed to block user'), + options: { + ...getNotificationErrorOptions(error), + severity: 'error', + type: 'api:user:block:failed', + }, + origin: { context: { user }, emitter: 'UserActions' }, + }); + } + }); + + const unblockUser = useStableCallback(async (options?: UserActionOptions) => { + if (!user?.id) { + return; + } + + try { + await client.unBlockUser(user.id); + addNotification({ + message: t('User unblocked'), + options: { severity: 'success', type: 'api:user:unblock:success' }, + origin: { context: { user }, emitter: 'UserActions' }, + }); + await options?.onSuccess?.(); + } catch (error) { + addNotification({ + message: t('Failed to block user'), + options: { + ...getNotificationErrorOptions(error), + severity: 'error', + type: 'api:user:block:failed', + }, + origin: { context: { user }, emitter: 'UserActions' }, + }); + } + }); + + return useMemo<UserActions>( + () => ({ + blockUser, + muteUser, + unblockUser, + unmuteUser, + }), + [blockUser, muteUser, unblockUser, unmuteUser], + ); +}; diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts index a521767f0a..37708d4e18 100644 --- a/package/src/hooks/index.ts +++ b/package/src/hooks/index.ts @@ -1,5 +1,14 @@ export * from '../a11y'; +export * from './actions'; export * from './useAppStateListener'; +export * from './useChannelMemberCount'; +export * from './useChannelName'; +export * from './useChannelImage'; +export * from './useChannelMembershipState'; +export * from './useChannelOwnCapabilities'; +export * from './useChannelMuteActive'; +export * from './useIsDirectChat'; +export * from './useIsChannelMember'; export * from './useStreami18n'; export * from './useViewport'; export * from './useScreenDimensions'; diff --git a/package/src/hooks/useChannelImage.ts b/package/src/hooks/useChannelImage.ts new file mode 100644 index 0000000000..ec8f3718ce --- /dev/null +++ b/package/src/hooks/useChannelImage.ts @@ -0,0 +1,12 @@ +import { Channel, EventTypes } from 'stream-chat'; + +import { useSelectedChannelState } from './useSelectedChannelState'; + +const selector = (channel: Channel) => channel.data?.image; +const keys: EventTypes[] = ['channel.updated']; + +export function useChannelImage(channel: Channel): string | undefined; +export function useChannelImage(channel?: Channel): string | undefined; +export function useChannelImage(channel?: Channel) { + return useSelectedChannelState({ channel, selector, stateChangeEventKeys: keys }); +} diff --git a/package/src/hooks/useChannelMemberCount.ts b/package/src/hooks/useChannelMemberCount.ts new file mode 100644 index 0000000000..49cf159520 --- /dev/null +++ b/package/src/hooks/useChannelMemberCount.ts @@ -0,0 +1,12 @@ +import { Channel, EventTypes } from 'stream-chat'; + +import { useSelectedChannelState } from './useSelectedChannelState'; + +const selector = (channel: Channel) => channel.data?.member_count ?? 0; +const keys: EventTypes[] = ['channel.updated']; + +export function useChannelMemberCount(channel: Channel): number; +export function useChannelMemberCount(channel?: Channel): number | undefined; +export function useChannelMemberCount(channel?: Channel) { + return useSelectedChannelState({ channel, selector, stateChangeEventKeys: keys }); +} diff --git a/package/src/components/ChannelList/hooks/useChannelMembershipState.ts b/package/src/hooks/useChannelMembershipState.ts similarity index 86% rename from package/src/components/ChannelList/hooks/useChannelMembershipState.ts rename to package/src/hooks/useChannelMembershipState.ts index a483fd3749..5ddf5761c3 100644 --- a/package/src/components/ChannelList/hooks/useChannelMembershipState.ts +++ b/package/src/hooks/useChannelMembershipState.ts @@ -1,6 +1,6 @@ import { Channel, ChannelMemberResponse, EventTypes } from 'stream-chat'; -import { useSelectedChannelState } from '../../../hooks/useSelectedChannelState'; +import { useSelectedChannelState } from './useSelectedChannelState'; const selector = (channel: Channel) => channel.state.membership; const keys: EventTypes[] = ['member.updated']; diff --git a/package/src/hooks/useChannelMuteActive.ts b/package/src/hooks/useChannelMuteActive.ts new file mode 100644 index 0000000000..2bcb857f17 --- /dev/null +++ b/package/src/hooks/useChannelMuteActive.ts @@ -0,0 +1,25 @@ +import { Channel } from 'stream-chat'; + +import { getOtherUserInDirectChannel } from './actions/useChannelActions'; + +import { useIsDirectChat } from './useIsDirectChat'; + +import { useMutedChannels } from '../components/ChannelList/hooks/useMutedChannels'; +import { useUserMuteActive } from '../components/Message/hooks/useUserMuteActive'; + +export const useChannelMuteActive = (channel: Channel) => { + const isDirectChat = useIsDirectChat(channel); + const mutedChannels = useMutedChannels(channel); + const otherUser = getOtherUserInDirectChannel(channel)?.user; + const otherUserMuted = useUserMuteActive(otherUser); + + const channelMuted = !!mutedChannels.find( + (mutedChannel) => channel.cid === mutedChannel.channel?.cid, + ); + + if (!isDirectChat) { + return channelMuted; + } + + return channelMuted || otherUserMuted; +}; diff --git a/package/src/hooks/useChannelName.ts b/package/src/hooks/useChannelName.ts new file mode 100644 index 0000000000..7b0caaabf5 --- /dev/null +++ b/package/src/hooks/useChannelName.ts @@ -0,0 +1,12 @@ +import { Channel, EventTypes } from 'stream-chat'; + +import { useSelectedChannelState } from './useSelectedChannelState'; + +const selector = (channel: Channel) => channel.data?.name; +const keys: EventTypes[] = ['channel.updated']; + +export function useChannelName(channel: Channel): string | undefined; +export function useChannelName(channel?: Channel): string | undefined; +export function useChannelName(channel?: Channel) { + return useSelectedChannelState({ channel, selector, stateChangeEventKeys: keys }); +} diff --git a/package/src/hooks/useChannelOwnCapabilities.ts b/package/src/hooks/useChannelOwnCapabilities.ts new file mode 100644 index 0000000000..94f52bdb86 --- /dev/null +++ b/package/src/hooks/useChannelOwnCapabilities.ts @@ -0,0 +1,12 @@ +import { Channel, EventTypes } from 'stream-chat'; + +import { useSelectedChannelState } from './useSelectedChannelState'; + +const selector = (channel: Channel) => channel.data?.own_capabilities as string[] | undefined; +const keys: EventTypes[] = ['capabilities.changed']; + +export function useChannelOwnCapabilities(channel: Channel): string[] | undefined; +export function useChannelOwnCapabilities(channel?: Channel): string[] | undefined; +export function useChannelOwnCapabilities(channel?: Channel) { + return useSelectedChannelState({ channel, selector, stateChangeEventKeys: keys }); +} diff --git a/package/src/hooks/useIsChannelMember.ts b/package/src/hooks/useIsChannelMember.ts new file mode 100644 index 0000000000..58f8e4ddae --- /dev/null +++ b/package/src/hooks/useIsChannelMember.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react'; + +import type { Channel } from 'stream-chat'; + +import { useChannelMembersState } from '../components/ChannelList/hooks/useChannelMembersState'; + +/** + * Determines whether the user with the given id is already a member of the channel. + */ +export const useIsChannelMember = (channel: Channel, userId?: string) => { + const members = useChannelMembersState(channel); + + return useMemo(() => { + if (!userId) { + return false; + } + + return Object.values(members).some((member) => member.user?.id === userId); + }, [members, userId]); +}; diff --git a/package/src/components/ChannelList/hooks/useIsDirectChat.ts b/package/src/hooks/useIsDirectChat.ts similarity index 75% rename from package/src/components/ChannelList/hooks/useIsDirectChat.ts rename to package/src/hooks/useIsDirectChat.ts index dc35fe807b..bb890dc248 100644 --- a/package/src/components/ChannelList/hooks/useIsDirectChat.ts +++ b/package/src/hooks/useIsDirectChat.ts @@ -2,9 +2,8 @@ import { useMemo } from 'react'; import type { Channel } from 'stream-chat'; -import { useChannelMembersState } from './useChannelMembersState'; - -import { useChatContext } from '../../../contexts'; +import { useChannelMembersState } from '../components/ChannelList/hooks/useChannelMembersState'; +import { useChatContext } from '../contexts'; export const useIsDirectChat = (channel: Channel) => { const { client } = useChatContext(); diff --git a/package/src/hooks/useSyncClientEvents.ts b/package/src/hooks/useSyncClientEvents.ts index 7c3bcca5bf..6cd79a7b30 100644 --- a/package/src/hooks/useSyncClientEvents.ts +++ b/package/src/hooks/useSyncClientEvents.ts @@ -56,3 +56,50 @@ export function useSyncClientEventsToChannel<O>({ return useSyncExternalStore(subscribe, getSnapshot); } + +export function useSyncClientEvents<O>(_: { + client: StreamChat; + selector: (client: StreamChat) => O; + stateChangeEventKeys: EventTypes[]; +}): O; +export function useSyncClientEvents<O>(_: { + client?: StreamChat | undefined; + selector: (client: StreamChat) => O; + stateChangeEventKeys: EventTypes[]; +}): O | undefined; +export function useSyncClientEvents<O>({ + client, + selector, + stateChangeEventKeys = ['all'], +}: { + selector: (client: StreamChat) => O; + client?: StreamChat; + stateChangeEventKeys: EventTypes[]; +}): O | undefined { + const subscribe = useCallback( + (onStoreChange: (value: O) => void) => { + if (!client) { + return noop; + } + + const subscriptions = stateChangeEventKeys.map((et) => + client.on(et, () => { + onStoreChange(selector(client)); + }), + ); + + return () => subscriptions.forEach((subscription) => subscription.unsubscribe()); + }, + [client, selector, stateChangeEventKeys], + ); + + const getSnapshot = useCallback(() => { + if (!client) { + return undefined; + } + + return selector(client); + }, [client, selector]); + + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/package/src/i18n/ar.json b/package/src/i18n/ar.json index 7220c8fa3b..aee82a8d02 100644 --- a/package/src/i18n/ar.json +++ b/package/src/i18n/ar.json @@ -90,6 +90,7 @@ "No chats here yet…": "لا توجد محادثات هنا بعد…", "No items exist": "لا توجد عناصر", "No threads here yet": "لا توجد مواضيع هنا بعد", + "No user found": "لم يتم العثور على مستخدم", "Not supported": "غير مدعوم", "Nothing yet...": "لا شيء بعد...", "Ok": "حسناً", @@ -161,6 +162,7 @@ "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "رد على", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[أمس]\", \"lastWeek\":\"dddd\", \"nextDay\":\"[غدًا]\", \"nextWeek\":\"dddd [في] LT\", \"sameDay\":\"LT\", \"sameElse\":\"L\"}) }}", + "timestamp/FileAttachmentListSection": "{{ timestamp | timestampFormatter(format: MMMM YYYY) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -235,11 +237,16 @@ "Delete Group": "حذف المجموعة", "Leave Chat": "مغادرة الدردشة", "Leave Group": "مغادرة المجموعة", + "Mute Chat": "كتم الدردشة", "Mute Group": "كتم المجموعة", + "Admin": "مسؤول", + "Moderator": "مشرف", "Offline": "غير متصل", "Online": "متصل", + "Owner": "مالك", "Unarchive Chat": "إلغاء أرشفة الدردشة", "Unarchive Group": "إلغاء أرشفة المجموعة", + "Unmute Chat": "إلغاء كتم الدردشة", "Unmute Group": "إلغاء كتم المجموعة", "{{memberCount}} members, {{onlineCount}} online_one": "{{memberCount}} عضو، {{onlineCount}} متصل", "{{memberCount}} members, {{onlineCount}} online_other": "{{memberCount}} أعضاء، {{onlineCount}} متصل", @@ -268,6 +275,7 @@ "a11y/Loading failed": "Loading failed", "a11y/Message actions": "Message actions", "a11y/Muted": "مكتوم", + "a11y/Pinned": "مثبت", "a11y/New message from {{user}}": "New message from {{user}}", "a11y/Offline": "Offline", "a11y/Open message actions": "Open message actions", @@ -293,10 +301,14 @@ "a11y/you reacted": "تفاعلت", "a11y/{{count}} new messages": "{{count}} new messages", "a11y/Add attachment": "Add attachment", + "a11y/Add members": "إضافة أعضاء", + "a11y/Clear search": "مسح البحث", + "a11y/Close": "Close", "a11y/Close attachments": "Close attachments", "a11y/Remove attachment": "Remove Attachment", "a11y/Close poll": "Close poll", "a11y/Close poll creation": "Close poll creation", + "a11y/Confirm add members": "تأكيد إضافة الأعضاء", "a11y/Create poll": "Create poll", "a11y/Decrease maximum votes": "Decrease maximum votes", "a11y/Delete voice recording": "Delete voice recording", @@ -319,11 +331,29 @@ "a11y/Select image": "Select image", "a11y/Select video": "Select video", "a11y/Send voice recording": "Send voice recording", + "a11y/Search users to add": "البحث عن مستخدمين لإضافتهم", + "a11y/Select {{name}}": "تحديد {{name}}", + "a11y/{{name}} is already a member": "{{name}} عضو بالفعل", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", "a11y/Stop voice recording": "Stop voice recording", + "Add": "إضافة", + "Add Members": "إضافة أعضاء", + "Contact Info": "معلومات جهة الاتصال", + "Edit": "تعديل", + "Files": "الملفات", + "Group Info": "معلومات المجموعة", + "timestamp/UserActivityStatus": "آخر ظهور {{ timestamp | fromNowFormatter }}", + "Photos & Videos": "الصور ومقاطع الفيديو", + "Pinned Messages": "الرسائل المثبتة", + "View all": "عرض الكل", + "{{count}} members_one": "{{count}} عضو", + "{{count}} members_other": "{{count}} أعضاء", + "{{count}} members_many": "{{count}} أعضاء", + "a11y/Back": "رجوع", "a11y/Notifications": "Notifications", "a11y/Dismiss notification": "Dismiss notification", + "a11y/Edit channel": "تعديل القناة", "Attachment upload blocked due to {{reason}}": "تم حظر رفع المرفق بسبب {{reason}}", "Attachment upload failed due to {{reason}}": "فشل رفع المرفق بسبب {{reason}}", "Command not available": "الأمر غير متاح", @@ -346,19 +376,31 @@ "Wait until all attachments have uploaded": "انتظر حتى يتم رفع جميع المرفقات", "Cannot seek in the recording": "لا يمكن التنقل داخل التسجيل", "Channel archived": "تمت أرشفة القناة", + "Channel deleted": "تم حذف القناة", + "Channel image updated": "تم تحديث صورة القناة", "Channel muted": "تم كتم القناة", + "Channel name updated": "تم تحديث اسم القناة", "Channel pinned": "تم تثبيت القناة", "Channel unarchived": "تم إلغاء أرشفة القناة", "Channel unmuted": "تم إلغاء كتم القناة", "Channel unpinned": "تم إلغاء تثبيت القناة", "Edit message request failed": "فشل طلب تعديل الرسالة", + "Failed to add members": "فشل إضافة الأعضاء", "Failed to block user": "فشل حظر المستخدم", + "Failed to delete channel": "فشل حذف القناة", "Failed to leave channel": "فشل مغادرة القناة", + "Failed to load pinned messages": "فشل تحميل الرسائل المثبتة", + "Failed to load users": "فشل تحميل المستخدمين", "Failed to play the recording": "فشل تشغيل التسجيل", "Failed to update channel archive status": "فشل تحديث حالة أرشفة القناة", + "Failed to update channel image": "فشل تحديث صورة القناة", "Failed to update channel mute status": "فشل تحديث حالة كتم القناة", + "Failed to update channel name": "فشل تحديث اسم القناة", "Failed to update channel pinned status": "فشل تحديث حالة تثبيت القناة", "Left channel": "تمت مغادرة القناة", + "{{count}} members added_one": "تمت إضافة {{count}} عضو", + "{{count}} members added_other": "تمت إضافة {{count}} أعضاء", + "{{count}} members added_many": "تمت إضافة {{count}} أعضاء", "Recording format is not supported and cannot be reproduced": "تنسيق التسجيل غير مدعوم ولا يمكن تشغيله", "Send message request failed": "فشل طلب إرسال الرسالة", "User blocked": "تم حظر المستخدم", @@ -368,14 +410,52 @@ "size limit": "حد الحجم", "unknown error": "خطأ غير معروف", "unsupported file type": "نوع ملف غير مدعوم", + "Already a member": "عضو بالفعل", + "Channel name": "اسم القناة", + "Edit Group Picture": "تعديل صورة المجموعة", + "Choose Image": "اختيار صورة", + "Take Photo": "التقاط صورة", + "Upload": "رفع", + "a11y/Channel name": "اسم القناة", + "a11y/Confirm edit channel": "تأكيد تعديل القناة", + "a11y/Upload channel image": "رفع صورة القناة", + "Reset Picture": "إعادة تعيين الصورة", + "a11y/Close edit picture sheet": "إغلاق نافذة تعديل الصورة", + "Muted": "مكتوم", + "Failed to load members": "فشل تحميل الأعضاء", + "Remove User": "إزالة المستخدم", + "Remove": "إزالة", + "Are you sure you want to remove this member from the channel?": "هل أنت متأكد أنك تريد إزالة هذا العضو من القناة؟", + "{{count}} members removed_one": "تمت إزالة {{count}} عضو", + "{{count}} members removed_other": "تمت إزالة {{count}} أعضاء", + "{{count}} members removed_many": "تمت إزالة {{count}} أعضاء", + "Failed to remove members": "فشل إزالة الأعضاء", "a11y/Double tap and hold to activate contextual menu": "انقر نقرًا مزدوجًا مع الاستمرار لتفعيل قائمة السياق", "a11y/Swipe right to go through different actions": "اسحب لليمين للتنقل بين الإجراءات المختلفة", - "a11y/Close": "Close", "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.", "a11y/{{count}} unread messages": "{{count}} رسائل غير مقروءة", "a11y/Message from you": "رسالة منك", "a11y/Message from {{sender}}": "رسالة من {{sender}}", "a11y/Gallery Image": "صورة من المعرض", "a11y/Gallery Video": "فيديو من المعرض", - "a11y/{{position}} of {{count}}": "{{position}} من {{count}}" + "a11y/{{position}} of {{count}}": "{{position}} من {{count}}", + "No pinned messages": "لا توجد رسائل مثبتة", + "a11y/Search pinned messages": "البحث في الرسائل المثبتة", + "Long-press a message to pin it to the chat": "اضغط مطولاً على رسالة لتثبيتها في المحادثة", + "No photos or videos": "لا توجد صور أو مقاطع فيديو", + "Share a photo or video to see it here": "شارك صورة أو مقطع فيديو لرؤيته هنا", + "Failed to load media": "فشل تحميل الوسائط", + "No files": "لا توجد ملفات", + "Share a file to see it here": "شارك ملفًا لرؤيته هنا", + "Failed to load files": "فشل تحميل الملفات", + "Notify all {{ role }} members": "Notify all {{ role }} members", + "a11y/Command suggestions available": "Command suggestions available", + "a11y/Emoji suggestions available": "Emoji suggestions available", + "a11y/Mention suggestions available": "Mention suggestions available", + "mention/Channel Description": "Notify everyone in this channel", + "mention/Here Description": "Notify every online member in this channel", + "Pin Chat": "تثبيت الدردشة", + "Pin Group": "تثبيت المجموعة", + "Unpin Chat": "إلغاء تثبيت الدردشة", + "Unpin Group": "إلغاء تثبيت المجموعة" } diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index 37ba4bddc8..d9f62353f1 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -72,6 +72,7 @@ "Loading threads...": "Loading threads...", "Loading...": "Loading...", "Location": "Location", + "Long-press a message to pin it to the chat": "Long-press a message to pin it to the chat", "Mark as Unread": "Mark as Unread", "Maximum number of files reached": "Maximum number of files reached", "Message Reactions": "Message Reactions", @@ -87,9 +88,21 @@ "Limit votes per person": "Limit votes per person", "Choose between 2–10 options": "Choose between 2–10 options", "Mute User": "Mute User", + "Remove User": "Remove User", + "Remove": "Remove", + "Are you sure you want to remove this member from the channel?": "Are you sure you want to remove this member from the channel?", + "{{count}} members removed_one": "{{count}} member removed", + "{{count}} members removed_other": "{{count}} members removed", + "{{count}} members removed_many": "{{count}} members removed", + "Failed to remove members": "Failed to remove members", + "Muted": "Muted", "No chats here yet…": "No chats here yet…", + "No files": "No files", "No items exist": "No items exist", + "No photos or videos": "No photos or videos", + "No pinned messages": "No pinned messages", "No threads here yet": "No threads here yet", + "No user found": "No user found", "Not supported": "Not supported", "Nothing yet...": "Nothing yet...", "Ok": "Ok", @@ -101,6 +114,7 @@ "Options": "Options", "Photo": "Photo", "Photos and Videos": "Photos and Videos", + "Share a photo or video to see it here": "Share a photo or video to see it here", "Pin to Conversation": "Pin to Conversation", "Pinned by": "Pinned by", "Please allow Audio permissions in settings.": "Please allow Audio permissions in settings.", @@ -161,6 +175,7 @@ "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "replied to", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Yesterday]\", \"lastWeek\":\"dddd\", \"nextDay\":\"[Tomorrow]\", \"nextWeek\":\"dddd [at] LT\", \"sameDay\":\"LT\", \"sameElse\":\"L\"}) }}", + "timestamp/FileAttachmentListSection": "{{ timestamp | timestampFormatter(format: MMMM YYYY) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -235,12 +250,21 @@ "Delete Group": "Delete Group", "Leave Chat": "Leave Chat", "Leave Group": "Leave Group", + "Mute Chat": "Mute Chat", "Mute Group": "Mute Group", + "Pin Chat": "Pin Chat", + "Pin Group": "Pin Group", + "Admin": "Admin", + "Moderator": "Moderator", "Offline": "Offline", "Online": "Online", + "Owner": "Owner", "Unarchive Chat": "Unarchive Chat", "Unarchive Group": "Unarchive Group", + "Unmute Chat": "Unmute Chat", "Unmute Group": "Unmute Group", + "Unpin Chat": "Unpin Chat", + "Unpin Group": "Unpin Group", "{{memberCount}} members, {{onlineCount}} online_one": "{{memberCount}} member, {{onlineCount}} online", "{{memberCount}} members, {{onlineCount}} online_other": "{{memberCount}} members, {{onlineCount}} online", "{{memberCount}} members, {{onlineCount}} online_many": "{{memberCount}} members, {{onlineCount}} online", @@ -270,6 +294,7 @@ "a11y/Message from you": "Message from you", "a11y/Message from {{sender}}": "Message from {{sender}}", "a11y/Muted": "Muted", + "a11y/Pinned": "Pinned", "a11y/New message from {{user}}": "New message from {{user}}", "a11y/Offline": "Offline", "a11y/Open message actions": "Open message actions", @@ -295,10 +320,14 @@ "a11y/you reacted": "you reacted", "a11y/{{count}} new messages": "{{count}} new messages", "a11y/Add attachment": "Add attachment", + "a11y/Add members": "Add members", + "a11y/Clear search": "Clear search", + "a11y/Close": "Close", "a11y/Close attachments": "Close attachments", "a11y/Remove attachment": "Remove Attachment", "a11y/Close poll": "Close poll", "a11y/Close poll creation": "Close poll creation", + "a11y/Confirm add members": "Confirm add members", "a11y/Create poll": "Create poll", "a11y/Decrease maximum votes": "Decrease maximum votes", "a11y/Delete voice recording": "Delete voice recording", @@ -321,13 +350,32 @@ "a11y/Select image": "Select image", "a11y/Select video": "Select video", "a11y/Send voice recording": "Send voice recording", + "a11y/Search pinned messages": "Search pinned messages", + "a11y/Search users to add": "Search users to add", + "a11y/Select {{name}}": "Select {{name}}", + "a11y/{{name}} is already a member": "{{name}} is already a member", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", "a11y/Stop voice recording": "Stop voice recording", + "Add": "Add", + "Add Members": "Add Members", + "Contact Info": "Contact Info", + "Edit": "Edit", + "Files": "Files", + "Share a file to see it here": "Share a file to see it here", + "Group Info": "Group Info", + "timestamp/UserActivityStatus": "Last seen {{ timestamp | fromNowFormatter }}", + "Photos & Videos": "Photos & Videos", + "Pinned Messages": "Pinned Messages", + "View all": "View all", + "{{count}} members_one": "{{count}} member", + "{{count}} members_other": "{{count}} members", + "{{count}} members_many": "{{count}} members", + "a11y/Back": "Back", "a11y/Notifications": "Notifications", "a11y/Dismiss notification": "Dismiss notification", + "a11y/Edit channel": "Edit channel", "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.", - "a11y/Close": "Close", "a11y/Double tap and hold to activate contextual menu": "Double tap and hold to activate contextual menu", "a11y/Swipe right to go through different actions": "Swipe right to go through different actions", "Attachment upload blocked due to {{reason}}": "Attachment upload blocked due to {{reason}}", @@ -352,19 +400,34 @@ "Wait until all attachments have uploaded": "Wait until all attachments have uploaded", "Cannot seek in the recording": "Cannot seek in the recording", "Channel archived": "Channel archived", + "Channel deleted": "Channel deleted", + "Channel image updated": "Channel image updated", "Channel muted": "Channel muted", + "Channel name updated": "Channel name updated", "Channel pinned": "Channel pinned", "Channel unarchived": "Channel unarchived", "Channel unmuted": "Channel unmuted", "Channel unpinned": "Channel unpinned", "Edit message request failed": "Edit message request failed", + "Failed to add members": "Failed to add members", "Failed to block user": "Failed to block user", + "Failed to delete channel": "Failed to delete channel", "Failed to leave channel": "Failed to leave channel", + "Failed to load files": "Failed to load files", + "Failed to load media": "Failed to load media", + "Failed to load members": "Failed to load members", + "Failed to load pinned messages": "Failed to load pinned messages", + "Failed to load users": "Failed to load users", "Failed to play the recording": "Failed to play the recording", "Failed to update channel archive status": "Failed to update channel archive status", + "Failed to update channel image": "Failed to update channel image", "Failed to update channel mute status": "Failed to update channel mute status", + "Failed to update channel name": "Failed to update channel name", "Failed to update channel pinned status": "Failed to update channel pinned status", "Left channel": "Left channel", + "{{count}} members added_one": "{{count}} member added", + "{{count}} members added_other": "{{count}} members added", + "{{count}} members added_many": "{{count}} members added", "Recording format is not supported and cannot be reproduced": "Recording format is not supported and cannot be reproduced", "Send message request failed": "Send message request failed", "User blocked": "User blocked", @@ -374,8 +437,25 @@ "size limit": "size limit", "unknown error": "unknown error", "unsupported file type": "unsupported file type", + "Already a member": "Already a member", + "Channel name": "Channel name", + "Edit Group Picture": "Edit Group Picture", + "Choose Image": "Choose Image", + "Take Photo": "Take Photo", + "Upload": "Upload", + "a11y/Channel name": "Channel name", + "a11y/Confirm edit channel": "Confirm edit channel", + "a11y/Upload channel image": "Upload channel image", + "Reset Picture": "Reset Picture", + "a11y/Close edit picture sheet": "Close edit picture sheet", "a11y/{{count}} unread messages": "{{count}} unread messages", "a11y/Gallery Image": "Gallery image", "a11y/Gallery Video": "Gallery video", - "a11y/{{position}} of {{count}}": "{{position}} of {{count}}" + "a11y/{{position}} of {{count}}": "{{position}} of {{count}}", + "Notify all {{ role }} members": "Notify all {{ role }} members", + "a11y/Command suggestions available": "Command suggestions available", + "a11y/Emoji suggestions available": "Emoji suggestions available", + "a11y/Mention suggestions available": "Mention suggestions available", + "mention/Channel Description": "Notify everyone in this channel", + "mention/Here Description": "Notify every online member in this channel" } diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index a153f414c9..1f2a75a753 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -90,6 +90,7 @@ "No chats here yet…": "No hay chats aquí todavía...", "No items exist": "No hay elementos", "No threads here yet": "Aún no hay hilos aquí", + "No user found": "No se encontró ningún usuario", "Not supported": "No admitido", "Nothing yet...": "Aún no hay nada...", "Ok": "Aceptar", @@ -161,6 +162,7 @@ "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "respondió a", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Ayer]\", \"lastWeek\":\"dddd\", \"nextDay\":\"[Mañana]\", \"nextWeek\":\"dddd [a las] LT\", \"sameDay\":\"LT\", \"sameElse\":\"L\"}) }}", + "timestamp/FileAttachmentListSection": "{{ timestamp | timestampFormatter(format: MMMM YYYY) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -235,11 +237,16 @@ "Delete Group": "Eliminar grupo", "Leave Chat": "Salir del chat", "Leave Group": "Salir del grupo", + "Mute Chat": "Silenciar chat", "Mute Group": "Silenciar grupo", + "Admin": "Administrador", + "Moderator": "Moderador", "Offline": "Desconectado", "Online": "En línea", + "Owner": "Propietario", "Unarchive Chat": "Desarchivar chat", "Unarchive Group": "Desarchivar grupo", + "Unmute Chat": "Activar sonido del chat", "Unmute Group": "Activar sonido del grupo", "{{memberCount}} members, {{onlineCount}} online_one": "{{memberCount}} miembro, {{onlineCount}} en línea", "{{memberCount}} members, {{onlineCount}} online_other": "{{memberCount}} miembros, {{onlineCount}} en línea", @@ -268,6 +275,7 @@ "a11y/Loading failed": "Error al cargar", "a11y/Message actions": "Acciones del mensaje", "a11y/Muted": "Silenciado", + "a11y/Pinned": "Fijado", "a11y/New message from {{user}}": "Nuevo mensaje de {{user}}", "a11y/Offline": "Sin conexión", "a11y/Open message actions": "Abrir acciones del mensaje", @@ -293,10 +301,14 @@ "a11y/you reacted": "tú reaccionaste", "a11y/{{count}} new messages": "{{count}} mensajes nuevos", "a11y/Add attachment": "Add attachment", + "a11y/Add members": "Añadir miembros", + "a11y/Clear search": "Borrar búsqueda", + "a11y/Close": "Close", "a11y/Close attachments": "Close attachments", "a11y/Remove attachment": "Remove Attachment", "a11y/Close poll": "Close poll", "a11y/Close poll creation": "Close poll creation", + "a11y/Confirm add members": "Confirmar añadir miembros", "a11y/Create poll": "Create poll", "a11y/Decrease maximum votes": "Decrease maximum votes", "a11y/Delete voice recording": "Delete voice recording", @@ -319,11 +331,29 @@ "a11y/Select image": "Select image", "a11y/Select video": "Select video", "a11y/Send voice recording": "Send voice recording", + "a11y/Search users to add": "Buscar usuarios para añadir", + "a11y/Select {{name}}": "Seleccionar {{name}}", + "a11y/{{name}} is already a member": "{{name}} ya es miembro", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", "a11y/Stop voice recording": "Stop voice recording", + "Add": "Añadir", + "Add Members": "Añadir miembros", + "Contact Info": "Información de contacto", + "Edit": "Editar", + "Files": "Archivos", + "Group Info": "Información del grupo", + "timestamp/UserActivityStatus": "Última conexión {{ timestamp | fromNowFormatter }}", + "Photos & Videos": "Fotos y videos", + "Pinned Messages": "Mensajes fijados", + "View all": "Ver todo", + "{{count}} members_one": "{{count}} miembro", + "{{count}} members_other": "{{count}} miembros", + "{{count}} members_many": "{{count}} miembros", + "a11y/Back": "Atrás", "a11y/Notifications": "Notificaciones", "a11y/Dismiss notification": "Descartar notificación", + "a11y/Edit channel": "Editar canal", "Attachment upload blocked due to {{reason}}": "Carga de adjunto bloqueada por {{reason}}", "Attachment upload failed due to {{reason}}": "Error al cargar el adjunto debido a {{reason}}", "Command not available": "Comando no disponible", @@ -346,19 +376,31 @@ "Wait until all attachments have uploaded": "Espera hasta que se hayan cargado todos los adjuntos", "Cannot seek in the recording": "No se puede cambiar la posición de la grabación", "Channel archived": "Canal archivado", + "Channel deleted": "Canal eliminado", + "Channel image updated": "Imagen del canal actualizada", "Channel muted": "Canal silenciado", + "Channel name updated": "Nombre del canal actualizado", "Channel pinned": "Canal fijado", "Channel unarchived": "Canal desarchivado", "Channel unmuted": "Canal no silenciado", "Channel unpinned": "Canal desfijado", "Edit message request failed": "No se pudo editar el mensaje", + "Failed to add members": "Error al añadir miembros", "Failed to block user": "No se pudo bloquear al usuario", + "Failed to delete channel": "Error al eliminar el canal", "Failed to leave channel": "No se pudo salir del canal", + "Failed to load pinned messages": "No se pudieron cargar los mensajes fijados", + "Failed to load users": "No se pudieron cargar los usuarios", "Failed to play the recording": "No se pudo reproducir la grabación", "Failed to update channel archive status": "No se pudo actualizar el estado de archivo del canal", + "Failed to update channel image": "No se pudo actualizar la imagen del canal", "Failed to update channel mute status": "No se pudo actualizar el estado de silencio del canal", + "Failed to update channel name": "No se pudo actualizar el nombre del canal", "Failed to update channel pinned status": "No se pudo actualizar el estado de fijación del canal", "Left channel": "Saliste del canal", + "{{count}} members added_one": "{{count}} miembro añadido", + "{{count}} members added_other": "{{count}} miembros añadidos", + "{{count}} members added_many": "{{count}} miembros añadidos", "Recording format is not supported and cannot be reproduced": "El formato de la grabación no es compatible y no se puede reproducir", "Send message request failed": "No se pudo enviar el mensaje", "User blocked": "Usuario bloqueado", @@ -368,14 +410,52 @@ "size limit": "límite de tamaño", "unknown error": "error desconocido", "unsupported file type": "tipo de archivo no compatible", + "Already a member": "Ya es miembro", + "Channel name": "Nombre del canal", + "Edit Group Picture": "Editar foto del grupo", + "Choose Image": "Elegir imagen", + "Take Photo": "Tomar foto", + "Upload": "Subir", + "a11y/Channel name": "Nombre del canal", + "a11y/Confirm edit channel": "Confirmar edición del canal", + "a11y/Upload channel image": "Subir imagen del canal", + "Reset Picture": "Restablecer foto", + "a11y/Close edit picture sheet": "Cerrar hoja de edición de foto", + "Muted": "Silenciado", + "Failed to load members": "No se pudieron cargar los miembros", + "Remove User": "Quitar usuario", + "Remove": "Quitar", + "Are you sure you want to remove this member from the channel?": "¿Estás seguro de que quieres quitar a este miembro del canal?", + "{{count}} members removed_one": "{{count}} miembro eliminado", + "{{count}} members removed_other": "{{count}} miembros eliminados", + "{{count}} members removed_many": "{{count}} miembros eliminados", + "Failed to remove members": "No se pudieron eliminar los miembros", "a11y/Double tap and hold to activate contextual menu": "Toca dos veces y mantén pulsado para activar el menú contextual", "a11y/Swipe right to go through different actions": "Desliza a la derecha para recorrer las diferentes acciones", - "a11y/Close": "Close", "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.", "a11y/{{count}} unread messages": "{{count}} mensajes sin leer", "a11y/Message from you": "Mensaje tuyo", "a11y/Message from {{sender}}": "Mensaje de {{sender}}", "a11y/Gallery Image": "Imagen de la galería", "a11y/Gallery Video": "Vídeo de la galería", - "a11y/{{position}} of {{count}}": "{{position}} de {{count}}" + "a11y/{{position}} of {{count}}": "{{position}} de {{count}}", + "No pinned messages": "No hay mensajes fijados", + "a11y/Search pinned messages": "Buscar mensajes fijados", + "Long-press a message to pin it to the chat": "Mantén pulsado un mensaje para fijarlo en el chat", + "No photos or videos": "No hay fotos ni videos", + "Share a photo or video to see it here": "Comparte una foto o un video para verlo aquí", + "Failed to load media": "Error al cargar multimedia", + "No files": "No hay archivos", + "Share a file to see it here": "Comparte un archivo para verlo aquí", + "Failed to load files": "Error al cargar los archivos", + "Notify all {{ role }} members": "Notify all {{ role }} members", + "a11y/Command suggestions available": "Command suggestions available", + "a11y/Emoji suggestions available": "Emoji suggestions available", + "a11y/Mention suggestions available": "Mention suggestions available", + "mention/Channel Description": "Notify everyone in this channel", + "mention/Here Description": "Notify every online member in this channel", + "Pin Chat": "Fijar chat", + "Pin Group": "Fijar grupo", + "Unpin Chat": "Desfijar chat", + "Unpin Group": "Desfijar grupo" } diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index efac795164..1b52d874f3 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -90,6 +90,7 @@ "No chats here yet…": "Pas de discussions ici pour le moment…", "No items exist": "Aucun élément", "No threads here yet": "Aucun fil ici pour le moment", + "No user found": "Aucun utilisateur trouvé", "Not supported": "Non pris en charge", "Nothing yet...": "Aucun message...", "Ok": "Ok", @@ -161,6 +162,7 @@ "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "a répondu à", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Hier]\", \"lastWeek\":\"dddd\", \"nextDay\":\"[Demain]\", \"nextWeek\":\"dddd [à] LT\", \"sameDay\":\"LT\", \"sameElse\":\"L\"}) }}", + "timestamp/FileAttachmentListSection": "{{ timestamp | timestampFormatter(format: MMMM YYYY) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -235,11 +237,16 @@ "Delete Group": "Supprimer le groupe", "Leave Chat": "Quitter la discussion", "Leave Group": "Quitter le groupe", + "Mute Chat": "Mettre la discussion en sourdine", "Mute Group": "Mettre le groupe en sourdine", + "Admin": "Administrateur", + "Moderator": "Modérateur", "Offline": "Hors ligne", "Online": "En ligne", + "Owner": "Propriétaire", "Unarchive Chat": "Désarchiver la discussion", "Unarchive Group": "Désarchiver le groupe", + "Unmute Chat": "Rétablir le son de la discussion", "Unmute Group": "Rétablir le son du groupe", "{{memberCount}} members, {{onlineCount}} online_one": "{{memberCount}} membre, {{onlineCount}} en ligne", "{{memberCount}} members, {{onlineCount}} online_other": "{{memberCount}} membres, {{onlineCount}} en ligne", @@ -268,6 +275,7 @@ "a11y/Loading failed": "Échec du chargement", "a11y/Message actions": "Actions du message", "a11y/Muted": "Mis en sourdine", + "a11y/Pinned": "Épinglé", "a11y/New message from {{user}}": "Nouveau message de {{user}}", "a11y/Offline": "Hors ligne", "a11y/Open message actions": "Ouvrir les actions du message", @@ -293,10 +301,14 @@ "a11y/you reacted": "vous avez réagi", "a11y/{{count}} new messages": "{{count}} nouveaux messages", "a11y/Add attachment": "Add attachment", + "a11y/Add members": "Ajouter des membres", + "a11y/Clear search": "Effacer la recherche", + "a11y/Close": "Close", "a11y/Close attachments": "Close attachments", "a11y/Remove attachment": "Remove Attachment", "a11y/Close poll": "Close poll", "a11y/Close poll creation": "Close poll creation", + "a11y/Confirm add members": "Confirmer l'ajout de membres", "a11y/Create poll": "Create poll", "a11y/Decrease maximum votes": "Decrease maximum votes", "a11y/Delete voice recording": "Delete voice recording", @@ -319,11 +331,29 @@ "a11y/Select image": "Select image", "a11y/Select video": "Select video", "a11y/Send voice recording": "Send voice recording", + "a11y/Search users to add": "Rechercher des utilisateurs à ajouter", + "a11y/Select {{name}}": "Sélectionner {{name}}", + "a11y/{{name}} is already a member": "{{name}} est déjà membre", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", "a11y/Stop voice recording": "Stop voice recording", + "Add": "Ajouter", + "Add Members": "Ajouter des membres", + "Contact Info": "Informations de contact", + "Edit": "Modifier", + "Files": "Fichiers", + "Group Info": "Informations du groupe", + "timestamp/UserActivityStatus": "Vu pour la dernière fois {{ timestamp | fromNowFormatter }}", + "Photos & Videos": "Photos et vidéos", + "Pinned Messages": "Messages épinglés", + "View all": "Tout voir", + "{{count}} members_one": "{{count}} membre", + "{{count}} members_other": "{{count}} membres", + "{{count}} members_many": "{{count}} membres", + "a11y/Back": "Retour", "a11y/Notifications": "Notifications", "a11y/Dismiss notification": "Fermer la notification", + "a11y/Edit channel": "Modifier le canal", "Attachment upload blocked due to {{reason}}": "Envoi de la pièce jointe bloqué en raison de {{reason}}", "Attachment upload failed due to {{reason}}": "Échec de l'envoi de la pièce jointe en raison de {{reason}}", "Command not available": "Commande non disponible", @@ -346,19 +376,31 @@ "Wait until all attachments have uploaded": "Attendez que toutes les pièces jointes soient envoyées", "Cannot seek in the recording": "Impossible de se déplacer dans l’enregistrement", "Channel archived": "Canal archivé", + "Channel deleted": "Canal supprimé", + "Channel image updated": "Image du canal mise à jour", "Channel muted": "Canal mis en sourdine", + "Channel name updated": "Nom du canal mis à jour", "Channel pinned": "Canal épinglé", "Channel unarchived": "Canal désarchivé", "Channel unmuted": "Canal retiré de la sourdine", "Channel unpinned": "Canal désépinglé", "Edit message request failed": "Échec de la modification du message", + "Failed to add members": "Échec de l’ajout des membres", "Failed to block user": "Échec du blocage de l’utilisateur", + "Failed to delete channel": "Échec de la suppression du canal", "Failed to leave channel": "Échec de la sortie du canal", + "Failed to load pinned messages": "Échec du chargement des messages épinglés", + "Failed to load users": "Échec du chargement des utilisateurs", "Failed to play the recording": "Échec de la lecture de l’enregistrement", "Failed to update channel archive status": "Échec de la mise à jour du statut d’archivage du canal", + "Failed to update channel image": "Échec de la mise à jour de l’image du canal", "Failed to update channel mute status": "Échec de la mise à jour du statut de mise en sourdine du canal", + "Failed to update channel name": "Échec de la mise à jour du nom du canal", "Failed to update channel pinned status": "Échec de la mise à jour du statut d’épinglage du canal", "Left channel": "Canal quitté", + "{{count}} members added_one": "{{count}} membre ajouté", + "{{count}} members added_other": "{{count}} membres ajoutés", + "{{count}} members added_many": "{{count}} membres ajoutés", "Recording format is not supported and cannot be reproduced": "Le format de l’enregistrement n’est pas pris en charge et ne peut pas être lu", "Send message request failed": "Échec de l’envoi du message", "User blocked": "Utilisateur bloqué", @@ -368,14 +410,52 @@ "size limit": "limite de taille", "unknown error": "erreur inconnue", "unsupported file type": "type de fichier non pris en charge", + "Already a member": "Déjà membre", + "Channel name": "Nom du canal", + "Edit Group Picture": "Modifier la photo du groupe", + "Choose Image": "Choisir une image", + "Take Photo": "Prendre une photo", + "Upload": "Téléverser", + "a11y/Channel name": "Nom du canal", + "a11y/Confirm edit channel": "Confirmer la modification du canal", + "a11y/Upload channel image": "Téléverser l'image du canal", + "Reset Picture": "Réinitialiser la photo", + "a11y/Close edit picture sheet": "Fermer la feuille de modification de la photo", + "Muted": "En sourdine", + "Failed to load members": "Échec du chargement des membres", + "Remove User": "Supprimer l'utilisateur", + "Remove": "Supprimer", + "Are you sure you want to remove this member from the channel?": "Êtes-vous sûr de vouloir supprimer ce membre du canal ?", + "{{count}} members removed_one": "{{count}} membre supprimé", + "{{count}} members removed_other": "{{count}} membres supprimés", + "{{count}} members removed_many": "{{count}} membres supprimés", + "Failed to remove members": "Échec de la suppression des membres", "a11y/Double tap and hold to activate contextual menu": "Appuyez deux fois et maintenez pour activer le menu contextuel", "a11y/Swipe right to go through different actions": "Glissez vers la droite pour parcourir les différentes actions", - "a11y/Close": "Close", "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.", "a11y/{{count}} unread messages": "{{count}} messages non lus", "a11y/Message from you": "Votre message", "a11y/Message from {{sender}}": "Message de {{sender}}", "a11y/Gallery Image": "Image de la galerie", "a11y/Gallery Video": "Vidéo de la galerie", - "a11y/{{position}} of {{count}}": "{{position}} sur {{count}}" + "a11y/{{position}} of {{count}}": "{{position}} sur {{count}}", + "No pinned messages": "Aucun message épinglé", + "a11y/Search pinned messages": "Rechercher des messages épinglés", + "Long-press a message to pin it to the chat": "Appuyez longuement sur un message pour l’épingler à la discussion", + "No photos or videos": "Aucune photo ni vidéo", + "Share a photo or video to see it here": "Partagez une photo ou une vidéo pour la voir ici", + "Failed to load media": "Échec du chargement des médias", + "No files": "Aucun fichier", + "Share a file to see it here": "Partagez un fichier pour le voir ici", + "Failed to load files": "Échec du chargement des fichiers", + "Notify all {{ role }} members": "Notify all {{ role }} members", + "a11y/Command suggestions available": "Command suggestions available", + "a11y/Emoji suggestions available": "Emoji suggestions available", + "a11y/Mention suggestions available": "Mention suggestions available", + "mention/Channel Description": "Notify everyone in this channel", + "mention/Here Description": "Notify every online member in this channel", + "Pin Chat": "Épingler la discussion", + "Pin Group": "Épingler le groupe", + "Unpin Chat": "Détacher la discussion", + "Unpin Group": "Détacher le groupe" } diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index 5ada05628d..417a18aca5 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -90,6 +90,7 @@ "No chats here yet…": "אין צ'אטים כאן עדיין...", "No items exist": "אין פריטים", "No threads here yet": "אין שרשורים כאן עדיין", + "No user found": "לא נמצא משתמש", "Not supported": "לא נתמך", "Nothing yet...": "אינפורמציה תתקבל בהמשך...", "Ok": "אוקיי", @@ -161,6 +162,7 @@ "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "הגיב ל", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[אתמול]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[מחר]\",\"nextWeek\":\"dddd [בשעה] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/FileAttachmentListSection": "{{ timestamp | timestampFormatter(format: MMMM YYYY) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -235,11 +237,16 @@ "Delete Group": "מחק/י קבוצה", "Leave Chat": "צא/י מהצ׳אט", "Leave Group": "צא/י מהקבוצה", + "Mute Chat": "השתק/י צ'אט", "Mute Group": "השתק/י קבוצה", + "Admin": "מנהל", + "Moderator": "מפקח", "Offline": "לא מחובר/ת", "Online": "מחובר/ת", + "Owner": "בעלים", "Unarchive Chat": "הוצא/י צ׳אט מהארכיון", "Unarchive Group": "הוצא/י קבוצה מהארכיון", + "Unmute Chat": "בטל/י השתקת צ'אט", "Unmute Group": "בטל/י השתקת קבוצה", "{{memberCount}} members, {{onlineCount}} online_one": "{{memberCount}} חבר/ה, {{onlineCount}} מחוברים", "{{memberCount}} members, {{onlineCount}} online_other": "{{memberCount}} חברים, {{onlineCount}} מחוברים", @@ -268,6 +275,7 @@ "a11y/Loading failed": "הטעינה נכשלה", "a11y/Message actions": "פעולות הודעה", "a11y/Muted": "מושתק", + "a11y/Pinned": "מוצמד", "a11y/New message from {{user}}": "הודעה חדשה מ-{{user}}", "a11y/Offline": "לא מקוון", "a11y/Open message actions": "פתח פעולות הודעה", @@ -293,10 +301,14 @@ "a11y/you reacted": "הגבת", "a11y/{{count}} new messages": "{{count}} הודעות חדשות", "a11y/Add attachment": "Add attachment", + "a11y/Add members": "הוספת חברים", + "a11y/Clear search": "ניקוי החיפוש", + "a11y/Close": "Close", "a11y/Close attachments": "Close attachments", "a11y/Remove attachment": "Remove Attachment", "a11y/Close poll": "Close poll", "a11y/Close poll creation": "Close poll creation", + "a11y/Confirm add members": "אישור הוספת חברים", "a11y/Create poll": "Create poll", "a11y/Decrease maximum votes": "Decrease maximum votes", "a11y/Delete voice recording": "Delete voice recording", @@ -319,11 +331,29 @@ "a11y/Select image": "Select image", "a11y/Select video": "Select video", "a11y/Send voice recording": "Send voice recording", + "a11y/Search users to add": "חיפוש משתמשים להוספה", + "a11y/Select {{name}}": "בחירת {{name}}", + "a11y/{{name}} is already a member": "{{name}} כבר חבר", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", "a11y/Stop voice recording": "Stop voice recording", + "Add": "הוסף", + "Add Members": "הוספת חברים", + "Contact Info": "פרטי איש קשר", + "Edit": "ערוך", + "Files": "קבצים", + "Group Info": "פרטי הקבוצה", + "timestamp/UserActivityStatus": "נראה לאחרונה {{ timestamp | fromNowFormatter }}", + "Photos & Videos": "תמונות וסרטונים", + "Pinned Messages": "הודעות מוצמדות", + "View all": "הצג הכל", + "{{count}} members_one": "חבר אחד", + "{{count}} members_other": "{{count}} חברים", + "{{count}} members_many": "{{count}} חברים", + "a11y/Back": "חזור", "a11y/Notifications": "התראות", "a11y/Dismiss notification": "סגור התראה", + "a11y/Edit channel": "ערוך ערוץ", "Attachment upload blocked due to {{reason}}": "העלאת הקובץ המצורף נחסמה עקב {{reason}}", "Attachment upload failed due to {{reason}}": "העלאת הקובץ המצורף נכשלה עקב {{reason}}", "Command not available": "הפקודה אינה זמינה", @@ -346,19 +376,31 @@ "Wait until all attachments have uploaded": "יש להמתין עד שכל הקבצים המצורפים יועלו", "Cannot seek in the recording": "לא ניתן לעבור למיקום אחר בהקלטה", "Channel archived": "השיחה הועברה לארכיון", + "Channel deleted": "השיחה נמחקה", + "Channel image updated": "תמונת השיחה עודכנה", "Channel muted": "השיחה הושתקה", + "Channel name updated": "שם השיחה עודכן", "Channel pinned": "השיחה ננעצה", "Channel unarchived": "השיחה הוצאה מהארכיון", "Channel unmuted": "השתקת השיחה בוטלה", "Channel unpinned": "נעיצת השיחה בוטלה", "Edit message request failed": "עריכת ההודעה נכשלה", + "Failed to add members": "הוספת החברים נכשלה", "Failed to block user": "חסימת המשתמש נכשלה", + "Failed to delete channel": "מחיקת השיחה נכשלה", "Failed to leave channel": "עזיבת השיחה נכשלה", + "Failed to load pinned messages": "טעינת ההודעות המוצמדות נכשלה", + "Failed to load users": "טעינת המשתמשים נכשלה", "Failed to play the recording": "הפעלת ההקלטה נכשלה", "Failed to update channel archive status": "עדכון מצב הארכיון של השיחה נכשל", + "Failed to update channel image": "עדכון תמונת השיחה נכשל", "Failed to update channel mute status": "עדכון מצב ההשתקה של השיחה נכשל", + "Failed to update channel name": "עדכון שם השיחה נכשל", "Failed to update channel pinned status": "עדכון מצב הנעיצה של השיחה נכשל", "Left channel": "עזבת את השיחה", + "{{count}} members added_one": "נוסף חבר אחד", + "{{count}} members added_other": "{{count}} חברים נוספו", + "{{count}} members added_many": "{{count}} חברים נוספו", "Recording format is not supported and cannot be reproduced": "פורמט ההקלטה אינו נתמך ולא ניתן להשמיע אותו", "Send message request failed": "שליחת ההודעה נכשלה", "User blocked": "המשתמש נחסם", @@ -368,14 +410,52 @@ "size limit": "מגבלת גודל", "unknown error": "שגיאה לא ידועה", "unsupported file type": "סוג קובץ לא נתמך", + "Already a member": "כבר חבר", + "Channel name": "שם הערוץ", + "Edit Group Picture": "עריכת תמונת הקבוצה", + "Choose Image": "בחירת תמונה", + "Take Photo": "צילום תמונה", + "Upload": "העלאה", + "a11y/Channel name": "שם הערוץ", + "a11y/Confirm edit channel": "אישור עריכת הערוץ", + "a11y/Upload channel image": "העלאת תמונת הערוץ", + "Reset Picture": "איפוס התמונה", + "a11y/Close edit picture sheet": "סגירת חלונית עריכת התמונה", + "Muted": "מושתק", + "Failed to load members": "טעינת החברים נכשלה", + "Remove User": "הסר משתמש", + "Remove": "הסר", + "Are you sure you want to remove this member from the channel?": "האם אתה בטוח שברצונך להסיר חבר זה מהערוץ?", + "{{count}} members removed_one": "חבר אחד הוסר", + "{{count}} members removed_other": "{{count}} חברים הוסרו", + "{{count}} members removed_many": "{{count}} חברים הוסרו", + "Failed to remove members": "הסרת החברים נכשלה", "a11y/Double tap and hold to activate contextual menu": "הקש פעמיים והחזק כדי להפעיל את התפריט ההקשרי", "a11y/Swipe right to go through different actions": "החלק ימינה כדי לעבור בין הפעולות השונות", - "a11y/Close": "Close", "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.", "a11y/{{count}} unread messages": "{{count}} הודעות שלא נקראו", "a11y/Message from you": "הודעה ממך", "a11y/Message from {{sender}}": "הודעה מאת {{sender}}", "a11y/Gallery Image": "תמונה מהגלריה", "a11y/Gallery Video": "סרטון מהגלריה", - "a11y/{{position}} of {{count}}": "{{position}} מתוך {{count}}" + "a11y/{{position}} of {{count}}": "{{position}} מתוך {{count}}", + "No pinned messages": "אין הודעות מוצמדות", + "a11y/Search pinned messages": "חיפוש הודעות מוצמדות", + "Long-press a message to pin it to the chat": "לחץ לחיצה ארוכה על הודעה כדי להצמיד אותה לצ'אט", + "No photos or videos": "אין תמונות או סרטונים", + "Share a photo or video to see it here": "שתפו תמונה או סרטון כדי לראות אותם כאן", + "Failed to load media": "טעינת המדיה נכשלה", + "No files": "אין קבצים", + "Share a file to see it here": "שתפו קובץ כדי לראות אותו כאן", + "Failed to load files": "טעינת הקבצים נכשלה", + "Notify all {{ role }} members": "Notify all {{ role }} members", + "a11y/Command suggestions available": "Command suggestions available", + "a11y/Emoji suggestions available": "Emoji suggestions available", + "a11y/Mention suggestions available": "Mention suggestions available", + "mention/Channel Description": "Notify everyone in this channel", + "mention/Here Description": "Notify every online member in this channel", + "Pin Chat": "הצמד/י צ'אט", + "Pin Group": "הצמד/י קבוצה", + "Unpin Chat": "בטל/י הצמדת צ'אט", + "Unpin Group": "בטל/י הצמדת קבוצה" } diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index 20e4cfb534..3e89355001 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -90,6 +90,7 @@ "No chats here yet…": "अभी तक यहाँ कोई चैट नहीं है...", "No items exist": "कोई आइटम मौजूद नहीं", "No threads here yet": "यहाँ अभी तक कोई थ्रेड्स नहीं हैं", + "No user found": "कोई उपयोगकर्ता नहीं मिला", "Not supported": "समर्थित नहीं", "Nothing yet...": "कोई मैसेज नहीं है...", "Ok": "ठीक", @@ -161,6 +162,7 @@ "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "को उत्तर दिया", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[कल]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[कल]\",\"nextWeek\":\"dddd [को] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/FileAttachmentListSection": "{{ timestamp | timestampFormatter(format: MMMM YYYY) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -235,11 +237,16 @@ "Delete Group": "ग्रुप हटाएं", "Leave Chat": "चैट छोड़ें", "Leave Group": "ग्रुप छोड़ें", + "Mute Chat": "चैट म्यूट करें", "Mute Group": "ग्रुप म्यूट करें", + "Admin": "एडमिन", + "Moderator": "मॉडरेटर", "Offline": "ऑफलाइन", "Online": "ऑनलाइन", + "Owner": "मालिक", "Unarchive Chat": "चैट को संग्रह से निकालें", "Unarchive Group": "ग्रुप को संग्रह से निकालें", + "Unmute Chat": "चैट अनम्यूट करें", "Unmute Group": "ग्रुप अनम्यूट करें", "{{memberCount}} members, {{onlineCount}} online_one": "{{memberCount}} सदस्य, {{onlineCount}} ऑनलाइन", "{{memberCount}} members, {{onlineCount}} online_other": "{{memberCount}} सदस्य, {{onlineCount}} ऑनलाइन", @@ -268,6 +275,7 @@ "a11y/Loading failed": "लोड नहीं हो सका", "a11y/Message actions": "संदेश की कार्रवाइयां", "a11y/Muted": "म्यूट किया गया", + "a11y/Pinned": "पिन किया गया", "a11y/New message from {{user}}": "{{user}} से नया संदेश", "a11y/Offline": "ऑफलाइन", "a11y/Open message actions": "संदेश की कार्रवाइयां खोलें", @@ -293,10 +301,14 @@ "a11y/you reacted": "आपने प्रतिक्रिया दी", "a11y/{{count}} new messages": "{{count}} नए संदेश", "a11y/Add attachment": "Add attachment", + "a11y/Add members": "सदस्य जोड़ें", + "a11y/Clear search": "खोज साफ़ करें", + "a11y/Close": "Close", "a11y/Close attachments": "Close attachments", "a11y/Remove attachment": "Remove Attachment", "a11y/Close poll": "Close poll", "a11y/Close poll creation": "Close poll creation", + "a11y/Confirm add members": "सदस्य जोड़ने की पुष्टि करें", "a11y/Create poll": "Create poll", "a11y/Decrease maximum votes": "Decrease maximum votes", "a11y/Delete voice recording": "Delete voice recording", @@ -319,11 +331,29 @@ "a11y/Select image": "Select image", "a11y/Select video": "Select video", "a11y/Send voice recording": "Send voice recording", + "a11y/Search users to add": "जोड़ने के लिए उपयोगकर्ता खोजें", + "a11y/Select {{name}}": "{{name}} चुनें", + "a11y/{{name}} is already a member": "{{name}} पहले से ही सदस्य है", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", "a11y/Stop voice recording": "Stop voice recording", + "Add": "जोड़ें", + "Add Members": "सदस्य जोड़ें", + "Contact Info": "संपर्क जानकारी", + "Edit": "संपादित करें", + "Files": "फ़ाइलें", + "Group Info": "ग्रुप की जानकारी", + "timestamp/UserActivityStatus": "अंतिम बार देखा गया {{ timestamp | fromNowFormatter }}", + "Photos & Videos": "फ़ोटो और वीडियो", + "Pinned Messages": "पिन किए गए संदेश", + "View all": "सभी देखें", + "{{count}} members_one": "{{count}} सदस्य", + "{{count}} members_other": "{{count}} सदस्य", + "{{count}} members_many": "{{count}} सदस्य", + "a11y/Back": "वापस", "a11y/Notifications": "सूचनाएं", "a11y/Dismiss notification": "सूचना हटाएं", + "a11y/Edit channel": "चैनल संपादित करें", "Attachment upload blocked due to {{reason}}": "{{reason}} के कारण अटैचमेंट अपलोड अवरुद्ध है", "Attachment upload failed due to {{reason}}": "{{reason}} के कारण अटैचमेंट अपलोड विफल रहा", "Command not available": "कमांड उपलब्ध नहीं है", @@ -346,19 +376,31 @@ "Wait until all attachments have uploaded": "सभी अटैचमेंट अपलोड होने तक प्रतीक्षा करें", "Cannot seek in the recording": "रिकॉर्डिंग में सीक नहीं किया जा सकता", "Channel archived": "चैनल आर्काइव किया गया", + "Channel deleted": "चैनल हटा दिया गया", + "Channel image updated": "चैनल इमेज अपडेट किया गया", "Channel muted": "चैनल म्यूट किया गया", + "Channel name updated": "चैनल का नाम अपडेट किया गया", "Channel pinned": "चैनल पिन किया गया", "Channel unarchived": "चैनल अनआर्काइव किया गया", "Channel unmuted": "चैनल अनम्यूट किया गया", "Channel unpinned": "चैनल से पिन हटाया गया", "Edit message request failed": "संदेश संपादित करने का अनुरोध विफल रहा", + "Failed to add members": "सदस्य जोड़ने में विफल", "Failed to block user": "उपयोगकर्ता को ब्लॉक करने में विफल", + "Failed to delete channel": "चैनल हटाने में विफल", "Failed to leave channel": "चैनल छोड़ने में विफल", + "Failed to load pinned messages": "पिन किए गए संदेश लोड करने में विफल", + "Failed to load users": "उपयोगकर्ता लोड करने में विफल", "Failed to play the recording": "रिकॉर्डिंग चलाने में विफल", "Failed to update channel archive status": "चैनल आर्काइव स्थिति अपडेट करने में विफल", + "Failed to update channel image": "चैनल इमेज अपडेट करने में विफल", "Failed to update channel mute status": "चैनल म्यूट स्थिति अपडेट करने में विफल", + "Failed to update channel name": "चैनल का नाम अपडेट करने में विफल", "Failed to update channel pinned status": "चैनल पिन स्थिति अपडेट करने में विफल", "Left channel": "चैनल छोड़ दिया", + "{{count}} members added_one": "{{count}} सदस्य जोड़ा गया", + "{{count}} members added_other": "{{count}} सदस्य जोड़े गए", + "{{count}} members added_many": "{{count}} सदस्य जोड़े गए", "Recording format is not supported and cannot be reproduced": "रिकॉर्डिंग फ़ॉर्मेट समर्थित नहीं है और इसे चलाया नहीं जा सकता", "Send message request failed": "संदेश भेजने का अनुरोध विफल रहा", "User blocked": "उपयोगकर्ता ब्लॉक किया गया", @@ -368,14 +410,52 @@ "size limit": "आकार सीमा", "unknown error": "अज्ञात त्रुटि", "unsupported file type": "असमर्थित फ़ाइल प्रकार", + "Already a member": "पहले से सदस्य", + "Channel name": "चैनल का नाम", + "Edit Group Picture": "समूह चित्र संपादित करें", + "Choose Image": "छवि चुनें", + "Take Photo": "फ़ोटो लें", + "Upload": "अपलोड करें", + "a11y/Channel name": "चैनल का नाम", + "a11y/Confirm edit channel": "चैनल संपादन की पुष्टि करें", + "a11y/Upload channel image": "चैनल छवि अपलोड करें", + "Reset Picture": "चित्र रीसेट करें", + "a11y/Close edit picture sheet": "चित्र संपादन शीट बंद करें", + "Muted": "म्यूट किया गया", + "Failed to load members": "सदस्यों को लोड करने में विफल", + "Remove User": "उपयोगकर्ता को हटाएं", + "Remove": "हटाएं", + "Are you sure you want to remove this member from the channel?": "क्या आप वाकई इस सदस्य को चैनल से हटाना चाहते हैं?", + "{{count}} members removed_one": "{{count}} सदस्य हटाया गया", + "{{count}} members removed_other": "{{count}} सदस्य हटाए गए", + "{{count}} members removed_many": "{{count}} सदस्य हटाए गए", + "Failed to remove members": "सदस्यों को हटाने में विफल", "a11y/Double tap and hold to activate contextual menu": "संदर्भ मेनू सक्रिय करने के लिए दो बार टैप करें और होल्ड करें", "a11y/Swipe right to go through different actions": "विभिन्न क्रियाओं के बीच जाने के लिए दाएं स्वाइप करें", - "a11y/Close": "Close", "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.", "a11y/{{count}} unread messages": "{{count}} अपठित संदेश", "a11y/Message from you": "आपका संदेश", "a11y/Message from {{sender}}": "{{sender}} से संदेश", "a11y/Gallery Image": "गैलरी छवि", "a11y/Gallery Video": "गैलरी वीडियो", - "a11y/{{position}} of {{count}}": "{{count}} में से {{position}}" + "a11y/{{position}} of {{count}}": "{{count}} में से {{position}}", + "No pinned messages": "कोई पिन किया गया संदेश नहीं", + "a11y/Search pinned messages": "पिन किए गए संदेश खोजें", + "Long-press a message to pin it to the chat": "किसी संदेश को चैट में पिन करने के लिए उसे देर तक दबाएँ", + "No photos or videos": "कोई फ़ोटो या वीडियो नहीं", + "Share a photo or video to see it here": "इसे यहाँ देखने के लिए कोई फ़ोटो या वीडियो शेयर करें", + "Failed to load media": "मीडिया लोड करने में विफल", + "No files": "कोई फ़ाइल नहीं", + "Share a file to see it here": "इसे यहाँ देखने के लिए कोई फ़ाइल शेयर करें", + "Failed to load files": "फ़ाइलें लोड करने में विफल", + "Notify all {{ role }} members": "Notify all {{ role }} members", + "a11y/Command suggestions available": "Command suggestions available", + "a11y/Emoji suggestions available": "Emoji suggestions available", + "a11y/Mention suggestions available": "Mention suggestions available", + "mention/Channel Description": "Notify everyone in this channel", + "mention/Here Description": "Notify every online member in this channel", + "Pin Chat": "चैट पिन करें", + "Pin Group": "ग्रुप पिन करें", + "Unpin Chat": "चैट अनपिन करें", + "Unpin Group": "ग्रुप अनपिन करें" } diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index e2d571d401..836e5ed082 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -90,6 +90,7 @@ "No chats here yet…": "Non ci sono ancora chat qui...", "No items exist": "Nessun elemento", "No threads here yet": "Nessun thread qui ancora", + "No user found": "Nessun utente trovato", "Not supported": "non supportato", "Nothing yet...": "Ancora niente...", "Ok": "Ok", @@ -161,6 +162,7 @@ "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "ha risposto a", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Ieri]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Domani]\",\"nextWeek\":\"dddd [alle] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/FileAttachmentListSection": "{{ timestamp | timestampFormatter(format: MMMM YYYY) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -235,11 +237,16 @@ "Delete Group": "Elimina gruppo", "Leave Chat": "Lascia chat", "Leave Group": "Lascia gruppo", + "Mute Chat": "Disattiva audio chat", "Mute Group": "Disattiva audio gruppo", + "Admin": "Amministratore", + "Moderator": "Moderatore", "Offline": "Offline", "Online": "Online", + "Owner": "Proprietario", "Unarchive Chat": "Rimuovi chat dall'archivio", "Unarchive Group": "Rimuovi gruppo dall'archivio", + "Unmute Chat": "Riattiva audio chat", "Unmute Group": "Riattiva audio gruppo", "{{memberCount}} members, {{onlineCount}} online_one": "{{memberCount}} membro, {{onlineCount}} online", "{{memberCount}} members, {{onlineCount}} online_other": "{{memberCount}} membri, {{onlineCount}} online", @@ -268,6 +275,7 @@ "a11y/Loading failed": "Caricamento non riuscito", "a11y/Message actions": "Azioni del messaggio", "a11y/Muted": "Silenziato", + "a11y/Pinned": "Fissato", "a11y/New message from {{user}}": "Nuovo messaggio da {{user}}", "a11y/Offline": "Offline", "a11y/Open message actions": "Apri azioni del messaggio", @@ -293,10 +301,14 @@ "a11y/you reacted": "hai reagito", "a11y/{{count}} new messages": "{{count}} nuovi messaggi", "a11y/Add attachment": "Add attachment", + "a11y/Add members": "Aggiungi membri", + "a11y/Clear search": "Cancella ricerca", + "a11y/Close": "Close", "a11y/Close attachments": "Close attachments", "a11y/Remove attachment": "Remove Attachment", "a11y/Close poll": "Close poll", "a11y/Close poll creation": "Close poll creation", + "a11y/Confirm add members": "Conferma aggiunta membri", "a11y/Create poll": "Create poll", "a11y/Decrease maximum votes": "Decrease maximum votes", "a11y/Delete voice recording": "Delete voice recording", @@ -319,11 +331,29 @@ "a11y/Select image": "Select image", "a11y/Select video": "Select video", "a11y/Send voice recording": "Send voice recording", + "a11y/Search users to add": "Cerca utenti da aggiungere", + "a11y/Select {{name}}": "Seleziona {{name}}", + "a11y/{{name}} is already a member": "{{name}} è già un membro", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", "a11y/Stop voice recording": "Stop voice recording", + "Add": "Aggiungi", + "Add Members": "Aggiungi membri", + "Contact Info": "Informazioni di contatto", + "Edit": "Modifica", + "Files": "File", + "Group Info": "Informazioni del gruppo", + "timestamp/UserActivityStatus": "Ultimo accesso {{ timestamp | fromNowFormatter }}", + "Photos & Videos": "Foto e video", + "Pinned Messages": "Messaggi in evidenza", + "View all": "Vedi tutto", + "{{count}} members_one": "{{count}} membro", + "{{count}} members_other": "{{count}} membri", + "{{count}} members_many": "{{count}} membri", + "a11y/Back": "Indietro", "a11y/Notifications": "Notifiche", "a11y/Dismiss notification": "Chiudi notifica", + "a11y/Edit channel": "Modifica canale", "Attachment upload blocked due to {{reason}}": "Caricamento dell'allegato bloccato a causa di {{reason}}", "Attachment upload failed due to {{reason}}": "Caricamento dell'allegato non riuscito a causa di {{reason}}", "Command not available": "Comando non disponibile", @@ -346,19 +376,31 @@ "Wait until all attachments have uploaded": "Attendi il caricamento di tutti gli allegati", "Cannot seek in the recording": "Impossibile spostarsi nella registrazione", "Channel archived": "Canale archiviato", + "Channel deleted": "Canale eliminato", + "Channel image updated": "Immagine del canale aggiornata", "Channel muted": "Canale silenziato", + "Channel name updated": "Nome del canale aggiornato", "Channel pinned": "Canale fissato", "Channel unarchived": "Canale rimosso dall'archivio", "Channel unmuted": "Canale non più silenziato", "Channel unpinned": "Canale non più fissato", "Edit message request failed": "Richiesta di modifica del messaggio non riuscita", + "Failed to add members": "Impossibile aggiungere membri", "Failed to block user": "Impossibile bloccare l'utente", + "Failed to delete channel": "Impossibile eliminare il canale", "Failed to leave channel": "Impossibile lasciare il canale", + "Failed to load pinned messages": "Impossibile caricare i messaggi fissati", + "Failed to load users": "Impossibile caricare gli utenti", "Failed to play the recording": "Impossibile riprodurre la registrazione", "Failed to update channel archive status": "Impossibile aggiornare lo stato di archiviazione del canale", + "Failed to update channel image": "Impossibile aggiornare l'immagine del canale", "Failed to update channel mute status": "Impossibile aggiornare lo stato di silenziamento del canale", + "Failed to update channel name": "Impossibile aggiornare il nome del canale", "Failed to update channel pinned status": "Impossibile aggiornare lo stato di fissaggio del canale", "Left channel": "Canale lasciato", + "{{count}} members added_one": "{{count}} membro aggiunto", + "{{count}} members added_other": "{{count}} membri aggiunti", + "{{count}} members added_many": "{{count}} membri aggiunti", "Recording format is not supported and cannot be reproduced": "Il formato della registrazione non è supportato e non può essere riprodotto", "Send message request failed": "Richiesta di invio del messaggio non riuscita", "User blocked": "Utente bloccato", @@ -368,14 +410,52 @@ "size limit": "limite di dimensione", "unknown error": "errore sconosciuto", "unsupported file type": "tipo di file non supportato", + "Already a member": "Già membro", + "Channel name": "Nome del canale", + "Edit Group Picture": "Modifica immagine del gruppo", + "Choose Image": "Scegli immagine", + "Take Photo": "Scatta foto", + "Upload": "Carica", + "a11y/Channel name": "Nome del canale", + "a11y/Confirm edit channel": "Conferma modifica del canale", + "a11y/Upload channel image": "Carica immagine del canale", + "Reset Picture": "Reimposta immagine", + "a11y/Close edit picture sheet": "Chiudi il pannello di modifica immagine", + "Muted": "Silenziato", + "Failed to load members": "Impossibile caricare i membri", + "Remove User": "Rimuovi utente", + "Remove": "Rimuovi", + "Are you sure you want to remove this member from the channel?": "Sei sicuro di voler rimuovere questo membro dal canale?", + "{{count}} members removed_one": "{{count}} membro rimosso", + "{{count}} members removed_other": "{{count}} membri rimossi", + "{{count}} members removed_many": "{{count}} membri rimossi", + "Failed to remove members": "Impossibile rimuovere i membri", "a11y/Double tap and hold to activate contextual menu": "Tocca due volte e tieni premuto per attivare il menu contestuale", "a11y/Swipe right to go through different actions": "Scorri a destra per passare in rassegna le diverse azioni", - "a11y/Close": "Close", "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.", "a11y/{{count}} unread messages": "{{count}} messaggi non letti", "a11y/Message from you": "Messaggio da te", "a11y/Message from {{sender}}": "Messaggio da {{sender}}", "a11y/Gallery Image": "Immagine della galleria", "a11y/Gallery Video": "Video della galleria", - "a11y/{{position}} of {{count}}": "{{position}} di {{count}}" + "a11y/{{position}} of {{count}}": "{{position}} di {{count}}", + "No pinned messages": "Nessun messaggio in evidenza", + "a11y/Search pinned messages": "Cerca messaggi in evidenza", + "Long-press a message to pin it to the chat": "Tieni premuto un messaggio per fissarlo nella chat", + "No photos or videos": "Nessuna foto o video", + "Share a photo or video to see it here": "Condividi una foto o un video per vederlo qui", + "Failed to load media": "Caricamento dei file multimediali non riuscito", + "No files": "Nessun file", + "Share a file to see it here": "Condividi un file per vederlo qui", + "Failed to load files": "Caricamento dei file non riuscito", + "Notify all {{ role }} members": "Notify all {{ role }} members", + "a11y/Command suggestions available": "Command suggestions available", + "a11y/Emoji suggestions available": "Emoji suggestions available", + "a11y/Mention suggestions available": "Mention suggestions available", + "mention/Channel Description": "Notify everyone in this channel", + "mention/Here Description": "Notify every online member in this channel", + "Pin Chat": "Fissa chat", + "Pin Group": "Fissa gruppo", + "Unpin Chat": "Sfissa chat", + "Unpin Group": "Sfissa gruppo" } diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index da5fa54df9..0eaf022c44 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -90,6 +90,7 @@ "No chats here yet…": "まだチャットはありません…", "No items exist": "項目がありません", "No threads here yet": "まだスレッドがありません", + "No user found": "ユーザーが見つかりません", "Not supported": "サポートしていません", "Nothing yet...": "まだ何もありません...", "Ok": "確認", @@ -161,6 +162,7 @@ "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "に返信しました", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[昨日]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[明日]\",\"nextWeek\":\"dddd [の] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/FileAttachmentListSection": "{{ timestamp | timestampFormatter(format: MMMM YYYY) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -235,11 +237,16 @@ "Delete Group": "グループを削除", "Leave Chat": "チャットを退出", "Leave Group": "グループを退出", + "Mute Chat": "チャットをミュート", "Mute Group": "グループをミュート", + "Admin": "管理者", + "Moderator": "モデレーター", "Offline": "オフライン", "Online": "オンライン", + "Owner": "オーナー", "Unarchive Chat": "チャットのアーカイブを解除", "Unarchive Group": "グループのアーカイブを解除", + "Unmute Chat": "チャットのミュートを解除", "Unmute Group": "グループのミュートを解除", "{{memberCount}} members, {{onlineCount}} online_one": "{{memberCount}}人のメンバー、{{onlineCount}}人がオンライン", "{{memberCount}} members, {{onlineCount}} online_other": "{{memberCount}}人のメンバー、{{onlineCount}}人がオンライン", @@ -268,6 +275,7 @@ "a11y/Loading failed": "読み込みに失敗しました", "a11y/Message actions": "メッセージの操作", "a11y/Muted": "ミュート中", + "a11y/Pinned": "ピン留め中", "a11y/New message from {{user}}": "{{user}}からの新しいメッセージ", "a11y/Offline": "オフライン", "a11y/Open message actions": "メッセージの操作を開く", @@ -293,10 +301,14 @@ "a11y/you reacted": "あなたがリアクション", "a11y/{{count}} new messages": "新しいメッセージ{{count}}件", "a11y/Add attachment": "Add attachment", + "a11y/Add members": "メンバーを追加", + "a11y/Clear search": "検索をクリア", + "a11y/Close": "Close", "a11y/Close attachments": "Close attachments", "a11y/Remove attachment": "Remove Attachment", "a11y/Close poll": "Close poll", "a11y/Close poll creation": "Close poll creation", + "a11y/Confirm add members": "メンバーの追加を確定", "a11y/Create poll": "Create poll", "a11y/Decrease maximum votes": "Decrease maximum votes", "a11y/Delete voice recording": "Delete voice recording", @@ -319,11 +331,29 @@ "a11y/Select image": "Select image", "a11y/Select video": "Select video", "a11y/Send voice recording": "Send voice recording", + "a11y/Search users to add": "追加するユーザーを検索", + "a11y/Select {{name}}": "{{name}}を選択", + "a11y/{{name}} is already a member": "{{name}}は既にメンバーです", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", "a11y/Stop voice recording": "Stop voice recording", + "Add": "追加", + "Add Members": "メンバーを追加", + "Contact Info": "連絡先情報", + "Edit": "編集", + "Files": "ファイル", + "Group Info": "グループ情報", + "timestamp/UserActivityStatus": "最終ログイン {{ timestamp | fromNowFormatter }}", + "Photos & Videos": "写真と動画", + "Pinned Messages": "ピン留めされたメッセージ", + "View all": "すべて表示", + "{{count}} members_one": "{{count}}人のメンバー", + "{{count}} members_other": "{{count}}人のメンバー", + "{{count}} members_many": "{{count}}人のメンバー", + "a11y/Back": "戻る", "a11y/Notifications": "通知", "a11y/Dismiss notification": "通知を閉じる", + "a11y/Edit channel": "チャンネルを編集", "Attachment upload blocked due to {{reason}}": "{{reason}} のため添付ファイルのアップロードがブロックされました", "Attachment upload failed due to {{reason}}": "{{reason}} のため添付ファイルのアップロードに失敗しました", "Command not available": "コマンドは利用できません", @@ -346,19 +376,31 @@ "Wait until all attachments have uploaded": "すべての添付ファイルのアップロードが完了するまでお待ちください", "Cannot seek in the recording": "録音内をシークできません", "Channel archived": "チャンネルをアーカイブしました", + "Channel deleted": "チャンネルを削除しました", + "Channel image updated": "チャンネルの画像を更新しました", "Channel muted": "チャンネルをミュートしました", + "Channel name updated": "チャンネルの名前を更新しました", "Channel pinned": "チャンネルをピン留めしました", "Channel unarchived": "チャンネルのアーカイブを解除しました", "Channel unmuted": "チャンネルのミュートを解除しました", "Channel unpinned": "チャンネルのピン留めを解除しました", "Edit message request failed": "メッセージの編集リクエストに失敗しました", + "Failed to add members": "メンバーの追加に失敗しました", "Failed to block user": "ユーザーのブロックに失敗しました", + "Failed to delete channel": "チャンネルの削除に失敗しました", "Failed to leave channel": "チャンネルの退出に失敗しました", + "Failed to load pinned messages": "ピン留めされたメッセージの読み込みに失敗しました", + "Failed to load users": "ユーザーの読み込みに失敗しました", "Failed to play the recording": "録音の再生に失敗しました", "Failed to update channel archive status": "チャンネルのアーカイブ状態の更新に失敗しました", + "Failed to update channel image": "チャンネルの画像の更新に失敗しました", "Failed to update channel mute status": "チャンネルのミュート状態の更新に失敗しました", + "Failed to update channel name": "チャンネルの名前の更新に失敗しました", "Failed to update channel pinned status": "チャンネルのピン留め状態の更新に失敗しました", "Left channel": "チャンネルから退出しました", + "{{count}} members added_one": "{{count}}人のメンバーを追加しました", + "{{count}} members added_other": "{{count}}人のメンバーを追加しました", + "{{count}} members added_many": "{{count}}人のメンバーを追加しました", "Recording format is not supported and cannot be reproduced": "録音形式がサポートされていないため再生できません", "Send message request failed": "メッセージの送信リクエストに失敗しました", "User blocked": "ユーザーをブロックしました", @@ -368,14 +410,52 @@ "size limit": "サイズ制限", "unknown error": "不明なエラー", "unsupported file type": "サポートされていないファイル形式", + "Already a member": "既にメンバー", + "Channel name": "チャンネル名", + "Edit Group Picture": "グループの画像を編集", + "Choose Image": "画像を選択", + "Take Photo": "写真を撮る", + "Upload": "アップロード", + "a11y/Channel name": "チャンネル名", + "a11y/Confirm edit channel": "チャンネルの編集を確定", + "a11y/Upload channel image": "チャンネル画像をアップロード", + "Reset Picture": "画像をリセット", + "a11y/Close edit picture sheet": "画像編集シートを閉じる", + "Muted": "ミュート中", + "Failed to load members": "メンバーの読み込みに失敗しました", + "Remove User": "ユーザーを削除する", + "Remove": "削除", + "Are you sure you want to remove this member from the channel?": "このメンバーをチャンネルから削除してもよろしいですか?", + "{{count}} members removed_one": "{{count}}人のメンバーを削除しました", + "{{count}} members removed_other": "{{count}}人のメンバーを削除しました", + "{{count}} members removed_many": "{{count}}人のメンバーを削除しました", + "Failed to remove members": "メンバーの削除に失敗しました", "a11y/Double tap and hold to activate contextual menu": "コンテキストメニューを表示するにはダブルタップして長押し", "a11y/Swipe right to go through different actions": "右にスワイプして異なるアクションを切り替えます", - "a11y/Close": "Close", "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.", "a11y/{{count}} unread messages": "未読メッセージ{{count}}件", "a11y/Message from you": "あなたからのメッセージ", "a11y/Message from {{sender}}": "{{sender}}からのメッセージ", "a11y/Gallery Image": "ギャラリー画像", "a11y/Gallery Video": "ギャラリービデオ", - "a11y/{{position}} of {{count}}": "{{count}} 中 {{position}}" + "a11y/{{position}} of {{count}}": "{{count}} 中 {{position}}", + "No pinned messages": "ピン留めされたメッセージはありません", + "a11y/Search pinned messages": "ピン留めされたメッセージを検索", + "Long-press a message to pin it to the chat": "メッセージを長押ししてチャットにピン留めします", + "No photos or videos": "写真や動画はありません", + "Share a photo or video to see it here": "写真や動画を共有すると、ここに表示されます", + "Failed to load media": "メディアの読み込みに失敗しました", + "No files": "ファイルがありません", + "Share a file to see it here": "ファイルを共有すると、ここに表示されます", + "Failed to load files": "ファイルの読み込みに失敗しました", + "Notify all {{ role }} members": "Notify all {{ role }} members", + "a11y/Command suggestions available": "Command suggestions available", + "a11y/Emoji suggestions available": "Emoji suggestions available", + "a11y/Mention suggestions available": "Mention suggestions available", + "mention/Channel Description": "Notify everyone in this channel", + "mention/Here Description": "Notify every online member in this channel", + "Pin Chat": "チャットをピン留め", + "Pin Group": "グループをピン留め", + "Unpin Chat": "チャットのピン留めを解除", + "Unpin Group": "グループのピン留めを解除" } diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index 1bd28ddeba..69a3ac277f 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -90,6 +90,7 @@ "No chats here yet…": "아직 여기에 채팅이 없어요…", "No items exist": "항목이 없습니다", "No threads here yet": "아직 스레드가 없습니다", + "No user found": "사용자를 찾을 수 없습니다", "Not supported": "지원하지 않습니다", "Nothing yet...": "아직 아무것도...", "Ok": "확인", @@ -161,6 +162,7 @@ "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "에 답장했습니다", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[어제]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[내일]\",\"nextWeek\":\"dddd [LT에]\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/FileAttachmentListSection": "{{ timestamp | timestampFormatter(format: MMMM YYYY) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -235,11 +237,16 @@ "Delete Group": "그룹 삭제", "Leave Chat": "채팅 나가기", "Leave Group": "그룹 나가기", + "Mute Chat": "채팅 음소거", "Mute Group": "그룹 음소거", + "Admin": "관리자", + "Moderator": "중재자", "Offline": "오프라인", "Online": "온라인", + "Owner": "소유자", "Unarchive Chat": "채팅 보관 해제", "Unarchive Group": "그룹 보관 해제", + "Unmute Chat": "채팅 음소거 해제", "Unmute Group": "그룹 음소거 해제", "{{memberCount}} members, {{onlineCount}} online_one": "{{memberCount}}명, {{onlineCount}}명 온라인", "{{memberCount}} members, {{onlineCount}} online_other": "{{memberCount}}명, {{onlineCount}}명 온라인", @@ -268,6 +275,7 @@ "a11y/Loading failed": "로드 실패", "a11y/Message actions": "메시지 작업", "a11y/Muted": "음소거됨", + "a11y/Pinned": "고정됨", "a11y/New message from {{user}}": "{{user}}님의 새 메시지", "a11y/Offline": "오프라인", "a11y/Open message actions": "메시지 작업 열기", @@ -293,10 +301,14 @@ "a11y/you reacted": "내가 반응함", "a11y/{{count}} new messages": "새 메시지 {{count}}개", "a11y/Add attachment": "Add attachment", + "a11y/Add members": "멤버 추가", + "a11y/Clear search": "검색 지우기", + "a11y/Close": "Close", "a11y/Close attachments": "Close attachments", "a11y/Remove attachment": "Remove Attachment", "a11y/Close poll": "Close poll", "a11y/Close poll creation": "Close poll creation", + "a11y/Confirm add members": "멤버 추가 확인", "a11y/Create poll": "Create poll", "a11y/Decrease maximum votes": "Decrease maximum votes", "a11y/Delete voice recording": "Delete voice recording", @@ -319,11 +331,29 @@ "a11y/Select image": "Select image", "a11y/Select video": "Select video", "a11y/Send voice recording": "Send voice recording", + "a11y/Search users to add": "추가할 사용자 검색", + "a11y/Select {{name}}": "{{name}} 선택", + "a11y/{{name}} is already a member": "{{name}}님은 이미 멤버입니다", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", "a11y/Stop voice recording": "Stop voice recording", + "Add": "추가", + "Add Members": "멤버 추가", + "Contact Info": "연락처 정보", + "Edit": "편집", + "Files": "파일", + "Group Info": "그룹 정보", + "timestamp/UserActivityStatus": "마지막 접속 {{ timestamp | fromNowFormatter }}", + "Photos & Videos": "사진 및 동영상", + "Pinned Messages": "고정된 메시지", + "View all": "모두 보기", + "{{count}} members_one": "멤버 {{count}}명", + "{{count}} members_other": "멤버 {{count}}명", + "{{count}} members_many": "멤버 {{count}}명", + "a11y/Back": "뒤로", "a11y/Notifications": "알림", "a11y/Dismiss notification": "알림 닫기", + "a11y/Edit channel": "채널 편집", "Attachment upload blocked due to {{reason}}": "{{reason}} 때문에 첨부 파일 업로드가 차단되었습니다", "Attachment upload failed due to {{reason}}": "{{reason}} 때문에 첨부 파일 업로드에 실패했습니다", "Command not available": "명령을 사용할 수 없습니다", @@ -346,19 +376,31 @@ "Wait until all attachments have uploaded": "모든 첨부 파일이 업로드될 때까지 기다리세요", "Cannot seek in the recording": "녹음에서 이동할 수 없습니다", "Channel archived": "채널이 보관되었습니다", + "Channel deleted": "채널이 삭제되었습니다", + "Channel image updated": "채널 이미지가 업데이트되었습니다", "Channel muted": "채널이 음소거되었습니다", + "Channel name updated": "채널 이름이 업데이트되었습니다", "Channel pinned": "채널이 고정되었습니다", "Channel unarchived": "채널 보관이 해제되었습니다", "Channel unmuted": "채널 음소거가 해제되었습니다", "Channel unpinned": "채널 고정이 해제되었습니다", "Edit message request failed": "메시지 수정 요청이 실패했습니다", + "Failed to add members": "멤버 추가에 실패했습니다", "Failed to block user": "사용자 차단에 실패했습니다", + "Failed to delete channel": "채널 삭제에 실패했습니다", "Failed to leave channel": "채널 나가기에 실패했습니다", + "Failed to load pinned messages": "고정된 메시지를 불러오지 못했습니다", + "Failed to load users": "사용자 불러오기에 실패했습니다", "Failed to play the recording": "녹음 재생에 실패했습니다", "Failed to update channel archive status": "채널 보관 상태 업데이트에 실패했습니다", + "Failed to update channel image": "채널 이미지 업데이트에 실패했습니다", "Failed to update channel mute status": "채널 음소거 상태 업데이트에 실패했습니다", + "Failed to update channel name": "채널 이름 업데이트에 실패했습니다", "Failed to update channel pinned status": "채널 고정 상태 업데이트에 실패했습니다", "Left channel": "채널에서 나갔습니다", + "{{count}} members added_one": "{{count}}명의 멤버가 추가되었습니다", + "{{count}} members added_other": "{{count}}명의 멤버가 추가되었습니다", + "{{count}} members added_many": "{{count}}명의 멤버가 추가되었습니다", "Recording format is not supported and cannot be reproduced": "녹음 형식이 지원되지 않아 재생할 수 없습니다", "Send message request failed": "메시지 보내기 요청이 실패했습니다", "User blocked": "사용자가 차단되었습니다", @@ -368,14 +410,52 @@ "size limit": "크기 제한", "unknown error": "알 수 없는 오류", "unsupported file type": "지원되지 않는 파일 형식", + "Already a member": "이미 멤버", + "Channel name": "채널 이름", + "Edit Group Picture": "그룹 사진 편집", + "Choose Image": "이미지 선택", + "Take Photo": "사진 촬영", + "Upload": "업로드", + "a11y/Channel name": "채널 이름", + "a11y/Confirm edit channel": "채널 편집 확인", + "a11y/Upload channel image": "채널 이미지 업로드", + "Reset Picture": "사진 초기화", + "a11y/Close edit picture sheet": "사진 편집 시트 닫기", + "Muted": "음소거됨", + "Failed to load members": "멤버 불러오기에 실패했습니다", + "Remove User": "사용자 삭제", + "Remove": "삭제", + "Are you sure you want to remove this member from the channel?": "이 멤버를 채널에서 삭제하시겠습니까?", + "{{count}} members removed_one": "{{count}}명의 멤버가 삭제되었습니다", + "{{count}} members removed_other": "{{count}}명의 멤버가 삭제되었습니다", + "{{count}} members removed_many": "{{count}}명의 멤버가 삭제되었습니다", + "Failed to remove members": "멤버 삭제에 실패했습니다", "a11y/Double tap and hold to activate contextual menu": "컨텍스트 메뉴를 활성화하려면 두 번 탭하고 길게 누르세요", "a11y/Swipe right to go through different actions": "다른 작업을 탐색하려면 오른쪽으로 스와이프하세요", - "a11y/Close": "Close", "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.", "a11y/{{count}} unread messages": "읽지 않은 메시지 {{count}}개", "a11y/Message from you": "내가 보낸 메시지", "a11y/Message from {{sender}}": "{{sender}}님의 메시지", "a11y/Gallery Image": "갤러리 이미지", "a11y/Gallery Video": "갤러리 동영상", - "a11y/{{position}} of {{count}}": "{{count}}개 중 {{position}}번째" + "a11y/{{position}} of {{count}}": "{{count}}개 중 {{position}}번째", + "No pinned messages": "고정된 메시지가 없습니다", + "a11y/Search pinned messages": "고정된 메시지 검색", + "Long-press a message to pin it to the chat": "메시지를 길게 눌러 채팅에 고정하세요", + "No photos or videos": "사진 또는 동영상 없음", + "Share a photo or video to see it here": "사진이나 동영상을 공유하면 여기에 표시됩니다", + "Failed to load media": "미디어를 불러오지 못했습니다", + "No files": "파일 없음", + "Share a file to see it here": "파일을 공유하면 여기에 표시됩니다", + "Failed to load files": "파일을 불러오지 못했습니다", + "Notify all {{ role }} members": "Notify all {{ role }} members", + "a11y/Command suggestions available": "Command suggestions available", + "a11y/Emoji suggestions available": "Emoji suggestions available", + "a11y/Mention suggestions available": "Mention suggestions available", + "mention/Channel Description": "Notify everyone in this channel", + "mention/Here Description": "Notify every online member in this channel", + "Pin Chat": "채팅 고정", + "Pin Group": "그룹 고정", + "Unpin Chat": "채팅 고정 해제", + "Unpin Group": "그룹 고정 해제" } diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index f128f4fb1d..0a25467b5d 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -90,6 +90,7 @@ "No chats here yet…": "Nog geen chats hier…", "No items exist": "Er zijn geen items", "No threads here yet": "Hier zijn nog geen threads", + "No user found": "Geen gebruiker gevonden", "Not supported": "niet ondersteund", "Nothing yet...": "Nog niets...", "Ok": "Oké", @@ -161,6 +162,7 @@ "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "reageerde op", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Gisteren]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Morgen]\",\"nextWeek\":\"dddd [om] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/FileAttachmentListSection": "{{ timestamp | timestampFormatter(format: MMMM YYYY) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -235,11 +237,16 @@ "Delete Group": "Groep verwijderen", "Leave Chat": "Chat verlaten", "Leave Group": "Groep verlaten", + "Mute Chat": "Chat dempen", "Mute Group": "Groep dempen", + "Admin": "Beheerder", + "Moderator": "Moderator", "Offline": "Offline", "Online": "Online", + "Owner": "Eigenaar", "Unarchive Chat": "Chat uit archief halen", "Unarchive Group": "Groep uit archief halen", + "Unmute Chat": "Dempen van chat opheffen", "Unmute Group": "Dempen van groep opheffen", "{{memberCount}} members, {{onlineCount}} online_one": "{{memberCount}} lid, {{onlineCount}} online", "{{memberCount}} members, {{onlineCount}} online_other": "{{memberCount}} leden, {{onlineCount}} online", @@ -268,6 +275,7 @@ "a11y/Loading failed": "Laden mislukt", "a11y/Message actions": "Berichtacties", "a11y/Muted": "Gedempt", + "a11y/Pinned": "Vastgemaakt", "a11y/New message from {{user}}": "Nieuw bericht van {{user}}", "a11y/Offline": "Offline", "a11y/Open message actions": "Berichtacties openen", @@ -293,10 +301,14 @@ "a11y/you reacted": "jij hebt gereageerd", "a11y/{{count}} new messages": "{{count}} nieuwe berichten", "a11y/Add attachment": "Add attachment", + "a11y/Add members": "Leden toevoegen", + "a11y/Clear search": "Zoekopdracht wissen", + "a11y/Close": "Close", "a11y/Close attachments": "Close attachments", "a11y/Remove attachment": "Remove Attachment", "a11y/Close poll": "Close poll", "a11y/Close poll creation": "Close poll creation", + "a11y/Confirm add members": "Leden toevoegen bevestigen", "a11y/Create poll": "Create poll", "a11y/Decrease maximum votes": "Decrease maximum votes", "a11y/Delete voice recording": "Delete voice recording", @@ -319,11 +331,29 @@ "a11y/Select image": "Select image", "a11y/Select video": "Select video", "a11y/Send voice recording": "Send voice recording", + "a11y/Search users to add": "Zoek gebruikers om toe te voegen", + "a11y/Select {{name}}": "{{name}} selecteren", + "a11y/{{name}} is already a member": "{{name}} is al lid", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", "a11y/Stop voice recording": "Stop voice recording", + "Add": "Toevoegen", + "Add Members": "Leden toevoegen", + "Contact Info": "Contactgegevens", + "Edit": "Bewerken", + "Files": "Bestanden", + "Group Info": "Groepsinformatie", + "timestamp/UserActivityStatus": "Laatst gezien {{ timestamp | fromNowFormatter }}", + "Photos & Videos": "Foto's en video's", + "Pinned Messages": "Vastgemaakte berichten", + "View all": "Alles bekijken", + "{{count}} members_one": "{{count}} lid", + "{{count}} members_other": "{{count}} leden", + "{{count}} members_many": "{{count}} leden", + "a11y/Back": "Terug", "a11y/Notifications": "Meldingen", "a11y/Dismiss notification": "Melding sluiten", + "a11y/Edit channel": "Kanaal bewerken", "Attachment upload blocked due to {{reason}}": "Uploaden van bijlage geblokkeerd vanwege {{reason}}", "Attachment upload failed due to {{reason}}": "Uploaden van bijlage mislukt vanwege {{reason}}", "Command not available": "Opdracht niet beschikbaar", @@ -346,19 +376,31 @@ "Wait until all attachments have uploaded": "Wacht tot alle bijlagen zijn geüpload", "Cannot seek in the recording": "Kan niet spoelen in de opname", "Channel archived": "Kanaal gearchiveerd", + "Channel deleted": "Kanaal verwijderd", + "Channel image updated": "Kanaalafbeelding bijgewerkt", "Channel muted": "Kanaal gedempt", + "Channel name updated": "Kanaalnaam bijgewerkt", "Channel pinned": "Kanaal vastgezet", "Channel unarchived": "Kanaal uit archief gehaald", "Channel unmuted": "Kanaal niet meer gedempt", "Channel unpinned": "Kanaal losgemaakt", "Edit message request failed": "Verzoek om bericht te bewerken mislukt", + "Failed to add members": "Leden toevoegen mislukt", "Failed to block user": "Gebruiker blokkeren mislukt", + "Failed to delete channel": "Kanaal verwijderen mislukt", "Failed to leave channel": "Kanaal verlaten mislukt", + "Failed to load pinned messages": "Vastgemaakte berichten laden mislukt", + "Failed to load users": "Gebruikers laden mislukt", "Failed to play the recording": "Opname afspelen mislukt", "Failed to update channel archive status": "Bijwerken van kanaalarchiefstatus mislukt", + "Failed to update channel image": "Bijwerken van kanaalafbeelding mislukt", "Failed to update channel mute status": "Bijwerken van dempstatus van kanaal mislukt", + "Failed to update channel name": "Bijwerken van kanaalnaam mislukt", "Failed to update channel pinned status": "Bijwerken van vastzetstatus van kanaal mislukt", "Left channel": "Kanaal verlaten", + "{{count}} members added_one": "{{count}} lid toegevoegd", + "{{count}} members added_other": "{{count}} leden toegevoegd", + "{{count}} members added_many": "{{count}} leden toegevoegd", "Recording format is not supported and cannot be reproduced": "Opnameformaat wordt niet ondersteund en kan niet worden afgespeeld", "Send message request failed": "Verzoek om bericht te verzenden mislukt", "User blocked": "Gebruiker geblokkeerd", @@ -368,14 +410,52 @@ "size limit": "groottelimiet", "unknown error": "onbekende fout", "unsupported file type": "niet-ondersteund bestandstype", + "Already a member": "Al lid", + "Channel name": "Kanaalnaam", + "Edit Group Picture": "Groepsafbeelding bewerken", + "Choose Image": "Afbeelding kiezen", + "Take Photo": "Foto maken", + "Upload": "Uploaden", + "a11y/Channel name": "Kanaalnaam", + "a11y/Confirm edit channel": "Kanaal bewerken bevestigen", + "a11y/Upload channel image": "Kanaalafbeelding uploaden", + "Reset Picture": "Afbeelding herstellen", + "a11y/Close edit picture sheet": "Afbeelding bewerken-venster sluiten", + "Muted": "Gedempt", + "Failed to load members": "Leden laden mislukt", + "Remove User": "Gebruiker verwijderen", + "Remove": "Verwijderen", + "Are you sure you want to remove this member from the channel?": "Weet je zeker dat je dit lid uit het kanaal wilt verwijderen?", + "{{count}} members removed_one": "{{count}} lid verwijderd", + "{{count}} members removed_other": "{{count}} leden verwijderd", + "{{count}} members removed_many": "{{count}} leden verwijderd", + "Failed to remove members": "Leden verwijderen mislukt", "a11y/Double tap and hold to activate contextual menu": "Dubbeltik en houd vast om het contextmenu te openen", "a11y/Swipe right to go through different actions": "Veeg naar rechts om door verschillende acties te bladeren", - "a11y/Close": "Close", "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.", "a11y/{{count}} unread messages": "{{count}} ongelezen berichten", "a11y/Message from you": "Bericht van jou", "a11y/Message from {{sender}}": "Bericht van {{sender}}", "a11y/Gallery Image": "Galerij-afbeelding", "a11y/Gallery Video": "Galerij-video", - "a11y/{{position}} of {{count}}": "{{position}} van {{count}}" + "a11y/{{position}} of {{count}}": "{{position}} van {{count}}", + "No pinned messages": "Geen vastgemaakte berichten", + "a11y/Search pinned messages": "Zoek vastgemaakte berichten", + "Long-press a message to pin it to the chat": "Houd een bericht lang ingedrukt om het vast te pinnen in de chat", + "No photos or videos": "Geen foto's of video's", + "Share a photo or video to see it here": "Deel een foto of video om deze hier te zien", + "Failed to load media": "Media laden mislukt", + "No files": "Geen bestanden", + "Share a file to see it here": "Deel een bestand om het hier te zien", + "Failed to load files": "Bestanden laden mislukt", + "Notify all {{ role }} members": "Notify all {{ role }} members", + "a11y/Command suggestions available": "Command suggestions available", + "a11y/Emoji suggestions available": "Emoji suggestions available", + "a11y/Mention suggestions available": "Mention suggestions available", + "mention/Channel Description": "Notify everyone in this channel", + "mention/Here Description": "Notify every online member in this channel", + "Pin Chat": "Chat vastmaken", + "Pin Group": "Groep vastmaken", + "Unpin Chat": "Chat losmaken", + "Unpin Group": "Groep losmaken" } diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index 761f3cdeb0..cb231cff17 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -90,6 +90,7 @@ "No chats here yet…": "Ainda não há chats aqui...", "No items exist": "Nenhum item", "No threads here yet": "Ainda não há tópicos aqui", + "No user found": "Nenhum usuário encontrado", "Not supported": "Não suportado", "Nothing yet...": "Nada ainda...", "Ok": "Ok", @@ -161,6 +162,7 @@ "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "respondeu a", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Ontem]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Amanhã]\",\"nextWeek\":\"dddd [às] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/FileAttachmentListSection": "{{ timestamp | timestampFormatter(format: MMMM YYYY) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -235,11 +237,16 @@ "Delete Group": "Excluir grupo", "Leave Chat": "Sair da conversa", "Leave Group": "Sair do grupo", + "Mute Chat": "Silenciar conversa", "Mute Group": "Silenciar grupo", + "Admin": "Administrador", + "Moderator": "Moderador", "Offline": "Offline", "Online": "Online", + "Owner": "Proprietário", "Unarchive Chat": "Desarquivar conversa", "Unarchive Group": "Desarquivar grupo", + "Unmute Chat": "Remover conversa do modo silencioso", "Unmute Group": "Remover grupo do modo silencioso", "{{memberCount}} members, {{onlineCount}} online_one": "{{memberCount}} membro, {{onlineCount}} online", "{{memberCount}} members, {{onlineCount}} online_other": "{{memberCount}} membros, {{onlineCount}} online", @@ -268,6 +275,7 @@ "a11y/Loading failed": "Falha ao carregar", "a11y/Message actions": "Ações da mensagem", "a11y/Muted": "Silenciado", + "a11y/Pinned": "Fixado", "a11y/New message from {{user}}": "Nova mensagem de {{user}}", "a11y/Offline": "Offline", "a11y/Open message actions": "Abrir ações da mensagem", @@ -293,10 +301,14 @@ "a11y/you reacted": "você reagiu", "a11y/{{count}} new messages": "{{count}} novas mensagens", "a11y/Add attachment": "Add attachment", + "a11y/Add members": "Adicionar membros", + "a11y/Clear search": "Limpar busca", + "a11y/Close": "Close", "a11y/Close attachments": "Close attachments", "a11y/Remove attachment": "Remove Attachment", "a11y/Close poll": "Close poll", "a11y/Close poll creation": "Close poll creation", + "a11y/Confirm add members": "Confirmar adição de membros", "a11y/Create poll": "Create poll", "a11y/Decrease maximum votes": "Decrease maximum votes", "a11y/Delete voice recording": "Delete voice recording", @@ -319,11 +331,29 @@ "a11y/Select image": "Select image", "a11y/Select video": "Select video", "a11y/Send voice recording": "Send voice recording", + "a11y/Search users to add": "Buscar usuários para adicionar", + "a11y/Select {{name}}": "Selecionar {{name}}", + "a11y/{{name}} is already a member": "{{name}} já é um membro", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", "a11y/Stop voice recording": "Stop voice recording", + "Add": "Adicionar", + "Add Members": "Adicionar membros", + "Contact Info": "Informações de contato", + "Edit": "Editar", + "Files": "Arquivos", + "Group Info": "Informações do grupo", + "timestamp/UserActivityStatus": "Visto por último {{ timestamp | fromNowFormatter }}", + "Photos & Videos": "Fotos e vídeos", + "Pinned Messages": "Mensagens fixadas", + "View all": "Ver tudo", + "{{count}} members_one": "{{count}} membro", + "{{count}} members_other": "{{count}} membros", + "{{count}} members_many": "{{count}} membros", + "a11y/Back": "Voltar", "a11y/Notifications": "Notificações", "a11y/Dismiss notification": "Fechar notificação", + "a11y/Edit channel": "Editar canal", "Attachment upload blocked due to {{reason}}": "Upload do anexo bloqueado devido a {{reason}}", "Attachment upload failed due to {{reason}}": "Falha no upload do anexo devido a {{reason}}", "Command not available": "Comando indisponível", @@ -346,19 +376,31 @@ "Wait until all attachments have uploaded": "Aguarde até que todos os anexos tenham sido enviados", "Cannot seek in the recording": "Não é possível navegar pela gravação", "Channel archived": "Canal arquivado", + "Channel deleted": "Canal excluído", + "Channel image updated": "Imagem do canal atualizada", "Channel muted": "Canal silenciado", + "Channel name updated": "Nome do canal atualizado", "Channel pinned": "Canal fixado", "Channel unarchived": "Canal desarquivado", "Channel unmuted": "Canal com silenciamento removido", "Channel unpinned": "Canal desafixado", "Edit message request failed": "Falha na solicitação de edição da mensagem", + "Failed to add members": "Falha ao adicionar membros", "Failed to block user": "Falha ao bloquear usuário", + "Failed to delete channel": "Falha ao excluir o canal", "Failed to leave channel": "Falha ao sair do canal", + "Failed to load pinned messages": "Falha ao carregar mensagens fixadas", + "Failed to load users": "Falha ao carregar usuários", "Failed to play the recording": "Falha ao reproduzir a gravação", "Failed to update channel archive status": "Falha ao atualizar o status de arquivamento do canal", + "Failed to update channel image": "Falha ao atualizar a imagem do canal", "Failed to update channel mute status": "Falha ao atualizar o status de silenciamento do canal", + "Failed to update channel name": "Falha ao atualizar o nome do canal", "Failed to update channel pinned status": "Falha ao atualizar o status de fixação do canal", "Left channel": "Você saiu do canal", + "{{count}} members added_one": "{{count}} membro adicionado", + "{{count}} members added_other": "{{count}} membros adicionados", + "{{count}} members added_many": "{{count}} membros adicionados", "Recording format is not supported and cannot be reproduced": "O formato da gravação não é compatível e não pode ser reproduzido", "Send message request failed": "Falha na solicitação de envio da mensagem", "User blocked": "Usuário bloqueado", @@ -368,14 +410,52 @@ "size limit": "limite de tamanho", "unknown error": "erro desconhecido", "unsupported file type": "tipo de arquivo não compatível", + "Already a member": "Já é membro", + "Channel name": "Nome do canal", + "Edit Group Picture": "Editar foto do grupo", + "Choose Image": "Escolher imagem", + "Take Photo": "Tirar foto", + "Upload": "Enviar", + "a11y/Channel name": "Nome do canal", + "a11y/Confirm edit channel": "Confirmar edição do canal", + "a11y/Upload channel image": "Enviar imagem do canal", + "Reset Picture": "Redefinir foto", + "a11y/Close edit picture sheet": "Fechar painel de edição de foto", + "Muted": "Silenciado", + "Failed to load members": "Falha ao carregar membros", + "Remove User": "Remover usuário", + "Remove": "Remover", + "Are you sure you want to remove this member from the channel?": "Tem certeza de que deseja remover este membro do canal?", + "{{count}} members removed_one": "{{count}} membro removido", + "{{count}} members removed_other": "{{count}} membros removidos", + "{{count}} members removed_many": "{{count}} membros removidos", + "Failed to remove members": "Falha ao remover membros", "a11y/Double tap and hold to activate contextual menu": "Toque duas vezes e segure para ativar o menu contextual", "a11y/Swipe right to go through different actions": "Deslize para a direita para percorrer as diferentes ações", - "a11y/Close": "Close", "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.", "a11y/{{count}} unread messages": "{{count}} mensagens não lidas", "a11y/Message from you": "Mensagem sua", "a11y/Message from {{sender}}": "Mensagem de {{sender}}", "a11y/Gallery Image": "Imagem da galeria", "a11y/Gallery Video": "Vídeo da galeria", - "a11y/{{position}} of {{count}}": "{{position}} de {{count}}" + "a11y/{{position}} of {{count}}": "{{position}} de {{count}}", + "No pinned messages": "Nenhuma mensagem fixada", + "a11y/Search pinned messages": "Pesquisar mensagens fixadas", + "Long-press a message to pin it to the chat": "Mantenha pressionada uma mensagem para fixá-la no chat", + "No photos or videos": "Nenhuma foto ou vídeo", + "Share a photo or video to see it here": "Compartilhe uma foto ou vídeo para vê-lo aqui", + "Failed to load media": "Falha ao carregar mídia", + "No files": "Nenhum arquivo", + "Share a file to see it here": "Compartilhe um arquivo para vê-lo aqui", + "Failed to load files": "Falha ao carregar os arquivos", + "Notify all {{ role }} members": "Notify all {{ role }} members", + "a11y/Command suggestions available": "Command suggestions available", + "a11y/Emoji suggestions available": "Emoji suggestions available", + "a11y/Mention suggestions available": "Mention suggestions available", + "mention/Channel Description": "Notify everyone in this channel", + "mention/Here Description": "Notify every online member in this channel", + "Pin Chat": "Fixar conversa", + "Pin Group": "Fixar grupo", + "Unpin Chat": "Desafixar conversa", + "Unpin Group": "Desafixar grupo" } diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index 9cc17b3766..c00518ad09 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -90,6 +90,7 @@ "No chats here yet…": "Здесь пока нет чатов…", "No items exist": "Нет элементов", "No threads here yet": "Здесь пока нет потоков", + "No user found": "Пользователь не найден", "Not supported": "не поддерживается", "Nothing yet...": "Пока ничего нет...", "Ok": "Oк", @@ -161,6 +162,7 @@ "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "ответил на", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Вчера]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Завтра]\",\"nextWeek\":\"dddd [в] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/FileAttachmentListSection": "{{ timestamp | timestampFormatter(format: MMMM YYYY) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -235,11 +237,16 @@ "Delete Group": "Удалить группу", "Leave Chat": "Покинуть чат", "Leave Group": "Покинуть группу", + "Mute Chat": "Отключить чат", "Mute Group": "Отключить группу", + "Admin": "Администратор", + "Moderator": "Модератор", "Offline": "Не в сети", "Online": "В сети", + "Owner": "Владелец", "Unarchive Chat": "Разархивировать чат", "Unarchive Group": "Разархивировать группу", + "Unmute Chat": "Включить чат", "Unmute Group": "Включить группу", "{{memberCount}} members, {{onlineCount}} online_one": "{{memberCount}} участник, {{onlineCount}} онлайн", "{{memberCount}} members, {{onlineCount}} online_other": "{{memberCount}} участника, {{onlineCount}} онлайн", @@ -268,6 +275,7 @@ "a11y/Loading failed": "Не удалось загрузить", "a11y/Message actions": "Действия с сообщением", "a11y/Muted": "Без звука", + "a11y/Pinned": "Закреплено", "a11y/New message from {{user}}": "Новое сообщение от {{user}}", "a11y/Offline": "Не в сети", "a11y/Open message actions": "Открыть действия с сообщением", @@ -293,10 +301,14 @@ "a11y/you reacted": "вы отреагировали", "a11y/{{count}} new messages": "{{count}} новых сообщений", "a11y/Add attachment": "Add attachment", + "a11y/Add members": "Добавить участников", + "a11y/Clear search": "Очистить поиск", + "a11y/Close": "Close", "a11y/Close attachments": "Close attachments", "a11y/Remove attachment": "Remove Attachment", "a11y/Close poll": "Close poll", "a11y/Close poll creation": "Close poll creation", + "a11y/Confirm add members": "Подтвердить добавление участников", "a11y/Create poll": "Create poll", "a11y/Decrease maximum votes": "Decrease maximum votes", "a11y/Delete voice recording": "Delete voice recording", @@ -319,11 +331,29 @@ "a11y/Select image": "Select image", "a11y/Select video": "Select video", "a11y/Send voice recording": "Send voice recording", + "a11y/Search users to add": "Поиск пользователей для добавления", + "a11y/Select {{name}}": "Выбрать {{name}}", + "a11y/{{name}} is already a member": "{{name}} уже является участником", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", "a11y/Stop voice recording": "Stop voice recording", + "Add": "Добавить", + "Add Members": "Добавить участников", + "Contact Info": "Контактная информация", + "Edit": "Изменить", + "Files": "Файлы", + "Group Info": "Информация о группе", + "timestamp/UserActivityStatus": "Был(а) в сети {{ timestamp | fromNowFormatter }}", + "Photos & Videos": "Фото и видео", + "Pinned Messages": "Закреплённые сообщения", + "View all": "Показать все", + "{{count}} members_one": "{{count}} участник", + "{{count}} members_other": "{{count}} участника", + "{{count}} members_many": "{{count}} участников", + "a11y/Back": "Назад", "a11y/Notifications": "Уведомления", "a11y/Dismiss notification": "Закрыть уведомление", + "a11y/Edit channel": "Редактировать канал", "Attachment upload blocked due to {{reason}}": "Загрузка вложения заблокирована из-за {{reason}}", "Attachment upload failed due to {{reason}}": "Не удалось загрузить вложение из-за {{reason}}", "Command not available": "Команда недоступна", @@ -346,19 +376,31 @@ "Wait until all attachments have uploaded": "Дождитесь загрузки всех вложений", "Cannot seek in the recording": "Невозможно перемотать запись", "Channel archived": "Канал архивирован", + "Channel deleted": "Канал удалён", + "Channel image updated": "Изображение канала обновлено", "Channel muted": "Уведомления канала отключены", + "Channel name updated": "Имя канала обновлено", "Channel pinned": "Канал закреплен", "Channel unarchived": "Канал разархивирован", "Channel unmuted": "Уведомления канала включены", "Channel unpinned": "Канал откреплен", "Edit message request failed": "Не удалось изменить сообщение", + "Failed to add members": "Не удалось добавить участников", "Failed to block user": "Не удалось заблокировать пользователя", + "Failed to delete channel": "Не удалось удалить канал", "Failed to leave channel": "Не удалось покинуть канал", + "Failed to load pinned messages": "Не удалось загрузить закреплённые сообщения", + "Failed to load users": "Не удалось загрузить пользователей", "Failed to play the recording": "Не удалось воспроизвести запись", "Failed to update channel archive status": "Не удалось обновить статус архивации канала", + "Failed to update channel image": "Не удалось обновить изображение канала", "Failed to update channel mute status": "Не удалось обновить статус уведомлений канала", + "Failed to update channel name": "Не удалось обновить имя канала", "Failed to update channel pinned status": "Не удалось обновить статус закрепления канала", "Left channel": "Вы покинули канал", + "{{count}} members added_one": "Добавлен {{count}} участник", + "{{count}} members added_other": "Добавлено {{count}} участника", + "{{count}} members added_many": "Добавлено {{count}} участников", "Recording format is not supported and cannot be reproduced": "Формат записи не поддерживается, ее невозможно воспроизвести", "Send message request failed": "Не удалось отправить сообщение", "User blocked": "Пользователь заблокирован", @@ -368,14 +410,52 @@ "size limit": "лимит размера", "unknown error": "неизвестная ошибка", "unsupported file type": "неподдерживаемый тип файла", + "Already a member": "Уже участник", + "Channel name": "Название канала", + "Edit Group Picture": "Изменить фото группы", + "Choose Image": "Выбрать изображение", + "Take Photo": "Сделать снимок", + "Upload": "Загрузить", + "a11y/Channel name": "Название канала", + "a11y/Confirm edit channel": "Подтвердить изменение канала", + "a11y/Upload channel image": "Загрузить изображение канала", + "Reset Picture": "Сбросить фото", + "a11y/Close edit picture sheet": "Закрыть панель редактирования фото", + "Muted": "Заглушён", + "Failed to load members": "Не удалось загрузить участников", + "Remove User": "Удалить пользователя", + "Remove": "Удалить", + "Are you sure you want to remove this member from the channel?": "Вы уверены, что хотите удалить этого участника из канала?", + "{{count}} members removed_one": "Удалён {{count}} участник", + "{{count}} members removed_other": "Удалено {{count}} участника", + "{{count}} members removed_many": "Удалено {{count}} участников", + "Failed to remove members": "Не удалось удалить участников", "a11y/Double tap and hold to activate contextual menu": "Дважды коснитесь и удерживайте, чтобы открыть контекстное меню", "a11y/Swipe right to go through different actions": "Смахните вправо, чтобы переключаться между действиями", - "a11y/Close": "Close", "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.", "a11y/{{count}} unread messages": "{{count}} непрочитанных сообщений", "a11y/Message from you": "Сообщение от вас", "a11y/Message from {{sender}}": "Сообщение от {{sender}}", "a11y/Gallery Image": "Изображение из галереи", "a11y/Gallery Video": "Видео из галереи", - "a11y/{{position}} of {{count}}": "{{position}} из {{count}}" + "a11y/{{position}} of {{count}}": "{{position}} из {{count}}", + "No pinned messages": "Нет закреплённых сообщений", + "a11y/Search pinned messages": "Поиск закреплённых сообщений", + "Long-press a message to pin it to the chat": "Нажмите и удерживайте сообщение, чтобы закрепить его в чате", + "No photos or videos": "Нет фото и видео", + "Share a photo or video to see it here": "Поделитесь фото или видео, чтобы увидеть их здесь", + "Failed to load media": "Не удалось загрузить медиафайлы", + "No files": "Нет файлов", + "Share a file to see it here": "Поделитесь файлом, чтобы увидеть его здесь", + "Failed to load files": "Не удалось загрузить файлы", + "Notify all {{ role }} members": "Notify all {{ role }} members", + "a11y/Command suggestions available": "Command suggestions available", + "a11y/Emoji suggestions available": "Emoji suggestions available", + "a11y/Mention suggestions available": "Mention suggestions available", + "mention/Channel Description": "Notify everyone in this channel", + "mention/Here Description": "Notify every online member in this channel", + "Pin Chat": "Закрепить чат", + "Pin Group": "Закрепить группу", + "Unpin Chat": "Открепить чат", + "Unpin Group": "Открепить группу" } diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index 49fb6797e2..09c88e25ae 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -90,6 +90,7 @@ "No chats here yet…": "Henüz burada sohbet yok…", "No items exist": "Hiçbir öğe yok", "No threads here yet": "Burada henüz akış yok", + "No user found": "Kullanıcı bulunamadı", "Not supported": "Desteklenmiyor", "Nothing yet...": "Henüz değil...", "Ok": "Tamam", @@ -161,6 +162,7 @@ "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "replied to": "yanıtladı", "timestamp/ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Dün]\",\"lastWeek\":\"dddd\",\"nextDay\":\"[Yarın]\",\"nextWeek\":\"dddd [saat] LT\",\"sameDay\":\"LT\",\"sameElse\":\"L\"}) }}", + "timestamp/FileAttachmentListSection": "{{ timestamp | timestampFormatter(format: MMMM YYYY) }}", "timestamp/ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/InlineDateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageEditedTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -235,11 +237,16 @@ "Delete Group": "Grubu sil", "Leave Chat": "Sohbetten ayrıl", "Leave Group": "Gruptan ayrıl", + "Mute Chat": "Sohbeti sessize al", "Mute Group": "Grubu sessize al", + "Admin": "Yönetici", + "Moderator": "Moderatör", "Offline": "Çevrimdışı", "Online": "Çevrimiçi", + "Owner": "Sahip", "Unarchive Chat": "Sohbeti arşivden çıkar", "Unarchive Group": "Grubu arşivden çıkar", + "Unmute Chat": "Sohbetin sesini aç", "Unmute Group": "Grubun sesini ac", "{{memberCount}} members, {{onlineCount}} online_one": "{{memberCount}} üye, {{onlineCount}} çevrimiçi", "{{memberCount}} members, {{onlineCount}} online_other": "{{memberCount}} üye, {{onlineCount}} çevrimiçi", @@ -268,6 +275,7 @@ "a11y/Loading failed": "Yükleme başarısız", "a11y/Message actions": "Mesaj eylemleri", "a11y/Muted": "Sessize alındı", + "a11y/Pinned": "Sabitlendi", "a11y/New message from {{user}}": "{{user}} kullanıcısından yeni mesaj", "a11y/Offline": "Çevrimdışı", "a11y/Open message actions": "Mesaj eylemlerini aç", @@ -293,10 +301,14 @@ "a11y/you reacted": "siz tepki verdiniz", "a11y/{{count}} new messages": "{{count}} yeni mesaj", "a11y/Add attachment": "Add attachment", + "a11y/Add members": "Üye ekle", + "a11y/Clear search": "Aramayı temizle", + "a11y/Close": "Close", "a11y/Close attachments": "Close attachments", "a11y/Remove attachment": "Remove Attachment", "a11y/Close poll": "Close poll", "a11y/Close poll creation": "Close poll creation", + "a11y/Confirm add members": "Üye eklemeyi onayla", "a11y/Create poll": "Create poll", "a11y/Decrease maximum votes": "Decrease maximum votes", "a11y/Delete voice recording": "Delete voice recording", @@ -319,11 +331,29 @@ "a11y/Select image": "Select image", "a11y/Select video": "Select video", "a11y/Send voice recording": "Send voice recording", + "a11y/Search users to add": "Eklenecek kullanıcıları ara", + "a11y/Select {{name}}": "{{name}} seç", + "a11y/{{name}} is already a member": "{{name}} zaten bir üye", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", "a11y/Stop voice recording": "Stop voice recording", + "Add": "Ekle", + "Add Members": "Üye ekle", + "Contact Info": "İletişim bilgileri", + "Edit": "Düzenle", + "Files": "Dosyalar", + "Group Info": "Grup bilgileri", + "timestamp/UserActivityStatus": "Son görülme {{ timestamp | fromNowFormatter }}", + "Photos & Videos": "Fotoğraflar ve Videolar", + "Pinned Messages": "Sabitlenmiş mesajlar", + "View all": "Tümünü göster", + "{{count}} members_one": "{{count}} üye", + "{{count}} members_other": "{{count}} üye", + "{{count}} members_many": "{{count}} üye", + "a11y/Back": "Geri", "a11y/Notifications": "Bildirimler", "a11y/Dismiss notification": "Bildirimi kapat", + "a11y/Edit channel": "Kanalı düzenle", "Attachment upload blocked due to {{reason}}": "Ek yükleme {{reason}} nedeniyle engellendi", "Attachment upload failed due to {{reason}}": "Ek yükleme {{reason}} nedeniyle başarısız oldu", "Command not available": "Komut kullanılamıyor", @@ -346,19 +376,31 @@ "Wait until all attachments have uploaded": "Tüm ekler yüklenene kadar bekleyin", "Cannot seek in the recording": "Kayıtta ileri/geri sarılamıyor", "Channel archived": "Kanal arşivlendi", + "Channel deleted": "Kanal silindi", + "Channel image updated": "Kanal resmi güncellendi", "Channel muted": "Kanal sessize alındı", + "Channel name updated": "Kanal adı güncellendi", "Channel pinned": "Kanal sabitlendi", "Channel unarchived": "Kanal arşivden çıkarıldı", "Channel unmuted": "Kanalın sesi açıldı", "Channel unpinned": "Kanal sabitlemesi kaldırıldı", "Edit message request failed": "Mesaj düzenleme isteği başarısız oldu", + "Failed to add members": "Üyeler eklenemedi", "Failed to block user": "Kullanıcı engellenemedi", + "Failed to delete channel": "Kanal silinemedi", "Failed to leave channel": "Kanaldan çıkılamadı", + "Failed to load pinned messages": "Sabitlenmiş mesajlar yüklenemedi", + "Failed to load users": "Kullanıcılar yüklenemedi", "Failed to play the recording": "Kayıt oynatılamadı", "Failed to update channel archive status": "Kanal arşiv durumu güncellenemedi", + "Failed to update channel image": "Kanal resmi güncellenemedi", "Failed to update channel mute status": "Kanal sessize alma durumu güncellenemedi", + "Failed to update channel name": "Kanal adı güncellenemedi", "Failed to update channel pinned status": "Kanal sabitleme durumu güncellenemedi", "Left channel": "Kanaldan çıkıldı", + "{{count}} members added_one": "{{count}} üye eklendi", + "{{count}} members added_other": "{{count}} üye eklendi", + "{{count}} members added_many": "{{count}} üye eklendi", "Recording format is not supported and cannot be reproduced": "Kayıt biçimi desteklenmiyor ve oynatılamıyor", "Send message request failed": "Mesaj gönderme isteği başarısız oldu", "User blocked": "Kullanıcı engellendi", @@ -368,14 +410,52 @@ "size limit": "boyut sınırı", "unknown error": "bilinmeyen hata", "unsupported file type": "desteklenmeyen dosya türü", + "Already a member": "Zaten üye", + "Channel name": "Kanal adı", + "Edit Group Picture": "Grup resmini düzenle", + "Choose Image": "Resim seç", + "Take Photo": "Fotoğraf çek", + "Upload": "Yükle", + "a11y/Channel name": "Kanal adı", + "a11y/Confirm edit channel": "Kanal düzenlemeyi onayla", + "a11y/Upload channel image": "Kanal resmini yükle", + "Reset Picture": "Resmi sıfırla", + "a11y/Close edit picture sheet": "Resim düzenleme sayfasını kapat", + "Muted": "Sessize alındı", + "Failed to load members": "Üyeler yüklenemedi", + "Remove User": "Kullanıcıyı kaldır", + "Remove": "Kaldır", + "Are you sure you want to remove this member from the channel?": "Bu üyeyi kanaldan kaldırmak istediğinizden emin misiniz?", + "{{count}} members removed_one": "{{count}} üye kaldırıldı", + "{{count}} members removed_other": "{{count}} üye kaldırıldı", + "{{count}} members removed_many": "{{count}} üye kaldırıldı", + "Failed to remove members": "Üyeler kaldırılamadı", "a11y/Double tap and hold to activate contextual menu": "Bağlam menüsünü etkinleştirmek için çift dokunup basılı tut", "a11y/Swipe right to go through different actions": "Farklı eylemler arasında geçiş yapmak için sağa kaydır", - "a11y/Close": "Close", "a11y/Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.": "Bottom sheet opened. Activate the close action or use the escape gesture to dismiss.", "a11y/{{count}} unread messages": "{{count}} okunmamış mesaj", "a11y/Message from you": "Senden mesaj", "a11y/Message from {{sender}}": "{{sender}} kullanıcısından mesaj", "a11y/Gallery Image": "Galeri görüntüsü", "a11y/Gallery Video": "Galeri videosu", - "a11y/{{position}} of {{count}}": "{{count}} öğeden {{position}}" + "a11y/{{position}} of {{count}}": "{{count}} öğeden {{position}}", + "No pinned messages": "Sabitlenmiş mesaj yok", + "a11y/Search pinned messages": "Sabitlenmiş mesajları ara", + "Long-press a message to pin it to the chat": "Bir mesajı sohbete sabitlemek için uzun basın", + "No photos or videos": "Fotoğraf veya video yok", + "Share a photo or video to see it here": "Burada görmek için bir fotoğraf veya video paylaşın", + "Failed to load media": "Medya yüklenemedi", + "No files": "Dosya yok", + "Share a file to see it here": "Burada görmek için bir dosya paylaşın", + "Failed to load files": "Dosyalar yüklenemedi", + "Notify all {{ role }} members": "Notify all {{ role }} members", + "a11y/Command suggestions available": "Command suggestions available", + "a11y/Emoji suggestions available": "Emoji suggestions available", + "a11y/Mention suggestions available": "Mention suggestions available", + "mention/Channel Description": "Notify everyone in this channel", + "mention/Here Description": "Notify every online member in this channel", + "Pin Chat": "Sohbeti sabitle", + "Pin Group": "Grubu sabitle", + "Unpin Chat": "Sohbetin sabitlemesini kaldır", + "Unpin Group": "Grubun sabitlemesini kaldır" } diff --git a/package/src/icons/chevron-right.tsx b/package/src/icons/chevron-right.tsx new file mode 100644 index 0000000000..c553f9e75c --- /dev/null +++ b/package/src/icons/chevron-right.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { I18nManager } from 'react-native'; + +import { G, Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const ChevronRight = ({ height, size, width, ...rest }: IconProps) => ( + <Svg fill={'none'} height={height ?? size} viewBox={'0 0 20 20'} width={width ?? size} {...rest}> + <G transform={I18nManager.isRTL ? undefined : 'matrix(-1 0 0 1 20 0)'}> + <Path + d='M12.5 16.25L6.25 10L12.5 3.75' + strokeLinecap='round' + strokeLinejoin='round' + strokeWidth={1.5} + {...rest} + /> + </G> + </Svg> +); diff --git a/package/src/icons/folder.tsx b/package/src/icons/folder.tsx new file mode 100644 index 0000000000..56d4b7b196 --- /dev/null +++ b/package/src/icons/folder.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const Folder = ({ fill, height, pathFill, size, stroke, width, ...rest }: IconProps) => { + const color = stroke ?? pathFill ?? fill ?? 'black'; + + return ( + <Svg + fill={'none'} + height={height ?? size} + viewBox={'0 0 17 14'} + width={width ?? size} + {...rest} + > + <Path + d='M15.75 3.25V12.0695C15.75 12.2169 15.6915 12.3581 15.5873 12.4623C15.4831 12.5665 15.3419 12.625 15.1945 12.625H1.375C1.20924 12.625 1.05027 12.5592 0.933058 12.4419C0.815848 12.3247 0.75 12.1658 0.75 12V1.375C0.75 1.20924 0.815848 1.05027 0.933058 0.933058C1.05027 0.815848 1.20924 0.75 1.375 0.75H5.54141C5.67664 0.75 5.80822 0.793861 5.91641 0.875L8.25 2.625H15.125C15.2908 2.625 15.4497 2.69085 15.5669 2.80806C15.6842 2.92527 15.75 3.08424 15.75 3.25Z' + fill='none' + stroke={color} + strokeLinecap='round' + strokeLinejoin='round' + strokeWidth={1.5} + /> + </Svg> + ); +}; diff --git a/package/src/icons/index.ts b/package/src/icons/index.ts index 86239b3d0a..f44e8512e6 100644 --- a/package/src/icons/index.ts +++ b/package/src/icons/index.ts @@ -12,6 +12,7 @@ export * from './delete'; export * from './filetype-text-xl'; export * from './edit'; export * from './flag'; +export * from './gallery'; export { GiphyIcon } from './giphy'; export * from './loading'; export * from './mute'; @@ -45,7 +46,10 @@ export * from './refresh'; export { PollThumbnail } from './poll'; export * from './notification'; export { FilePickerIcon } from './file'; +export * from './folder'; export * from './command'; export * from './bell'; export * from './save'; export * from './checkmark-1'; +export * from './chevron-left'; +export * from './chevron-right'; diff --git a/package/src/icons/megaphone.tsx b/package/src/icons/megaphone.tsx new file mode 100644 index 0000000000..095b18db7d --- /dev/null +++ b/package/src/icons/megaphone.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const Megaphone = ({ fill, height, pathFill, size, stroke, width, ...rest }: IconProps) => { + const color = stroke ?? pathFill ?? fill ?? 'black'; + + return ( + <Svg height={height ?? size} width={width ?? size} viewBox='0 0 16 16' fill='none' {...rest}> + <Path + d='M10 4.99999V12.5419C10 12.6241 10.0204 12.705 10.0592 12.7775C10.098 12.85 10.1541 12.9119 10.2225 12.9575L10.91 13.4156C10.9767 13.4601 11.0531 13.4879 11.1328 13.4966C11.2124 13.5054 11.293 13.4948 11.3678 13.4659C11.4425 13.437 11.5092 13.3905 11.5623 13.3304C11.6153 13.2703 11.6531 13.1984 11.6725 13.1206L12.5 9.99999M10 4.99999H12.5C13.163 4.99999 13.7989 5.26338 14.2678 5.73222C14.7366 6.20106 15 6.83695 15 7.49999C15 8.16303 14.7366 8.79892 14.2678 9.26776C13.7989 9.7366 13.163 9.99999 12.5 9.99999M10 4.99999C10 4.99999 6.59688 4.86499 3.32188 2.11812C3.24905 2.05685 3.16025 2.01764 3.06591 2.0051C2.97158 1.99255 2.87562 2.00719 2.78931 2.04729C2.70301 2.08739 2.62994 2.15129 2.57869 2.23148C2.52744 2.31167 2.50014 2.40482 2.5 2.49999V12.5C2.50002 12.5952 2.52723 12.6884 2.57844 12.7687C2.62964 12.849 2.7027 12.913 2.78903 12.9532C2.87536 12.9934 2.97137 13.0081 3.06576 12.9955C3.16015 12.983 3.24901 12.9438 3.32188 12.8825C6.59688 10.135 10 9.99999 10 9.99999H12.5' + stroke={color} + strokeWidth={1.5} + strokeLinecap='round' + strokeLinejoin='round' + /> + </Svg> + ); +}; diff --git a/package/src/icons/shield.tsx b/package/src/icons/shield.tsx new file mode 100644 index 0000000000..bab786b10d --- /dev/null +++ b/package/src/icons/shield.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import { Path, Svg } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const Shield = ({ fill, height, pathFill, size, stroke, width, ...rest }: IconProps) => { + const color = stroke ?? pathFill ?? fill ?? 'black'; + + return ( + <Svg width={width ?? size} height={height ?? size} viewBox='0 0 16 16' fill='none' {...rest}> + <Path + d='M13.5 7V3.5C13.5 3.36739 13.4473 3.24021 13.3536 3.14645C13.2598 3.05268 13.1326 3 13 3H3C2.86739 3 2.74021 3.05268 2.64645 3.14645C2.55268 3.24021 2.5 3.36739 2.5 3.5V7C2.5 13 8 14.5 8 14.5C8 14.5 13.5 13 13.5 7Z' + stroke={color} + strokeWidth={1.5} + strokeLinecap='round' + strokeLinejoin='round' + /> + </Svg> + ); +}; diff --git a/package/src/icons/x-circle.tsx b/package/src/icons/x-circle.tsx new file mode 100644 index 0000000000..f1507f3225 --- /dev/null +++ b/package/src/icons/x-circle.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import Svg, { Path } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +export const XCircle = ({ fill, height, pathFill, size, stroke, width, ...rest }: IconProps) => { + const color = stroke ?? pathFill ?? fill ?? 'black'; + + return ( + <Svg width={width ?? size} height={height ?? size} viewBox='0 0 20 20' fill='none' {...rest}> + <Path + d='M12.5 7.5L7.5 12.5M7.5 7.5L12.5 12.5M17.5 10C17.5 14.1421 14.1421 17.5 10 17.5C5.85786 17.5 2.5 14.1421 2.5 10C2.5 5.85786 5.85786 2.5 10 2.5C14.1421 2.5 17.5 5.85786 17.5 10Z' + stroke={color} + strokeWidth={2} + strokeLinecap='round' + strokeLinejoin='round' + /> + </Svg> + ); +}; diff --git a/package/src/state-store/__tests__/edit-channel-details-store.test.ts b/package/src/state-store/__tests__/edit-channel-details-store.test.ts new file mode 100644 index 0000000000..6d2c9f56f5 --- /dev/null +++ b/package/src/state-store/__tests__/edit-channel-details-store.test.ts @@ -0,0 +1,205 @@ +import { act, renderHook } from '@testing-library/react-native'; + +import type { Channel } from 'stream-chat'; + +import { generateChannelResponse } from '../../mock-builders/generator/channel'; +import { getTestClientWithUser } from '../../mock-builders/mock'; +import type { File } from '../../types/types'; +import { + EditChannelDetailsStore, + useIsImageDirty, + useIsNameDirty, +} from '../edit-channel-details-store'; + +const file: File = { + name: 'pic.png', + size: 1234, + type: 'image/png', + uri: 'file://pic.png', +}; + +const createChannel = async (data: { image?: string; name?: string } = {}) => { + const client = await getTestClientWithUser({ id: 'me' }); + const response = generateChannelResponse({ channel: data }); + return client.channel('messaging', response.channel.id, response.channel) as Channel; +}; + +describe('EditChannelDetailsStore', () => { + describe('constructor', () => { + it('snapshots the initial name and image from the channel', async () => { + const channel = await createChannel({ + image: 'http://img/original.png', + name: 'Original', + }); + + const store = new EditChannelDetailsStore(channel); + const state = store.state.getLatestValue(); + + expect(state.initialName).toBe('Original'); + expect(state.initialImage).toBe('http://img/original.png'); + expect(state.currentName).toBe('Original'); + expect(state.updatedImage).toBeUndefined(); + expect(state.pendingAction).toBeNull(); + }); + + it('defaults to empty name and undefined image when channel has none', async () => { + const channel = await createChannel(); + + const store = new EditChannelDetailsStore(channel); + const state = store.state.getLatestValue(); + + expect(state.initialName).toBe(''); + expect(state.initialImage).toBeUndefined(); + expect(state.currentName).toBe(''); + }); + }); + + describe('setCurrentName', () => { + it('updates currentName and leaves initialName untouched', async () => { + const channel = await createChannel({ name: 'Original' }); + const store = new EditChannelDetailsStore(channel); + + store.setCurrentName('Renamed'); + + const state = store.state.getLatestValue(); + expect(state.currentName).toBe('Renamed'); + expect(state.initialName).toBe('Original'); + }); + }); + + describe('setUpdatedImage', () => { + it('handles File, null and back to undefined', async () => { + const channel = await createChannel(); + const store = new EditChannelDetailsStore(channel); + + store.setUpdatedImage(file); + expect(store.state.getLatestValue().updatedImage).toBe(file); + + store.setUpdatedImage(null); + expect(store.state.getLatestValue().updatedImage).toBeNull(); + + store.setUpdatedImage(undefined); + expect(store.state.getLatestValue().updatedImage).toBeUndefined(); + }); + }); + + describe('setPendingAction', () => { + it('sets each action value and clears back to null', async () => { + const channel = await createChannel(); + const store = new EditChannelDetailsStore(channel); + + (['camera', 'library', 'reset'] as const).forEach((action) => { + store.setPendingAction(action); + expect(store.state.getLatestValue().pendingAction).toBe(action); + }); + + store.setPendingAction(null); + expect(store.state.getLatestValue().pendingAction).toBeNull(); + }); + }); + + describe('useIsNameDirty', () => { + it('is false initially', async () => { + const channel = await createChannel({ name: 'Original' }); + const store = new EditChannelDetailsStore(channel); + + const { result } = renderHook(() => useIsNameDirty(store)); + expect(result.current).toBe(false); + }); + + it('reacts to name changes', async () => { + const channel = await createChannel({ name: 'Original' }); + const store = new EditChannelDetailsStore(channel); + + const { result } = renderHook(() => useIsNameDirty(store)); + expect(result.current).toBe(false); + + act(() => store.setCurrentName('Renamed')); + expect(result.current).toBe(true); + + act(() => store.setCurrentName('Original')); + expect(result.current).toBe(false); + }); + + it('treats untrimmed whitespace as dirty', async () => { + const channel = await createChannel({ name: 'Original' }); + const store = new EditChannelDetailsStore(channel); + + const { result } = renderHook(() => useIsNameDirty(store)); + + act(() => store.setCurrentName('Original ')); + expect(result.current).toBe(true); + }); + + it('ignores image changes', async () => { + const channel = await createChannel({ name: 'Original' }); + const store = new EditChannelDetailsStore(channel); + + const { result } = renderHook(() => useIsNameDirty(store)); + + act(() => store.setUpdatedImage(file)); + expect(result.current).toBe(false); + }); + }); + + describe('useIsImageDirty', () => { + it('is false initially', async () => { + const channel = await createChannel({ image: 'http://img/original.png' }); + const store = new EditChannelDetailsStore(channel); + + const { result } = renderHook(() => useIsImageDirty(store)); + expect(result.current).toBe(false); + }); + + it('is dirty after a new image is picked', async () => { + const channel = await createChannel({ name: 'Original' }); + const store = new EditChannelDetailsStore(channel); + + const { result } = renderHook(() => useIsImageDirty(store)); + + act(() => store.setUpdatedImage(file)); + expect(result.current).toBe(true); + }); + + it('ignores name changes', async () => { + const channel = await createChannel({ name: 'Original' }); + const store = new EditChannelDetailsStore(channel); + + const { result } = renderHook(() => useIsImageDirty(store)); + + act(() => store.setCurrentName('Renamed')); + expect(result.current).toBe(false); + }); + + describe('with an initial image', () => { + it('is dirty on reset and clean again when untouched', async () => { + const channel = await createChannel({ image: 'http://img/original.png' }); + const store = new EditChannelDetailsStore(channel); + + const { result } = renderHook(() => useIsImageDirty(store)); + expect(result.current).toBe(false); + + act(() => store.setUpdatedImage(null)); + expect(result.current).toBe(true); + + act(() => store.setUpdatedImage(undefined)); + expect(result.current).toBe(false); + }); + }); + + describe('without an initial image', () => { + it('is not dirty on reset but is dirty when a file is picked', async () => { + const channel = await createChannel(); + const store = new EditChannelDetailsStore(channel); + + const { result } = renderHook(() => useIsImageDirty(store)); + + act(() => store.setUpdatedImage(null)); + expect(result.current).toBe(false); + + act(() => store.setUpdatedImage(file)); + expect(result.current).toBe(true); + }); + }); + }); +}); diff --git a/package/src/state-store/__tests__/image-gallery-state-store.test.ts b/package/src/state-store/__tests__/image-gallery-state-store.test.ts index 377673a1d6..eaae1ec638 100644 --- a/package/src/state-store/__tests__/image-gallery-state-store.test.ts +++ b/package/src/state-store/__tests__/image-gallery-state-store.test.ts @@ -13,6 +13,7 @@ import { VideoPlayerPool } from '../video-player-pool'; jest.mock('../video-player-pool', () => ({ VideoPlayerPool: jest.fn().mockImplementation(() => ({ clear: jest.fn(), + getActivePlayer: jest.fn(() => null), pool: new Map(), state: { getLatestValue: () => ({ activeVideoPlayer: null }), @@ -179,6 +180,63 @@ describe('ImageGalleryStateStore', () => { expect(store.state.getLatestValue().currentIndex).toBe(0); }); + + it('should mirror currentIndex into currentIndexShared.value', () => { + const store = new ImageGalleryStateStore(); + + store.currentIndex = 7; + + expect(store.currentIndexShared.value).toBe(7); + }); + + it('should pause the active video player when currentIndex changes', () => { + const store = new ImageGalleryStateStore(); + const pause = jest.fn(); + (store.videoPlayerPool.getActivePlayer as jest.Mock).mockReturnValue({ pause }); + + store.currentIndex = 3; + + expect(pause).toHaveBeenCalledTimes(1); + }); + + it('should not pause the active player when currentIndex setter is called with the same value', () => { + const store = new ImageGalleryStateStore(); + store.currentIndex = 3; + const pause = jest.fn(); + (store.videoPlayerPool.getActivePlayer as jest.Mock).mockReturnValue({ pause }); + + store.currentIndex = 3; + + expect(pause).not.toHaveBeenCalled(); + }); + + it('should not throw when there is no active player on index change', () => { + const store = new ImageGalleryStateStore(); + (store.videoPlayerPool.getActivePlayer as jest.Mock).mockReturnValue(null); + + expect(() => { + store.currentIndex = 5; + }).not.toThrow(); + }); + }); + + describe('currentIndexShared mirror', () => { + it('should initialize at INITIAL_STATE.currentIndex (0)', () => { + const store = new ImageGalleryStateStore(); + + expect(store.currentIndexShared.value).toBe(0); + }); + + it('should reset to 0 on clear() even after non-zero set', () => { + const store = new ImageGalleryStateStore(); + store.currentIndex = 12; + expect(store.currentIndexShared.value).toBe(12); + + store.clear(); + + expect(store.state.getLatestValue().currentIndex).toBe(0); + expect(store.currentIndexShared.value).toBe(0); + }); }); describe('attachmentsWithMessage getter', () => { @@ -707,6 +765,29 @@ describe('ImageGalleryStateStore', () => { unsubscribe(); }); + it('should mirror the matched index into currentIndexShared', () => { + const store = new ImageGalleryStateStore(); + store.subscribeToMessages(); + const unsubscribe = store.subscribeToSelectedAttachmentUrl(); + + store.messages = [ + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/1.jpg' })], + id: 'msg-1', + }), + generateMessage({ + attachments: [generateImageAttachment({ image_url: 'https://example.com/2.jpg' })], + id: 'msg-2', + }), + ]; + store.selectedAttachmentUrl = 'https://example.com/2.jpg'; + + expect(store.state.getLatestValue().currentIndex).toBe(1); + expect(store.currentIndexShared.value).toBe(1); + + unsubscribe(); + }); + it('should return unsubscribe function', () => { const store = new ImageGalleryStateStore(); const unsubscribe = store.subscribeToSelectedAttachmentUrl(); diff --git a/package/src/state-store/__tests__/selection-store.test.ts b/package/src/state-store/__tests__/selection-store.test.ts new file mode 100644 index 0000000000..bd5d8eaf45 --- /dev/null +++ b/package/src/state-store/__tests__/selection-store.test.ts @@ -0,0 +1,183 @@ +import { act, renderHook } from '@testing-library/react-native'; + +import { SelectionStore, useIsSelected, useIsSelectionEmpty } from '../selection-store'; + +describe('SelectionStore', () => { + it('starts from the expected initial state', () => { + const store = new SelectionStore(); + const state = store.state.getLatestValue(); + + expect(state.selectedIds.size).toBe(0); + }); + + describe('select', () => { + it('adds an id and clones the set', () => { + const store = new SelectionStore(); + const initial = store.state.getLatestValue().selectedIds; + + store.select('a'); + + const afterAdd = store.state.getLatestValue().selectedIds; + expect(afterAdd.has('a')).toBe(true); + expect(afterAdd).not.toBe(initial); + }); + + it('is idempotent when the id is already selected', () => { + const store = new SelectionStore(); + store.select('a'); + const before = store.state.getLatestValue().selectedIds; + + store.select('a'); + + const after = store.state.getLatestValue().selectedIds; + expect(after).toBe(before); + expect(Array.from(after)).toEqual(['a']); + }); + + it('is a no-op for an undefined id', () => { + const store = new SelectionStore(); + const before = store.state.getLatestValue().selectedIds; + + store.select(undefined); + + expect(store.state.getLatestValue().selectedIds).toBe(before); + }); + + it('supports non-string id types', () => { + const store = new SelectionStore<number>(); + + store.select(1); + + expect(store.state.getLatestValue().selectedIds.has(1)).toBe(true); + }); + }); + + describe('deselect', () => { + it('removes a previously selected id and clones the set', () => { + const store = new SelectionStore(); + store.select('a'); + const beforeRemove = store.state.getLatestValue().selectedIds; + + store.deselect('a'); + + const afterRemove = store.state.getLatestValue().selectedIds; + expect(afterRemove.has('a')).toBe(false); + expect(afterRemove).not.toBe(beforeRemove); + }); + + it('is a no-op when the id is not selected', () => { + const store = new SelectionStore(); + const before = store.state.getLatestValue().selectedIds; + + store.deselect('a'); + + expect(store.state.getLatestValue().selectedIds).toBe(before); + }); + + it('is a no-op for an undefined id', () => { + const store = new SelectionStore(); + store.select('a'); + const before = store.state.getLatestValue().selectedIds; + + store.deselect(undefined); + + expect(store.state.getLatestValue().selectedIds).toBe(before); + }); + }); + + describe('toggle', () => { + it('selects an unselected id', () => { + const store = new SelectionStore(); + + store.toggle('a'); + + expect(store.state.getLatestValue().selectedIds.has('a')).toBe(true); + }); + + it('deselects an already-selected id', () => { + const store = new SelectionStore(); + store.select('a'); + + store.toggle('a'); + + expect(store.state.getLatestValue().selectedIds.has('a')).toBe(false); + }); + + it('is a no-op for an undefined id', () => { + const store = new SelectionStore(); + const before = store.state.getLatestValue().selectedIds; + + store.toggle(undefined); + + expect(store.state.getLatestValue().selectedIds).toBe(before); + }); + }); +}); + +describe('useIsSelectionEmpty', () => { + it('returns true when nothing is selected', () => { + const store = new SelectionStore(); + + const { result } = renderHook(() => useIsSelectionEmpty(store)); + + expect(result.current).toBe(true); + }); + + it('reacts to selection and deselection', () => { + const store = new SelectionStore(); + + const { result } = renderHook(() => useIsSelectionEmpty(store)); + expect(result.current).toBe(true); + + act(() => store.select('a')); + expect(result.current).toBe(false); + + act(() => store.deselect('a')); + expect(result.current).toBe(true); + }); +}); + +describe('useIsSelected', () => { + it('returns false when the id is not selected', () => { + const store = new SelectionStore(); + + const { result } = renderHook(() => useIsSelected(store, 'a')); + + expect(result.current).toBe(false); + }); + + it('reacts to the id being selected and deselected', () => { + const store = new SelectionStore(); + + const { result } = renderHook(() => useIsSelected(store, 'a')); + expect(result.current).toBe(false); + + act(() => store.select('a')); + expect(result.current).toBe(true); + + act(() => store.deselect('a')); + expect(result.current).toBe(false); + }); + + it('only tracks the requested id', () => { + const store = new SelectionStore(); + + const { result } = renderHook(() => useIsSelected(store, 'a')); + + act(() => store.select('b')); + expect(result.current).toBe(false); + }); + + it('re-evaluates when the requested id changes', () => { + const store = new SelectionStore(); + store.select('a'); + + const { result, rerender } = renderHook(({ id }) => useIsSelected(store, id), { + initialProps: { id: 'a' }, + }); + expect(result.current).toBe(true); + + rerender({ id: 'b' }); + expect(result.current).toBe(false); + }); +}); diff --git a/package/src/state-store/edit-channel-details-store.ts b/package/src/state-store/edit-channel-details-store.ts new file mode 100644 index 0000000000..777cb27324 --- /dev/null +++ b/package/src/state-store/edit-channel-details-store.ts @@ -0,0 +1,106 @@ +import { Channel, StateStore } from 'stream-chat'; + +import { useStateStore } from '../hooks/useStateStore'; +import type { File } from '../types/types'; + +export type EditChannelImagePendingAction = 'camera' | 'library' | 'reset'; + +export type EditChannelDetailsState = { + /** Current value of the name input. */ + currentName: string; + /** + * Channel image URL snapshotted at construction; not updated by WS events. + */ + initialImage: string | undefined; + /** + * Channel name snapshotted at construction; not updated by WS events. + */ + initialName: string; + /** Pending action from the {@link ChannelEditImageSheet}, or `null` when idle. */ + pendingAction: EditChannelImagePendingAction | null; + /** `undefined` = untouched, `File` = newly picked, `null` = reset. */ + updatedImage: File | null | undefined; +}; + +/** + * Holds the editable state for the channel details form (name + image) plus the + * pending image-picker action. The channel's name and image are snapshotted + * once at construction and are intentionally **not** updated by WebSocket + * events, so an inbound `channel.updated` does not clobber the user's + * in-progress edits. Leaf components can subscribe to narrow slices via + * {@link useStateStore}. + * + * @experimental This API is experimental and is subject to change. + */ +export class EditChannelDetailsStore { + public state: StateStore<EditChannelDetailsState>; + + constructor(channel: Channel) { + const initialName = channel.data?.name ?? ''; + const initialImage = channel.data?.image; + + this.state = new StateStore<EditChannelDetailsState>({ + currentName: initialName, + initialImage, + initialName, + pendingAction: null, + updatedImage: undefined, + }); + } + + /** Updates the current value of the name input. */ + setCurrentName(currentName: string) { + this.state.partialNext({ currentName }); + } + + /** + * Updates the picked image. `undefined` = untouched, `File` = newly picked, + * `null` = reset. + */ + setUpdatedImage(updatedImage: File | null | undefined) { + this.state.partialNext({ updatedImage }); + } + + /** Sets the pending image-picker action, or `null` to clear it. */ + setPendingAction(pendingAction: EditChannelImagePendingAction | null) { + this.state.partialNext({ pendingAction }); + } +} + +/** Whether the name input differs from the channel's initial name. */ +export const isNameDirty = (state: EditChannelDetailsState) => + state.currentName !== state.initialName; + +/** + * Whether the image has unsaved changes. The image is dirty once touched + * (`updatedImage !== undefined`), except when both the initial and updated + * image are falsy (no image before, none now). + */ +export const isImageDirty = (state: EditChannelDetailsState) => + !(state.updatedImage === undefined || (!state.updatedImage && !state.initialImage)); + +const selectIsNameDirty = (state: EditChannelDetailsState) => ({ + isNameDirty: isNameDirty(state), +}); + +const selectIsImageDirty = (state: EditChannelDetailsState) => ({ + isImageDirty: isImageDirty(state), +}); + +/** + * Subscribes to an {@link EditChannelDetailsStore} and returns whether the name + * input has unsaved changes. + * + * @experimental This API is experimental and is subject to change. + */ +export const useIsNameDirty = (store: EditChannelDetailsStore) => + useStateStore(store.state, selectIsNameDirty).isNameDirty; + +/** + * Subscribes to an {@link EditChannelDetailsStore} and returns whether the image + * has unsaved changes. + * + * @experimental This API is experimental and is subject to change. + */ +export const useIsImageDirty = (store: EditChannelDetailsStore) => + useStateStore(store.state, selectIsImageDirty).isImageDirty; diff --git a/package/src/state-store/image-gallery-state-store.ts b/package/src/state-store/image-gallery-state-store.ts index 3a024012e6..8ec05e162e 100644 --- a/package/src/state-store/image-gallery-state-store.ts +++ b/package/src/state-store/image-gallery-state-store.ts @@ -1,3 +1,5 @@ +import { makeMutable, SharedValue } from 'react-native-reanimated'; + import { Attachment, LocalMessage, StateStore, Unsubscribe, UserResponse } from 'stream-chat'; import { VideoPlayerPool } from './video-player-pool'; @@ -66,11 +68,19 @@ export class ImageGalleryStateStore { state: StateStore<ImageGalleryState>; options: ImageGalleryOptions; videoPlayerPool: VideoPlayerPool; + /** + * SharedValue mirror of `state.currentIndex` for worklet consumers (gesture + * handlers, animated styles). Maintained in lockstep with the setter and the + * reset path in `clear()`. Reads from worklets bypass the JS bridge entirely; + * any code path that updates `currentIndex` must keep this mirror in sync. + */ + currentIndexShared: SharedValue<number>; constructor(options: Partial<ImageGalleryOptions> = {}) { this.options = { ...INITIAL_IMAGE_GALLERY_OPTIONS, ...options }; this.state = new StateStore<ImageGalleryState>(INITIAL_STATE); this.videoPlayerPool = new VideoPlayerPool(); + this.currentIndexShared = makeMutable(INITIAL_STATE.currentIndex); } // Getters @@ -154,7 +164,21 @@ export class ImageGalleryStateStore { } set currentIndex(currentIndex: number) { + const previousIndex = this.state.getLatestValue().currentIndex; this.state.partialNext({ currentIndex }); + this.currentIndexShared.value = currentIndex; + // When the user moves off the current slide, pause whatever video was + // playing. Moved here from a useEffect in ImageGallery so the invariant + // lives next to the state it depends on — and the parent no longer needs + // to subscribe to currentIndex purely to drive this side effect. + // `clear()` is the other reset path; it bypasses this setter but already + // pauses everything through `videoPlayerPool.clear()`. + if (previousIndex !== currentIndex) { + const activePlayer = this.videoPlayerPool.getActivePlayer(); + if (activePlayer) { + activePlayer.pause(); + } + } } set requesterNode(requesterNode: number | null) { @@ -212,7 +236,8 @@ export class ImageGalleryStateStore { (asset) => stripQueryFromUrl(asset.uri) === stripQueryFromUrl(selectedAttachmentUrl ?? ''), ); - this.state.partialNext({ currentIndex: index === -1 ? 0 : index }); + // Route through the setter so currentIndexShared stays in sync. + this.currentIndex = index === -1 ? 0 : index; }, ); @@ -233,5 +258,6 @@ export class ImageGalleryStateStore { clear = () => { this.videoPlayerPool.clear(); this.state.partialNext(INITIAL_STATE); + this.currentIndexShared.value = INITIAL_STATE.currentIndex; }; } diff --git a/package/src/state-store/selection-store.ts b/package/src/state-store/selection-store.ts new file mode 100644 index 0000000000..212985b177 --- /dev/null +++ b/package/src/state-store/selection-store.ts @@ -0,0 +1,91 @@ +import { useCallback } from 'react'; + +import { StateStore } from 'stream-chat'; + +import { useStateStore } from '../hooks/useStateStore'; + +export type SelectionState<T = string> = { + /** Ids of the currently selected items. */ + selectedIds: Set<T>; +}; + +const createInitialState = <T>(): SelectionState<T> => ({ + selectedIds: new Set<T>(), +}); + +/** + * Holds a generic selection state (a set of selected ids). Leaf components can + * subscribe to narrow slices via `useStateStore`. The id type defaults to + * `string` (e.g. user ids in the "add members to channel" flow) but can be + * specialised for any other selectable entity. + * + * @experimental This API is experimental and is subject to change. + */ +export class SelectionStore<T = string> { + public state = new StateStore<SelectionState<T>>(createInitialState<T>()); + + select(id?: T) { + if (id === undefined || id === null) { + return; + } + const { selectedIds } = this.state.getLatestValue(); + if (selectedIds.has(id)) { + return; + } + const next = new Set(selectedIds); + next.add(id); + this.state.partialNext({ selectedIds: next }); + } + + deselect(id?: T) { + if (id === undefined || id === null) { + return; + } + const { selectedIds } = this.state.getLatestValue(); + if (!selectedIds.has(id)) { + return; + } + const next = new Set(selectedIds); + next.delete(id); + this.state.partialNext({ selectedIds: next }); + } + + toggle(id?: T) { + if (id === undefined || id === null) { + return; + } + const { selectedIds } = this.state.getLatestValue(); + if (selectedIds.has(id)) { + this.deselect(id); + } else { + this.select(id); + } + } +} + +const selectIsSelectionEmpty = <T>(state: SelectionState<T>) => ({ + isSelectionEmpty: state.selectedIds.size === 0, +}); + +/** + * Subscribes to a {@link SelectionStore} and returns whether the selection is + * currently empty. + * + * @experimental This API is experimental and is subject to change. + */ +export const useIsSelectionEmpty = <T>(store: SelectionStore<T>) => + useStateStore(store.state, selectIsSelectionEmpty).isSelectionEmpty; + +/** + * Subscribes to a {@link SelectionStore} and returns whether the given id is + * currently selected. + * + * @experimental This API is experimental and is subject to change. + */ +export const useIsSelected = <T>(store: SelectionStore<T>, id: T) => { + const selector = useCallback( + (state: SelectionState<T>) => ({ isSelected: state.selectedIds.has(id) }), + [id], + ); + return useStateStore(store.state, selector).isSelected; +}; diff --git a/package/src/store/SqliteClient.ts b/package/src/store/SqliteClient.ts index d82973cebe..733fc95dd8 100644 --- a/package/src/store/SqliteClient.ts +++ b/package/src/store/SqliteClient.ts @@ -28,7 +28,7 @@ import type { PreparedBatchQueries, PreparedQueries, Scalar, Table } from './typ * This way usage @op-engineering/op-sqlite package is scoped to a single class/file. */ export class SqliteClient { - static dbVersion = 15; + static dbVersion = 16; static dbName = DB_NAME; static dbLocation = DB_LOCATION; diff --git a/package/src/store/mappers/mapDraftMessageToStorable.ts b/package/src/store/mappers/mapDraftMessageToStorable.ts index 4cdc216e50..7966f855c7 100644 --- a/package/src/store/mappers/mapDraftMessageToStorable.ts +++ b/package/src/store/mappers/mapDraftMessageToStorable.ts @@ -12,6 +12,10 @@ export const mapDraftMessageToStorable = ({ custom, text, attachments, + mentioned_channel, + mentioned_group_ids, + mentioned_here, + mentioned_roles, mentioned_users, parent_id, poll_id, @@ -25,6 +29,10 @@ export const mapDraftMessageToStorable = ({ attachments: attachments ? JSON.stringify(attachments) : undefined, custom: custom ? JSON.stringify(custom) : undefined, id, + mentionedChannel: mentioned_channel, + mentionedGroupIds: mentioned_group_ids ? JSON.stringify(mentioned_group_ids) : undefined, + mentionedHere: mentioned_here, + mentionedRoles: mentioned_roles ? JSON.stringify(mentioned_roles) : undefined, mentionedUsers: mentioned_users ? JSON.stringify(mentioned_users) : undefined, parentId: parent_id, poll_id, diff --git a/package/src/store/mappers/mapStorableToDraftMessage.ts b/package/src/store/mappers/mapStorableToDraftMessage.ts index 3d194a9448..64bd754549 100644 --- a/package/src/store/mappers/mapStorableToDraftMessage.ts +++ b/package/src/store/mappers/mapStorableToDraftMessage.ts @@ -10,6 +10,10 @@ export const mapStorableToDraftMessage = ( custom, text, attachments, + mentionedChannel, + mentionedGroupIds, + mentionedHere, + mentionedRoles, mentionedUsers, parentId, poll_id, @@ -23,6 +27,10 @@ export const mapStorableToDraftMessage = ( attachments: attachments ? JSON.parse(attachments) : undefined, custom: custom ? JSON.parse(custom) : undefined, id, + mentioned_channel: mentionedChannel, + mentioned_group_ids: mentionedGroupIds ? JSON.parse(mentionedGroupIds) : undefined, + mentioned_here: mentionedHere, + mentioned_roles: mentionedRoles ? JSON.parse(mentionedRoles) : undefined, mentioned_users: mentionedUsers ? JSON.parse(mentionedUsers) : undefined, parent_id: parentId, poll_id, diff --git a/package/src/store/schema.ts b/package/src/store/schema.ts index b21f1dd9ba..9b918e44f5 100644 --- a/package/src/store/schema.ts +++ b/package/src/store/schema.ts @@ -90,6 +90,10 @@ export const tables: Tables = { attachments: 'TEXT', custom: 'TEXT', id: 'TEXT NOT NULL', + mentionedChannel: 'BOOLEAN DEFAULT FALSE', + mentionedGroupIds: 'TEXT', + mentionedHere: 'BOOLEAN DEFAULT FALSE', + mentionedRoles: 'TEXT', mentionedUsers: 'TEXT', parentId: 'TEXT', poll_id: 'TEXT', @@ -381,6 +385,10 @@ export type Schema = { id: string; attachments?: string; custom?: string; + mentionedChannel?: boolean; + mentionedGroupIds?: string; + mentionedHere?: boolean; + mentionedRoles?: string; mentionedUsers?: string; parentId?: string; poll_id?: string; diff --git a/package/src/theme/generated/StreamTokens.types.ts b/package/src/theme/generated/StreamTokens.types.ts index 24f807b48f..fbe14392df 100644 --- a/package/src/theme/generated/StreamTokens.types.ts +++ b/package/src/theme/generated/StreamTokens.types.ts @@ -444,6 +444,11 @@ export interface ChatSemantics { chatBgAttachmentIncoming: ColorValue; chatBgAttachmentOutgoing: ColorValue; chatBgIncoming: ColorValue; + chatBgMention: ColorValue; + chatBgMentionBroadcast: ColorValue; + chatBgMentionGroup: ColorValue; + chatBgMentionRole: ColorValue; + chatBgMentionUser: ColorValue; chatBgOutgoing: ColorValue; chatBorderIncoming: ColorValue; chatBorderOnChatIncoming: ColorValue; @@ -458,6 +463,10 @@ export interface ChatSemantics { chatTextIncoming: ColorValue; chatTextLink: ColorValue; chatTextMention: ColorValue; + chatTextMentionBroadcast: ColorValue; + chatTextMentionGroup: ColorValue; + chatTextMentionRole: ColorValue; + chatTextMentionUser: ColorValue; chatTextOutgoing: ColorValue; chatTextReaction: ColorValue; chatTextRead: ColorValue; diff --git a/package/src/theme/generated/dark/StreamTokens.android.ts b/package/src/theme/generated/dark/StreamTokens.android.ts index abf524ee10..69dfedf70b 100644 --- a/package/src/theme/generated/dark/StreamTokens.android.ts +++ b/package/src/theme/generated/dark/StreamTokens.android.ts @@ -509,6 +509,11 @@ export const semantics: IStreamTokens['semantics'] = { chatBgAttachmentIncoming: '$backgroundCoreSurfaceStrong', chatBgAttachmentOutgoing: '$brand150', chatBgIncoming: '$backgroundCoreSurfaceDefault', + chatBgMention: foundations.colors.baseTransparent0, + chatBgMentionBroadcast: '$chatBgMention', + chatBgMentionGroup: '$chatBgMention', + chatBgMentionRole: '$chatBgMention', + chatBgMentionUser: '$chatBgMention', chatBgOutgoing: '$brand100', chatBorderIncoming: '$borderCoreSubtle', chatBorderOnChatIncoming: '$borderCoreStrong', @@ -522,7 +527,11 @@ export const semantics: IStreamTokens['semantics'] = { chatReplyIndicatorOutgoing: '$brand400', chatTextIncoming: '$textPrimary', chatTextLink: '$textLink', - chatTextMention: '$textLink', + chatTextMention: '$accentPrimary', + chatTextMentionBroadcast: '$chatTextMention', + chatTextMentionGroup: '$chatTextMention', + chatTextMentionRole: '$chatTextMention', + chatTextMentionUser: '$chatTextMention', chatTextOutgoing: '$brand900', chatTextReaction: '$textSecondary', chatTextRead: '$accentPrimary', diff --git a/package/src/theme/generated/dark/StreamTokens.ios.ts b/package/src/theme/generated/dark/StreamTokens.ios.ts index 8f679bd27a..64e63bb277 100644 --- a/package/src/theme/generated/dark/StreamTokens.ios.ts +++ b/package/src/theme/generated/dark/StreamTokens.ios.ts @@ -509,6 +509,11 @@ export const semantics: IStreamTokens['semantics'] = { chatBgAttachmentIncoming: '$backgroundCoreSurfaceStrong', chatBgAttachmentOutgoing: '$brand150', chatBgIncoming: '$backgroundCoreSurfaceDefault', + chatBgMention: foundations.colors.baseTransparent0, + chatBgMentionBroadcast: '$chatBgMention', + chatBgMentionGroup: '$chatBgMention', + chatBgMentionRole: '$chatBgMention', + chatBgMentionUser: '$chatBgMention', chatBgOutgoing: '$brand100', chatBorderIncoming: '$borderCoreSubtle', chatBorderOnChatIncoming: '$borderCoreStrong', @@ -522,7 +527,11 @@ export const semantics: IStreamTokens['semantics'] = { chatReplyIndicatorOutgoing: '$brand400', chatTextIncoming: '$textPrimary', chatTextLink: '$textLink', - chatTextMention: '$textLink', + chatTextMention: '$accentPrimary', + chatTextMentionBroadcast: '$chatTextMention', + chatTextMentionGroup: '$chatTextMention', + chatTextMentionRole: '$chatTextMention', + chatTextMentionUser: '$chatTextMention', chatTextOutgoing: '$brand900', chatTextReaction: '$textSecondary', chatTextRead: '$accentPrimary', diff --git a/package/src/theme/generated/dark/StreamTokens.web.ts b/package/src/theme/generated/dark/StreamTokens.web.ts index 4733f3b7ab..d7b0bc657e 100644 --- a/package/src/theme/generated/dark/StreamTokens.web.ts +++ b/package/src/theme/generated/dark/StreamTokens.web.ts @@ -509,6 +509,11 @@ export const semantics: IStreamTokens['semantics'] = { chatBgAttachmentIncoming: '$backgroundCoreSurfaceStrong', chatBgAttachmentOutgoing: '$brand150', chatBgIncoming: '$backgroundCoreSurfaceDefault', + chatBgMention: foundations.colors.baseTransparent0, + chatBgMentionBroadcast: '$chatBgMention', + chatBgMentionGroup: '$chatBgMention', + chatBgMentionRole: '$chatBgMention', + chatBgMentionUser: '$chatBgMention', chatBgOutgoing: '$brand100', chatBorderIncoming: '$borderCoreSubtle', chatBorderOnChatIncoming: '$borderCoreStrong', @@ -522,7 +527,11 @@ export const semantics: IStreamTokens['semantics'] = { chatReplyIndicatorOutgoing: '$brand400', chatTextIncoming: '$textPrimary', chatTextLink: '$textLink', - chatTextMention: '$textLink', + chatTextMention: '$accentPrimary', + chatTextMentionBroadcast: '$chatTextMention', + chatTextMentionGroup: '$chatTextMention', + chatTextMentionRole: '$chatTextMention', + chatTextMentionUser: '$chatTextMention', chatTextOutgoing: '$brand900', chatTextReaction: '$textSecondary', chatTextRead: '$accentPrimary', diff --git a/package/src/theme/generated/light/StreamTokens.android.ts b/package/src/theme/generated/light/StreamTokens.android.ts index bbfd645c38..55058d22b0 100644 --- a/package/src/theme/generated/light/StreamTokens.android.ts +++ b/package/src/theme/generated/light/StreamTokens.android.ts @@ -509,6 +509,11 @@ export const semantics: IStreamTokens['semantics'] = { chatBgAttachmentIncoming: '$backgroundCoreSurfaceStrong', chatBgAttachmentOutgoing: '$brand150', chatBgIncoming: '$backgroundCoreSurfaceDefault', + chatBgMention: foundations.colors.baseTransparent0, + chatBgMentionBroadcast: '$chatBgMention', + chatBgMentionGroup: '$chatBgMention', + chatBgMentionRole: '$chatBgMention', + chatBgMentionUser: '$chatBgMention', chatBgOutgoing: '$brand100', chatBorderIncoming: '$borderCoreSubtle', chatBorderOnChatIncoming: '$borderCoreStrong', @@ -522,7 +527,11 @@ export const semantics: IStreamTokens['semantics'] = { chatReplyIndicatorOutgoing: '$brand400', chatTextIncoming: '$textPrimary', chatTextLink: '$textLink', - chatTextMention: '$textLink', + chatTextMention: '$accentPrimary', + chatTextMentionBroadcast: '$chatTextMention', + chatTextMentionGroup: '$chatTextMention', + chatTextMentionRole: '$chatTextMention', + chatTextMentionUser: '$chatTextMention', chatTextOutgoing: '$brand900', chatTextReaction: '$textSecondary', chatTextRead: '$accentPrimary', diff --git a/package/src/theme/generated/light/StreamTokens.ios.ts b/package/src/theme/generated/light/StreamTokens.ios.ts index 8bf993c444..d1d5923f33 100644 --- a/package/src/theme/generated/light/StreamTokens.ios.ts +++ b/package/src/theme/generated/light/StreamTokens.ios.ts @@ -509,6 +509,11 @@ export const semantics: IStreamTokens['semantics'] = { chatBgAttachmentIncoming: '$backgroundCoreSurfaceStrong', chatBgAttachmentOutgoing: '$brand150', chatBgIncoming: '$backgroundCoreSurfaceDefault', + chatBgMention: foundations.colors.baseTransparent0, + chatBgMentionBroadcast: '$chatBgMention', + chatBgMentionGroup: '$chatBgMention', + chatBgMentionRole: '$chatBgMention', + chatBgMentionUser: '$chatBgMention', chatBgOutgoing: '$brand100', chatBorderIncoming: '$borderCoreSubtle', chatBorderOnChatIncoming: '$borderCoreStrong', @@ -522,7 +527,11 @@ export const semantics: IStreamTokens['semantics'] = { chatReplyIndicatorOutgoing: '$brand400', chatTextIncoming: '$textPrimary', chatTextLink: '$textLink', - chatTextMention: '$textLink', + chatTextMention: '$accentPrimary', + chatTextMentionBroadcast: '$chatTextMention', + chatTextMentionGroup: '$chatTextMention', + chatTextMentionRole: '$chatTextMention', + chatTextMentionUser: '$chatTextMention', chatTextOutgoing: '$brand900', chatTextReaction: '$textSecondary', chatTextRead: '$accentPrimary', diff --git a/package/src/theme/generated/light/StreamTokens.web.ts b/package/src/theme/generated/light/StreamTokens.web.ts index cca8f2f43e..8f75c7dbc6 100644 --- a/package/src/theme/generated/light/StreamTokens.web.ts +++ b/package/src/theme/generated/light/StreamTokens.web.ts @@ -509,6 +509,11 @@ export const semantics: IStreamTokens['semantics'] = { chatBgAttachmentIncoming: '$backgroundCoreSurfaceStrong', chatBgAttachmentOutgoing: '$brand150', chatBgIncoming: '$backgroundCoreSurfaceDefault', + chatBgMention: foundations.colors.baseTransparent0, + chatBgMentionBroadcast: '$chatBgMention', + chatBgMentionGroup: '$chatBgMention', + chatBgMentionRole: '$chatBgMention', + chatBgMentionUser: '$chatBgMention', chatBgOutgoing: '$brand100', chatBorderIncoming: '$borderCoreSubtle', chatBorderOnChatIncoming: '$borderCoreStrong', @@ -522,7 +527,11 @@ export const semantics: IStreamTokens['semantics'] = { chatReplyIndicatorOutgoing: '$brand400', chatTextIncoming: '$textPrimary', chatTextLink: '$textLink', - chatTextMention: '$textLink', + chatTextMention: '$accentPrimary', + chatTextMentionBroadcast: '$chatTextMention', + chatTextMentionGroup: '$chatTextMention', + chatTextMentionRole: '$chatTextMention', + chatTextMentionUser: '$chatTextMention', chatTextOutgoing: '$brand900', chatTextReaction: '$textSecondary', chatTextRead: '$accentPrimary', diff --git a/package/src/types/types.ts b/package/src/types/types.ts index c372b9fe8b..27a4ef5e50 100644 --- a/package/src/types/types.ts +++ b/package/src/types/types.ts @@ -6,6 +6,7 @@ import type { LocalAudioAttachment, LocalUploadAttachment, LocalVoiceRecordingAttachment, + MinimumUploadRequestResult, } from 'stream-chat'; export enum FileTypes { @@ -540,3 +541,10 @@ export type Emoji = { export type EmojiSearchIndex = { search: (query: string) => PromiseLike<Array<Emoji>> | Array<Emoji> | null; }; + +/** + * Override the file upload request used to upload the channel image. + * By default the SDK uploads to Stream's CDN via `client.uploadImage`. + * @param file File object to upload + */ +export type GlobalFileUploadRequest = (file: File) => Promise<MinimumUploadRequestResult>; diff --git a/package/src/utils/i18n/predefinedFormatters.ts b/package/src/utils/i18n/predefinedFormatters.ts index 5883e55571..cfdfa8989e 100644 --- a/package/src/utils/i18n/predefinedFormatters.ts +++ b/package/src/utils/i18n/predefinedFormatters.ts @@ -52,6 +52,16 @@ export const predefinedFormatters: PredefinedFormatters = { } return result; }, + fromNowFormatter: + (streamI18n) => + (value, _, { withSuffix }: { withSuffix?: boolean } = {}) => { + const parsedTime = streamI18n.tDateTimeParser(value); + if (!isDayOrMoment(parsedTime) || typeof parsedTime.fromNow !== 'function') { + return JSON.stringify(value); + } + // fromNow(withoutSuffix); default to including the "ago" suffix + return parsedTime.fromNow(withSuffix === false); + }, relativeCompactDateFormatter: (streamI18n) => (value) => { if (value === undefined || value === null) { return JSON.stringify(value); diff --git a/package/src/utils/i18n/types.ts b/package/src/utils/i18n/types.ts index eaa0a3b990..ca271dc0a6 100644 --- a/package/src/utils/i18n/types.ts +++ b/package/src/utils/i18n/types.ts @@ -76,6 +76,7 @@ export type TimestampFormatterOptions = { export type PredefinedFormatters = { durationFormatter: FormatterFactory<string>; + fromNowFormatter: FormatterFactory<string | Date>; relativeCompactDateFormatter: FormatterFactory<string | Date>; timestampFormatter: FormatterFactory<string | Date>; }; diff --git a/perf/README.md b/perf/README.md index 4ff80ef01a..e02f80ad8b 100644 --- a/perf/README.md +++ b/perf/README.md @@ -68,6 +68,19 @@ Per-category self-time delta + top function self-time deltas (sorted by `|delta| For a fair diff, capture both profiles using the **same scenario** and the **same device** in roughly the same conditions. +## Android heap/codec/frame capture (memory & jank diagnostics) + +For perf work where the bottleneck is memory pressure, MediaCodec slot usage, or frame timing — not JS-thread CPU — use the adb-based heap dump script. + +```sh +perf/android-heap-dump.sh branch +perf/android-heap-dump.sh develop # after switching branches + rebuild +``` + +The script captures `dumpsys meminfo`, `gfxinfo`, `media.codec`, and `procstats` for the SampleApp and writes the combined output to `perf/profiles/android-heap-<label>-<timestamp>.txt`. Pre-test recipe (warming the video pool, resetting frame counters) is documented in the script header. + +For A/B comparisons, run the same scenario on both branches and diff the `Native Heap`, `Dalvik Heap`, `TOTAL PSS`, MediaCodec instance count, and frame-time percentiles between the two output files. + ## Conventions - Keep captured `.cpuprofile` files in `perf/profiles/` (gitignored). diff --git a/perf/android-heap-dump.sh b/perf/android-heap-dump.sh new file mode 100755 index 0000000000..05645b9547 --- /dev/null +++ b/perf/android-heap-dump.sh @@ -0,0 +1,108 @@ +#!/bin/bash +# +# android-heap-dump.sh +# +# Captures Android memory/heap/codec/frame stats for the SampleApp via adb. +# +# Usage: +# perf/android-heap-dump.sh [label] [package] +# +# label — short tag included in the output filename. Default: "snapshot". +# package — Android package id. Default: io.getstream.reactnative.sampleapp. +# +# Examples: +# perf/android-heap-dump.sh branch +# perf/android-heap-dump.sh develop +# perf/android-heap-dump.sh branch io.getstream.reactnative.sampleapp +# +# Output: +# perf/profiles/android-heap-<label>-<timestamp>.txt +# +# Suggested workflow: +# 1. Open SampleApp on the device, sign in, open the heavy channel. +# 2. ChannelDetails → Photos & Videos → tap a mid-list slide. +# 3. Scrub ~5 slides each way to warm the video pool. +# 4. Reset frame counters mid-test: +# adb shell dumpsys gfxinfo <package> reset +# 5. Swipe through ~10 more slides at a realistic pace. +# 6. Run this script. +# 7. Paste the output file path back to Claude. +# +# Captured sections (in order): +# MEMINFO — Dalvik/Native heap, total PSS, OOM grouping. +# GFXINFO — Frame timing percentiles + janky frames %. +# MEDIA.CODEC — System-wide MediaCodec instances (grep your PID). +# PROCSTATS — Process state + memory averages over the last hour. + +set -euo pipefail + +LABEL="${1:-snapshot}" +PKG="${2:-io.getstream.reactnative.sampleapp}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT_DIR="${SCRIPT_DIR}/profiles" +mkdir -p "${OUT_DIR}" + +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" +OUT="${OUT_DIR}/android-heap-${LABEL}-${TIMESTAMP}.txt" + +if ! command -v adb >/dev/null 2>&1; then + echo "error: adb not found on PATH" >&2 + exit 1 +fi + +if ! adb get-state >/dev/null 2>&1; then + echo "error: no Android device/emulator connected (adb get-state failed)" >&2 + exit 1 +fi + +echo "==> Package: ${PKG}" +echo "==> Output: ${OUT}" +echo + +PID="$(adb shell pidof "${PKG}" | tr -d '\r' || true)" +if [ -n "${PID}" ]; then + echo "==> PID: ${PID}" +else + echo "==> PID: (not running — capture will still proceed but some sections may be empty)" +fi +echo + +{ + echo "### LABEL: ${LABEL}" + echo "### DATE: $(date)" + echo "### PACKAGE: ${PKG}" + echo "### PID: ${PID:-<not running>}" + echo "### DEVICE: $(adb shell getprop ro.product.model | tr -d '\r') / Android $(adb shell getprop ro.build.version.release | tr -d '\r')" + echo "" + + echo "### ============================================================" + echo "### MEMINFO" + echo "### ============================================================" + adb shell dumpsys meminfo "${PKG}" + echo "" + + echo "### ============================================================" + echo "### GFXINFO" + echo "### ============================================================" + adb shell dumpsys gfxinfo "${PKG}" + echo "" + + echo "### ============================================================" + echo "### MEDIA.CODEC (system-wide; filter by PID ${PID:-<unknown>})" + echo "### ============================================================" + adb shell dumpsys media.codec + echo "" + + echo "### ============================================================" + echo "### PROCSTATS (last 1 hour)" + echo "### ============================================================" + adb shell dumpsys procstats "${PKG}" --hours 1 + echo "" +} > "${OUT}" 2>&1 + +LINES="$(wc -l < "${OUT}" | tr -d ' ')" +SIZE="$(wc -c < "${OUT}" | tr -d ' ')" + +echo "==> Done. ${LINES} lines, ${SIZE} bytes." +echo "==> ${OUT}" diff --git a/yarn.lock b/yarn.lock index f94f376f5b..d1eaf455e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5420,7 +5420,7 @@ __metadata: languageName: node linkType: hard -"@react-native-community/slider@npm:^5.2.0": +"@react-native-community/slider@npm:^5.0.1": version: 5.2.0 resolution: "@react-native-community/slider@npm:5.2.0" checksum: 10c0/58ec6a47b5aafefd98a61c79aeae05f1ab8371782eb497d9af665bf2f62a1c372da8fed6dead30e1d6c9e01bfb12bfdbafcde7890736f4f5d55502dabf2e01ad @@ -7064,7 +7064,7 @@ __metadata: languageName: node linkType: hard -"@types/ws@npm:^8.5.14": +"@types/ws@npm:^8.18.1": version: 8.18.1 resolution: "@types/ws@npm:8.18.1" dependencies: @@ -7442,7 +7442,7 @@ __metadata: react-native-teleport: "npm:^1.0.2" react-native-web: "npm:^0.21.0" react-native-worklets: "npm:0.8.3" - stream-chat: "npm:^9.44.2" + stream-chat: "npm:^9.47.0" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:~5.9.2" @@ -7503,6 +7503,15 @@ __metadata: languageName: node linkType: hard +"agent-base@npm:6": + version: 6.0.2 + resolution: "agent-base@npm:6.0.2" + dependencies: + debug: "npm:4" + checksum: 10c0/dc4f757e40b5f3e3d674bc9beb4f1048f4ee83af189bae39be99f57bf1f48dde166a8b0a5342a84b5944ee8e6ed1e5a9d801858f4ad44764e84957122fe46261 + languageName: node + linkType: hard + "agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": version: 7.1.4 resolution: "agent-base@npm:7.1.4" @@ -7919,14 +7928,15 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.15.1": - version: 1.16.0 - resolution: "axios@npm:1.16.0" +"axios@npm:^1.16.1": + version: 1.17.0 + resolution: "axios@npm:1.17.0" dependencies: follow-redirects: "npm:^1.16.0" form-data: "npm:^4.0.5" + https-proxy-agent: "npm:^5.0.1" proxy-from-env: "npm:^2.1.0" - checksum: 10c0/1c91a5221b77b76072026b4cc95ecdf38f7c3e33e63423abec09a85e6e9a12279637dcc9ac2ba1fc333e0c447fb3b0f46d7965acb5d7cea02d188e9c6d425c0b + checksum: 10c0/c4fa19ff3a3a63bde48beec03ad816b133b9a6385cccffffe172577ab18c6a70e299280d57f12c80c867fe25df41f92cb91d3a8258708a6d2be3e9e085f92650 languageName: node linkType: hard @@ -11478,7 +11488,7 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.4, form-data@npm:^4.0.5": +"form-data@npm:^4.0.5": version: 4.0.5 resolution: "form-data@npm:4.0.5" dependencies: @@ -12224,6 +12234,16 @@ __metadata: languageName: node linkType: hard +"https-proxy-agent@npm:^5.0.1": + version: 5.0.1 + resolution: "https-proxy-agent@npm:5.0.1" + dependencies: + agent-base: "npm:6" + debug: "npm:4" + checksum: 10c0/6dd639f03434003577c62b27cafdb864784ef19b2de430d8ae2a1d45e31c4fd60719e5637b44db1a88a046934307da7089e03d6089ec3ddacc1189d8de8897d1 + languageName: node + linkType: hard + "https-proxy-agent@npm:^7.0.0, https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.5": version: 7.0.6 resolution: "https-proxy-agent@npm:7.0.6" @@ -14483,7 +14503,7 @@ __metadata: languageName: node linkType: hard -"linkifyjs@npm:^4.3.2": +"linkifyjs@npm:^4.3.2, linkifyjs@npm:^4.3.3": version: 4.3.3 resolution: "linkifyjs@npm:4.3.3" checksum: 10c0/0eac293927f1465d13625c8c67c83c50d7fda9137c255a4a2ff5970ea4e9ba57de4846b170326e54b9e4e82be24f3705572bc50773737be9b13af5f1e4577798 @@ -17723,20 +17743,7 @@ __metadata: languageName: node linkType: hard -"react-native-gesture-handler@npm:3.0.0, react-native-gesture-handler@npm:^3.0.0": - version: 3.0.0 - resolution: "react-native-gesture-handler@npm:3.0.0" - dependencies: - "@types/react-test-renderer": "npm:^19.1.0" - invariant: "npm:^2.2.4" - peerDependencies: - react: "*" - react-native: "*" - checksum: 10c0/abc2c02d89c7dba0a1c58a02a99f136bfedcffc1d28e879045e5cc7738a12fd2ad76c5175987cd642141cce59ea104eb2337c4595d91b56ab2f43e1563c27747 - languageName: node - linkType: hard - -"react-native-gesture-handler@npm:^2.26.0, react-native-gesture-handler@npm:~2.31.1": +"react-native-gesture-handler@npm:^2.26.0, react-native-gesture-handler@npm:^2.31.2, react-native-gesture-handler@npm:~2.31.1": version: 2.31.2 resolution: "react-native-gesture-handler@npm:2.31.2" dependencies: @@ -17751,16 +17758,16 @@ __metadata: languageName: node linkType: hard -"react-native-gesture-handler@patch:react-native-gesture-handler@npm%3A3.0.0#~/.yarn/patches/react-native-gesture-handler-npm-3.0.0-8eca038751.patch": +"react-native-gesture-handler@npm:^3.0.0": version: 3.0.0 - resolution: "react-native-gesture-handler@patch:react-native-gesture-handler@npm%3A3.0.0#~/.yarn/patches/react-native-gesture-handler-npm-3.0.0-8eca038751.patch::version=3.0.0&hash=78c2cf" + resolution: "react-native-gesture-handler@npm:3.0.0" dependencies: "@types/react-test-renderer": "npm:^19.1.0" invariant: "npm:^2.2.4" peerDependencies: react: "*" react-native: "*" - checksum: 10c0/7e7e2ac293d4174af4d7e54bcf8a0b595e6fef8108e9341b36d5fd0b0f7aca469677f592f677f739163704fb0d7c86f2b25f956ab8fe3c3c79f8ba48ea9e1f7f + checksum: 10c0/abc2c02d89c7dba0a1c58a02a99f136bfedcffc1d28e879045e5cc7738a12fd2ad76c5175987cd642141cce59ea104eb2337c4595d91b56ab2f43e1563c27747 languageName: node linkType: hard @@ -17865,26 +17872,26 @@ __metadata: languageName: node linkType: hard -"react-native-nitro-modules@npm:^0.35.9": - version: 0.35.9 - resolution: "react-native-nitro-modules@npm:0.35.9" +"react-native-nitro-modules@npm:0.31.3": + version: 0.31.3 + resolution: "react-native-nitro-modules@npm:0.31.3" peerDependencies: react: "*" react-native: "*" - checksum: 10c0/2e5c1b3eed1d187e7c2bbbf661e6405cb69c6a20b01427ea4c8a3bfe07a4baf2efbeb170a21f8c17fb0e18f022244f4b1fe1ce69a7ec824a7a4cf72441893cf1 + checksum: 10c0/ec37750c9ae0a1c433c307cfba78474cb1c2593f6b4780d00250755688ed049602e28fa3c4e2b9f922a815353b6ed8c74db580973e736a4d8aba4f06eed7c91b languageName: node linkType: hard -"react-native-nitro-sound@npm:^0.2.15": - version: 0.2.15 - resolution: "react-native-nitro-sound@npm:0.2.15" +"react-native-nitro-sound@npm:0.2.9": + version: 0.2.9 + resolution: "react-native-nitro-sound@npm:0.2.9" dependencies: - "@react-native-community/slider": "npm:^5.2.0" + "@react-native-community/slider": "npm:^5.0.1" peerDependencies: react: "*" react-native: "*" - react-native-nitro-modules: ">=0.35.4" - checksum: 10c0/215624ccc6303475ee21ac734b40bcddb9bf8ad8996a3ea8af3c98d507280261686f8d7289a5eac56736ad33c0e20395fb843d78c5441c58f79f70086c5759fe + react-native-nitro-modules: "*" + checksum: 10c0/10fdb135ef226b0de845ae91d4cfb88fec8a55ee5350919b8005589fa08750b934c76b5ccd0265ae38e7d9aa14b28f83d156d3d3582cfef15e9b9f93873a2505 languageName: node linkType: hard @@ -18863,12 +18870,12 @@ __metadata: react: "npm:19.2.3" react-native: "npm:0.85.3" react-native-blob-util: "npm:^0.24.9" - react-native-gesture-handler: "patch:react-native-gesture-handler@npm%3A3.0.0#~/.yarn/patches/react-native-gesture-handler-npm-3.0.0-8eca038751.patch" + react-native-gesture-handler: "npm:^2.31.2" react-native-haptic-feedback: "npm:^3.0.0" react-native-image-picker: "npm:^8.2.1" react-native-maps: "npm:^1.27.2" - react-native-nitro-modules: "npm:^0.35.9" - react-native-nitro-sound: "npm:^0.2.15" + react-native-nitro-modules: "npm:0.31.3" + react-native-nitro-sound: "npm:0.2.9" react-native-reanimated: "npm:4.3.1" react-native-safe-area-context: "npm:^5.8.0" react-native-screens: "npm:^4.25.2" @@ -18877,7 +18884,7 @@ __metadata: react-native-teleport: "npm:^1.1.7" react-native-video: "npm:^6.19.2" react-native-worklets: "npm:^0.8.3" - stream-chat: "npm:^9.44.2" + stream-chat: "npm:^9.47.0" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:5.9.3" @@ -19604,7 +19611,7 @@ __metadata: react-native-worklets: "npm:^0.9.1" react-test-renderer: "npm:19.2.3" rimraf: "npm:^6.0.1" - stream-chat: "npm:^9.44.2" + stream-chat: "npm:^9.47.0" typescript: "npm:5.9.3" use-sync-external-store: "npm:^1.5.0" uuid: "npm:^11.1.0" @@ -19676,20 +19683,25 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.44.2": - version: 9.44.2 - resolution: "stream-chat@npm:9.44.2" +"stream-chat@npm:^9.47.0": + version: 9.47.0 + resolution: "stream-chat@npm:9.47.0" dependencies: "@types/jsonwebtoken": "npm:^9.0.8" - "@types/ws": "npm:^8.5.14" - axios: "npm:^1.15.1" + "@types/ws": "npm:^8.18.1" + axios: "npm:^1.16.1" base64-js: "npm:^1.5.1" - form-data: "npm:^4.0.4" + form-data: "npm:^4.0.5" isomorphic-ws: "npm:^5.0.0" jsonwebtoken: "npm:^9.0.3" - linkifyjs: "npm:^4.3.2" - ws: "npm:^8.18.1" - checksum: 10c0/2657a6fdf21f54833df230fa2f8261a3ff9648ae627ca394639dd4baf27c244257bd380544d45073915b7af64943e471e021c780c90426875662925048133895 + linkifyjs: "npm:^4.3.3" + ws: "npm:^8.20.1" + dependenciesMeta: + esbuild: + built: true + husky: + built: true + checksum: 10c0/6f1a84f31047d0ccaf764ee106a276d7361b1582083c762cecd0e427d37ff6b9bfc69decaa856be0ab81d42f53beea12a231bc207d73dcbf28126abc9e28d9f3 languageName: node linkType: hard @@ -20524,7 +20536,7 @@ __metadata: react-native-svg: "npm:^15.12.0" react-native-video: "npm:^6.16.1" react-native-worklets: "npm:^0.4.1" - stream-chat: "npm:^9.44.2" + stream-chat: "npm:^9.47.0" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:5.9.3" @@ -21291,7 +21303,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.12.1, ws@npm:^8.18.1": +"ws@npm:^8.12.1": version: 8.20.1 resolution: "ws@npm:8.20.1" peerDependencies: @@ -21306,6 +21318,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.20.1": + version: 8.21.0 + resolution: "ws@npm:8.21.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/ef4a243476283fc49bc7550966c4af4aa0eef56273837211e700de3b664e08604a760cdddcb5ba43c049140e74ccfec5b0ee0bb439e08c2adf9138902fdde5f9 + languageName: node + linkType: hard + "xcode@npm:^3.0.1": version: 3.0.1 resolution: "xcode@npm:3.0.1"