diff --git a/.claude/skills/accessibility/SKILL.md b/.claude/skills/accessibility/SKILL.md index e4edaf26b0..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`). +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 @@ -178,6 +217,7 @@ 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 @@ -216,9 +256,11 @@ 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}`). 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/package.json b/examples/ExpoMessaging/package.json index decf82d856..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.45.6", + "stream-chat": "^9.47.0", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, 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/package.json b/examples/SampleApp/package.json index a51a9a6a06..903b94629f 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -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.45.6", + "stream-chat": "^9.47.0", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/TypeScriptMessaging/package.json b/examples/TypeScriptMessaging/package.json index ca05635640..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.45.6", + "stream-chat": "^9.47.0", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/package/package.json b/package/package.json index 0d17422096..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.45.6", + "stream-chat": "^9.47.0", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { 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(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 = () => return ( @@ -52,7 +53,7 @@ export const EmojiHeader: React.FC = ({ query return ( - + {`Emoji matching "${queryText}"`} 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 ( - - - - - {name || id} - - - - ); +/** + * 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 ; + case 'channel': + case 'here': + return ; + case 'role': + return ; + case 'user_group': + return ; + 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 ; + return ; case 'emoji': return ; case 'command': @@ -147,6 +153,7 @@ const UnMemoizedAutoCompleteSuggestionItem = ({ return ( [{ 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; @@ -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 ( { onEndReachedThreshold={0.1} renderItem={renderItem} style={[styles.flatlist, { maxHeight }]} + contentContainerStyle={styles.flatlistContentContainer} testID={'auto-complete-suggestion-list'} /> + ); }; @@ -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 ( + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + ); +}; + +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; + /** + * 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 ( + + + + ); +}; + +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 ( + }> + + + ); +}; 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 ( + + {leading} + {children} + + ); +}; + +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 ( + }> + + + ); +}; 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) => ( + }> + + +); 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 ( + + + + } + > + + + ); +}; + +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; + matchStyle?: StyleProp; + 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 ( + + {fallback} + + ); + } + + const { parts, token } = tokenizedDisplayName; + return ( + + {parts.map((part, index) => + partMatchesToken(part, token) ? ( + + {part} + + ) : ( + part + ), + )} + + ); +}; 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({ui}); + +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(); + expect(getByText('Alice')).toBeTruthy(); + }); + + it('renders a broadcast row for @channel with description subtitle', () => { + const { getByText } = wrap(); + expect(getByText('@channel')).toBeTruthy(); + expect(getByText('mention/Channel Description')).toBeTruthy(); + }); + + it('renders a broadcast row for @here with description subtitle', () => { + const { getByText } = wrap(); + 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(); + 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(); + expect(getByText('@engineering')).toBeTruthy(); + expect(getByText('Engineering org')).toBeTruthy(); + }); + + it('omits the subtitle slot when a user group has no description', () => { + const { queryByText } = wrap( + , + ); + expect(queryByText('Engineering org')).toBeNull(); + }); + + it('renders nothing for an unknown mention type', () => { + const { toJSON } = wrap( + , + ); + 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(); + expect(getByText('Jane Doe')).toBeTruthy(); + }); + + it('renders nothing when neither tokenized payload nor fallback is provided', () => { + const { toJSON } = render(); + expect(toJSON()).toBeNull(); + }); + + it('renders all parts when the tokenized payload is present', () => { + const { queryByText } = render( + , + ); + // 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( + , + ); + // 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( + , + ); + 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/Message/Message.tsx b/package/src/components/Message/Message.tsx index aaecdc344d..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'; @@ -94,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'; 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/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(); + 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 ( - + {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 ( { shadowRadius: 12, }, suggestionsListContainer: { - backgroundColor: semantics.backgroundCoreElevation1, + backgroundColor: 'transparent', position: 'absolute', width: '100%', }, @@ -200,7 +200,6 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { AudioRecordingInProgress, AudioRecordingLockIndicator, AudioRecordingPreview, - AutoCompleteSuggestionList, Input, InputView, MessageComposerLeadingView, @@ -227,7 +226,6 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { inputBoxWrapper, inputContainer, inputFloatingContainer, - suggestionsListContainer: { container: suggestionListContainer }, wrapper, }, }, @@ -355,7 +353,12 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { layout: { height: newHeight }, }, }) => { - messageInputHeightStore.setHeight(newHeight); + messageInputHeightStore.setHeight( + newHeight - + (selectedPicker && !isKeyboardVisible + ? attachmentPickerBottomSheetHeight - bottomInset + : 0), + ); }} style={ messageInputFloating @@ -437,11 +440,6 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { )} - - - 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 | 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) => /> ) : null} + + + + + ); @@ -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(false); const { theme } = useTheme(); @@ -1363,6 +1375,22 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { /> ) : null} + + + + + ); 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 294d45e49d..e6b6c694a3 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap @@ -1715,6 +1715,30 @@ exports[`Thread should match thread snapshot 1`] = ` } } /> + + + + + - diff --git a/package/src/components/UIComponents/BottomSheetModal.tsx b/package/src/components/UIComponents/BottomSheetModal.tsx index 0d351833a7..84854be137 100644 --- a/package/src/components/UIComponents/BottomSheetModal.tsx +++ b/package/src/components/UIComponents/BottomSheetModal.tsx @@ -39,12 +39,12 @@ 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 = { /** @@ -544,25 +544,10 @@ const BottomSheetModalInner = (props: PropsWithChildren) 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( 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) => ( + + + + + + + + + + + +); + +const styles = StyleSheet.create({ + fade: { + bottom: 0, + height: CLIPPING_FADE_HEIGHT, + left: 0, + position: 'absolute', + right: 0, + }, +}); 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/index.ts b/package/src/components/UIComponents/index.ts index f08b5c01c6..0daf09d59d 100644 --- a/package/src/components/UIComponents/index.ts +++ b/package/src/components/UIComponents/index.ts @@ -1,4 +1,5 @@ export * from './BottomSheetModal'; +export * from './ClippingFadeBottom'; export * from './StreamBottomSheetModalFlatList'; export * from './EmptySearchResult'; export * from './ImageBackground'; diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 55ad9d66ad..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'; diff --git a/package/src/contexts/componentsContext/defaultComponents.ts b/package/src/contexts/componentsContext/defaultComponents.ts index 2d3f009af8..39536c7d55 100644 --- a/package/src/contexts/componentsContext/defaultComponents.ts +++ b/package/src/contexts/componentsContext/defaultComponents.ts @@ -24,7 +24,10 @@ 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'; @@ -196,6 +199,7 @@ const components = { AutoCompleteSuggestionHeader, AutoCompleteSuggestionItem, AutoCompleteSuggestionList, + MentionSuggestionItem, ChannelDetailsBottomSheet, CooldownTimer, CircularProgressIndicator, diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 479b622693..e1e1ff3eac 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -563,6 +563,10 @@ export type Theme = { avatarSize: number; column: ViewStyle; container: ViewStyle; + enhancedMentionContainer: ViewStyle; + enhancedMentionIcon: ViewStyle; + enhancedMentionSubtitle: TextStyle; + enhancedMentionTitle: TextStyle; name: TextStyle; tag: TextStyle; }; @@ -570,6 +574,7 @@ export type Theme = { suggestionsListContainer: { container: ViewStyle; flatlist: ViewStyle; + flatlistContentContainer: ViewStyle; }; videoAttachmentUploadPreview: { durationContainer: ViewStyle; @@ -1602,6 +1607,10 @@ export const defaultTheme: Theme = { avatarSize: 40, column: {}, container: {}, + enhancedMentionContainer: {}, + enhancedMentionIcon: {}, + enhancedMentionSubtitle: {}, + enhancedMentionTitle: {}, name: {}, tag: {}, }, @@ -1609,6 +1618,7 @@ export const defaultTheme: Theme = { suggestionsListContainer: { container: {}, flatlist: {}, + flatlistContentContainer: {}, }, wrapper: {}, linkPreviewList: { diff --git a/package/src/i18n/ar.json b/package/src/i18n/ar.json index a20ca3507b..16f27d4507 100644 --- a/package/src/i18n/ar.json +++ b/package/src/i18n/ar.json @@ -437,6 +437,12 @@ "a11y/Gallery Image": "صورة من المعرض", "a11y/Gallery Video": "فيديو من المعرض", "a11y/{{position}} of {{count}}": "{{position}} من {{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", "Pin Chat": "تثبيت الدردشة", "Pin Group": "تثبيت المجموعة", "Unpin Chat": "إلغاء تثبيت الدردشة", diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index 5278dcfae3..4b688732ce 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -440,5 +440,11 @@ "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 fe72e362bc..c44ffff63b 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -437,6 +437,12 @@ "a11y/Gallery Image": "Imagen de la galería", "a11y/Gallery Video": "Vídeo de la galería", "a11y/{{position}} of {{count}}": "{{position}} de {{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", "Pin Chat": "Fijar chat", "Pin Group": "Fijar grupo", "Unpin Chat": "Desfijar chat", diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index 719ad6ffa0..87f2079c40 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -437,6 +437,12 @@ "a11y/Gallery Image": "Image de la galerie", "a11y/Gallery Video": "Vidéo de la galerie", "a11y/{{position}} of {{count}}": "{{position}} sur {{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", "Pin Chat": "Épingler la discussion", "Pin Group": "Épingler le groupe", "Unpin Chat": "Détacher la discussion", diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index a00b0f6a34..8ae0ac8dfe 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -437,6 +437,12 @@ "a11y/Gallery Image": "תמונה מהגלריה", "a11y/Gallery Video": "סרטון מהגלריה", "a11y/{{position}} of {{count}}": "{{position}} מתוך {{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", "Pin Chat": "הצמד/י צ'אט", "Pin Group": "הצמד/י קבוצה", "Unpin Chat": "בטל/י הצמדת צ'אט", diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index fd1eb0ab95..a33b93270b 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -437,6 +437,12 @@ "a11y/Gallery Image": "गैलरी छवि", "a11y/Gallery Video": "गैलरी वीडियो", "a11y/{{position}} of {{count}}": "{{count}} में से {{position}}", + "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": "चैट अनपिन करें", diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index 632a13cf73..f31dda1329 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -437,6 +437,12 @@ "a11y/Gallery Image": "Immagine della galleria", "a11y/Gallery Video": "Video della galleria", "a11y/{{position}} of {{count}}": "{{position}} di {{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", "Pin Chat": "Fissa chat", "Pin Group": "Fissa gruppo", "Unpin Chat": "Sfissa chat", diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index dab0611eb7..637a0df201 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -437,6 +437,12 @@ "a11y/Gallery Image": "ギャラリー画像", "a11y/Gallery Video": "ギャラリービデオ", "a11y/{{position}} of {{count}}": "{{count}} 中 {{position}}", + "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": "チャットのピン留めを解除", diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index 1cb85ed25c..ffdf66af09 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -437,6 +437,12 @@ "a11y/Gallery Image": "갤러리 이미지", "a11y/Gallery Video": "갤러리 동영상", "a11y/{{position}} of {{count}}": "{{count}}개 중 {{position}}번째", + "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": "채팅 고정 해제", diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index 9a24789b48..9f4a83a73f 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -437,6 +437,12 @@ "a11y/Gallery Image": "Galerij-afbeelding", "a11y/Gallery Video": "Galerij-video", "a11y/{{position}} of {{count}}": "{{position}} van {{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", "Pin Chat": "Chat vastmaken", "Pin Group": "Groep vastmaken", "Unpin Chat": "Chat losmaken", diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index 0e2048d900..a22d73f790 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -437,6 +437,12 @@ "a11y/Gallery Image": "Imagem da galeria", "a11y/Gallery Video": "Vídeo da galeria", "a11y/{{position}} of {{count}}": "{{position}} de {{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", "Pin Chat": "Fixar conversa", "Pin Group": "Fixar grupo", "Unpin Chat": "Desafixar conversa", diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index 0f534afb8d..61e02679a7 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -437,6 +437,12 @@ "a11y/Gallery Image": "Изображение из галереи", "a11y/Gallery Video": "Видео из галереи", "a11y/{{position}} of {{count}}": "{{position}} из {{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", "Pin Chat": "Закрепить чат", "Pin Group": "Закрепить группу", "Unpin Chat": "Открепить чат", diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index aaaec2e523..1f14e578a5 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -437,6 +437,12 @@ "a11y/Gallery Image": "Galeri görüntüsü", "a11y/Gallery Video": "Galeri videosu", "a11y/{{position}} of {{count}}": "{{count}} öğeden {{position}}", + "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", 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 ( + + + + ); +}; 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 ( + + + + ); +}; 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/yarn.lock b/yarn.lock index 0fe6f28563..d1eaf455e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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.45.6" + stream-chat: "npm:^9.47.0" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:~5.9.2" @@ -18884,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.45.6" + stream-chat: "npm:^9.47.0" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:5.9.3" @@ -19611,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.45.6" + stream-chat: "npm:^9.47.0" typescript: "npm:5.9.3" use-sync-external-store: "npm:^1.5.0" uuid: "npm:^11.1.0" @@ -19683,9 +19683,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.45.6": - version: 9.45.6 - resolution: "stream-chat@npm:9.45.6" +"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.18.1" @@ -19701,7 +19701,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/b121144eb7aef34c989bb1702e0bc09a98bb4089f785d530c26721377d308eea9c8e7b11e387f66dd42a663dbeca76e2a8ba54b43ddb93496b4251bcc8c2df48 + checksum: 10c0/6f1a84f31047d0ccaf764ee106a276d7361b1582083c762cecd0e427d37ff6b9bfc69decaa856be0ab81d42f53beea12a231bc207d73dcbf28126abc9e28d9f3 languageName: node linkType: hard @@ -20536,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.45.6" + stream-chat: "npm:^9.47.0" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:5.9.3"