stream-chat-react-native-core 9.4.0-beta.6 → 9.4.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/lib/commonjs/components/ChannelDetails/hooks/useChannelDetailsActionItems.js +2 -1
  2. package/lib/commonjs/components/ChannelDetails/hooks/useChannelDetailsActionItems.js.map +1 -1
  3. package/lib/commonjs/components/ChannelList/ChannelList.js +4 -1
  4. package/lib/commonjs/components/ChannelList/ChannelList.js.map +1 -1
  5. package/lib/commonjs/components/ChannelList/hooks/useCreateChannelsContext.js +5 -3
  6. package/lib/commonjs/components/ChannelList/hooks/useCreateChannelsContext.js.map +1 -1
  7. package/lib/commonjs/components/ChannelPreview/ChannelPreview.js +3 -0
  8. package/lib/commonjs/components/ChannelPreview/ChannelPreview.js.map +1 -1
  9. package/lib/commonjs/components/ChannelPreview/ChannelPreviewPinnedStatus.js +30 -0
  10. package/lib/commonjs/components/ChannelPreview/ChannelPreviewPinnedStatus.js.map +1 -0
  11. package/lib/commonjs/components/ChannelPreview/ChannelPreviewView.js +8 -3
  12. package/lib/commonjs/components/ChannelPreview/ChannelPreviewView.js.map +1 -1
  13. package/lib/commonjs/components/ChannelPreview/ChannelSwipableWrapper.js +2 -1
  14. package/lib/commonjs/components/ChannelPreview/ChannelSwipableWrapper.js.map +1 -1
  15. package/lib/commonjs/components/ChannelPreview/hooks/index.js +11 -0
  16. package/lib/commonjs/components/ChannelPreview/hooks/index.js.map +1 -1
  17. package/lib/commonjs/components/ChannelPreview/hooks/useChannelPreviewData.js +3 -0
  18. package/lib/commonjs/components/ChannelPreview/hooks/useChannelPreviewData.js.map +1 -1
  19. package/lib/commonjs/components/ChannelPreview/hooks/useIsChannelPinned.js +11 -0
  20. package/lib/commonjs/components/ChannelPreview/hooks/useIsChannelPinned.js.map +1 -0
  21. package/lib/commonjs/components/index.js +11 -0
  22. package/lib/commonjs/components/index.js.map +1 -1
  23. package/lib/commonjs/contexts/channelsContext/ChannelsContext.js.map +1 -1
  24. package/lib/commonjs/contexts/componentsContext/defaultComponents.js +2 -0
  25. package/lib/commonjs/contexts/componentsContext/defaultComponents.js.map +1 -1
  26. package/lib/commonjs/contexts/themeContext/utils/theme.js +1 -0
  27. package/lib/commonjs/contexts/themeContext/utils/theme.js.map +1 -1
  28. package/lib/commonjs/hooks/actions/useChannelActionItems.js +20 -1
  29. package/lib/commonjs/hooks/actions/useChannelActionItems.js.map +1 -1
  30. package/lib/commonjs/hooks/actions/useChannelActionItemsById.js +3 -1
  31. package/lib/commonjs/hooks/actions/useChannelActionItemsById.js.map +1 -1
  32. package/lib/commonjs/i18n/ar.json +6 -1
  33. package/lib/commonjs/i18n/en.json +5 -0
  34. package/lib/commonjs/i18n/es.json +6 -1
  35. package/lib/commonjs/i18n/fr.json +6 -1
  36. package/lib/commonjs/i18n/he.json +6 -1
  37. package/lib/commonjs/i18n/hi.json +6 -1
  38. package/lib/commonjs/i18n/it.json +6 -1
  39. package/lib/commonjs/i18n/ja.json +6 -1
  40. package/lib/commonjs/i18n/ko.json +6 -1
  41. package/lib/commonjs/i18n/nl.json +6 -1
  42. package/lib/commonjs/i18n/pt-br.json +6 -1
  43. package/lib/commonjs/i18n/ru.json +6 -1
  44. package/lib/commonjs/i18n/tr.json +6 -1
  45. package/lib/commonjs/version.json +1 -1
  46. package/lib/module/components/ChannelDetails/hooks/useChannelDetailsActionItems.js +2 -1
  47. package/lib/module/components/ChannelDetails/hooks/useChannelDetailsActionItems.js.map +1 -1
  48. package/lib/module/components/ChannelList/ChannelList.js +4 -1
  49. package/lib/module/components/ChannelList/ChannelList.js.map +1 -1
  50. package/lib/module/components/ChannelList/hooks/useCreateChannelsContext.js +5 -3
  51. package/lib/module/components/ChannelList/hooks/useCreateChannelsContext.js.map +1 -1
  52. package/lib/module/components/ChannelPreview/ChannelPreview.js +3 -0
  53. package/lib/module/components/ChannelPreview/ChannelPreview.js.map +1 -1
  54. package/lib/module/components/ChannelPreview/ChannelPreviewPinnedStatus.js +30 -0
  55. package/lib/module/components/ChannelPreview/ChannelPreviewPinnedStatus.js.map +1 -0
  56. package/lib/module/components/ChannelPreview/ChannelPreviewView.js +8 -3
  57. package/lib/module/components/ChannelPreview/ChannelPreviewView.js.map +1 -1
  58. package/lib/module/components/ChannelPreview/ChannelSwipableWrapper.js +2 -1
  59. package/lib/module/components/ChannelPreview/ChannelSwipableWrapper.js.map +1 -1
  60. package/lib/module/components/ChannelPreview/hooks/index.js +11 -0
  61. package/lib/module/components/ChannelPreview/hooks/index.js.map +1 -1
  62. package/lib/module/components/ChannelPreview/hooks/useChannelPreviewData.js +3 -0
  63. package/lib/module/components/ChannelPreview/hooks/useChannelPreviewData.js.map +1 -1
  64. package/lib/module/components/ChannelPreview/hooks/useIsChannelPinned.js +11 -0
  65. package/lib/module/components/ChannelPreview/hooks/useIsChannelPinned.js.map +1 -0
  66. package/lib/module/components/index.js +11 -0
  67. package/lib/module/components/index.js.map +1 -1
  68. package/lib/module/contexts/channelsContext/ChannelsContext.js.map +1 -1
  69. package/lib/module/contexts/componentsContext/defaultComponents.js +2 -0
  70. package/lib/module/contexts/componentsContext/defaultComponents.js.map +1 -1
  71. package/lib/module/contexts/themeContext/utils/theme.js +1 -0
  72. package/lib/module/contexts/themeContext/utils/theme.js.map +1 -1
  73. package/lib/module/hooks/actions/useChannelActionItems.js +20 -1
  74. package/lib/module/hooks/actions/useChannelActionItems.js.map +1 -1
  75. package/lib/module/hooks/actions/useChannelActionItemsById.js +3 -1
  76. package/lib/module/hooks/actions/useChannelActionItemsById.js.map +1 -1
  77. package/lib/module/i18n/ar.json +6 -1
  78. package/lib/module/i18n/en.json +5 -0
  79. package/lib/module/i18n/es.json +6 -1
  80. package/lib/module/i18n/fr.json +6 -1
  81. package/lib/module/i18n/he.json +6 -1
  82. package/lib/module/i18n/hi.json +6 -1
  83. package/lib/module/i18n/it.json +6 -1
  84. package/lib/module/i18n/ja.json +6 -1
  85. package/lib/module/i18n/ko.json +6 -1
  86. package/lib/module/i18n/nl.json +6 -1
  87. package/lib/module/i18n/pt-br.json +6 -1
  88. package/lib/module/i18n/ru.json +6 -1
  89. package/lib/module/i18n/tr.json +6 -1
  90. package/lib/module/version.json +1 -1
  91. package/lib/typescript/components/ChannelList/ChannelList.d.ts +1 -1
  92. package/lib/typescript/components/ChannelList/ChannelList.d.ts.map +1 -1
  93. package/lib/typescript/components/ChannelList/hooks/useCreateChannelsContext.d.ts +1 -1
  94. package/lib/typescript/components/ChannelList/hooks/useCreateChannelsContext.d.ts.map +1 -1
  95. package/lib/typescript/components/ChannelPreview/ChannelPreview.d.ts.map +1 -1
  96. package/lib/typescript/components/ChannelPreview/ChannelPreviewPinnedStatus.d.ts +6 -0
  97. package/lib/typescript/components/ChannelPreview/ChannelPreviewPinnedStatus.d.ts.map +1 -0
  98. package/lib/typescript/components/ChannelPreview/ChannelPreviewView.d.ts +3 -1
  99. package/lib/typescript/components/ChannelPreview/ChannelPreviewView.d.ts.map +1 -1
  100. package/lib/typescript/components/ChannelPreview/ChannelSwipableWrapper.d.ts.map +1 -1
  101. package/lib/typescript/components/ChannelPreview/hooks/index.d.ts +1 -0
  102. package/lib/typescript/components/ChannelPreview/hooks/index.d.ts.map +1 -1
  103. package/lib/typescript/components/ChannelPreview/hooks/useChannelPreviewData.d.ts +1 -0
  104. package/lib/typescript/components/ChannelPreview/hooks/useChannelPreviewData.d.ts.map +1 -1
  105. package/lib/typescript/components/ChannelPreview/hooks/useIsChannelPinned.d.ts +3 -0
  106. package/lib/typescript/components/ChannelPreview/hooks/useIsChannelPinned.d.ts.map +1 -0
  107. package/lib/typescript/components/index.d.ts +1 -0
  108. package/lib/typescript/components/index.d.ts.map +1 -1
  109. package/lib/typescript/contexts/channelsContext/ChannelsContext.d.ts +1 -0
  110. package/lib/typescript/contexts/channelsContext/ChannelsContext.d.ts.map +1 -1
  111. package/lib/typescript/contexts/componentsContext/defaultComponents.d.ts +1 -0
  112. package/lib/typescript/contexts/componentsContext/defaultComponents.d.ts.map +1 -1
  113. package/lib/typescript/contexts/themeContext/ThemeContext.d.ts +1 -0
  114. package/lib/typescript/contexts/themeContext/ThemeContext.d.ts.map +1 -1
  115. package/lib/typescript/contexts/themeContext/utils/theme.d.ts +1 -0
  116. package/lib/typescript/contexts/themeContext/utils/theme.d.ts.map +1 -1
  117. package/lib/typescript/hooks/actions/useChannelActionItems.d.ts +35 -2
  118. package/lib/typescript/hooks/actions/useChannelActionItems.d.ts.map +1 -1
  119. package/lib/typescript/hooks/actions/useChannelActionItemsById.d.ts +3 -2
  120. package/lib/typescript/hooks/actions/useChannelActionItemsById.d.ts.map +1 -1
  121. package/lib/typescript/i18n/ar.json +6 -1
  122. package/lib/typescript/i18n/en.json +5 -0
  123. package/lib/typescript/i18n/es.json +6 -1
  124. package/lib/typescript/i18n/fr.json +6 -1
  125. package/lib/typescript/i18n/he.json +6 -1
  126. package/lib/typescript/i18n/hi.json +6 -1
  127. package/lib/typescript/i18n/it.json +6 -1
  128. package/lib/typescript/i18n/ja.json +6 -1
  129. package/lib/typescript/i18n/ko.json +6 -1
  130. package/lib/typescript/i18n/nl.json +6 -1
  131. package/lib/typescript/i18n/pt-br.json +6 -1
  132. package/lib/typescript/i18n/ru.json +6 -1
  133. package/lib/typescript/i18n/tr.json +6 -1
  134. package/lib/typescript/utils/i18n/Streami18n.d.ts +5 -0
  135. package/lib/typescript/utils/i18n/Streami18n.d.ts.map +1 -1
  136. package/package.json +1 -1
  137. package/src/components/ChannelDetails/__tests__/useChannelDetailsActionItems.test.tsx +6 -2
  138. package/src/components/ChannelDetails/hooks/useChannelDetailsActionItems.ts +1 -1
  139. package/src/components/ChannelList/ChannelList.tsx +3 -0
  140. package/src/components/ChannelList/hooks/useCreateChannelsContext.ts +3 -0
  141. package/src/components/ChannelPreview/ChannelPreview.tsx +21 -3
  142. package/src/components/ChannelPreview/ChannelPreviewPinnedStatus.tsx +25 -0
  143. package/src/components/ChannelPreview/ChannelPreviewView.tsx +18 -2
  144. package/src/components/ChannelPreview/ChannelSwipableWrapper.tsx +5 -1
  145. package/src/components/ChannelPreview/hooks/__tests__/useIsChannelPinned.test.tsx +35 -0
  146. package/src/components/ChannelPreview/hooks/index.ts +1 -0
  147. package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts +3 -1
  148. package/src/components/ChannelPreview/hooks/useIsChannelPinned.ts +8 -0
  149. package/src/components/index.ts +1 -0
  150. package/src/contexts/channelsContext/ChannelsContext.tsx +1 -0
  151. package/src/contexts/componentsContext/defaultComponents.ts +2 -0
  152. package/src/contexts/themeContext/utils/theme.ts +2 -0
  153. package/src/hooks/actions/__tests__/useChannelActionItems.test.tsx +137 -14
  154. package/src/hooks/actions/__tests__/useChannelActionItemsById.test.tsx +3 -1
  155. package/src/hooks/actions/useChannelActionItems.tsx +60 -2
  156. package/src/hooks/actions/useChannelActionItemsById.ts +8 -1
  157. package/src/i18n/ar.json +6 -1
  158. package/src/i18n/en.json +5 -0
  159. package/src/i18n/es.json +6 -1
  160. package/src/i18n/fr.json +6 -1
  161. package/src/i18n/he.json +6 -1
  162. package/src/i18n/hi.json +6 -1
  163. package/src/i18n/it.json +6 -1
  164. package/src/i18n/ja.json +6 -1
  165. package/src/i18n/ko.json +6 -1
  166. package/src/i18n/nl.json +6 -1
  167. package/src/i18n/pt-br.json +6 -1
  168. package/src/i18n/ru.json +6 -1
  169. package/src/i18n/tr.json +6 -1
  170. package/src/version.json +1 -1
@@ -41,6 +41,7 @@ export type ChannelListProps = Partial<
41
41
  | 'maxUnreadCount'
42
42
  | 'numberOfSkeletons'
43
43
  | 'mutedStatusPosition'
44
+ | 'pinnedStatusPosition'
44
45
  >
45
46
  > & {
46
47
  /** 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) => {
252
253
  queryChannelsOverride,
253
254
  notificationHostId: notificationHostIdProp,
254
255
  mutedStatusPosition = 'inlineTitle',
256
+ pinnedStatusPosition = 'inlineTitle',
255
257
  swipeActionsEnabled = true,
256
258
  } = props;
257
259
 
@@ -375,6 +377,7 @@ export const ChannelList = (props: ChannelListProps) => {
375
377
  }
376
378
  },
377
379
  mutedStatusPosition,
380
+ pinnedStatusPosition,
378
381
  });
379
382
 
380
383
  return (
@@ -23,6 +23,7 @@ export const useCreateChannelsContext = ({
23
23
  reloadList,
24
24
  setFlatListRef,
25
25
  mutedStatusPosition,
26
+ pinnedStatusPosition,
26
27
  }: ChannelsContextValue) => {
27
28
  const channelValueString = channels
28
29
  ?.map(
@@ -56,6 +57,7 @@ export const useCreateChannelsContext = ({
56
57
  reloadList,
57
58
  setFlatListRef,
58
59
  mutedStatusPosition,
60
+ pinnedStatusPosition,
59
61
  }),
60
62
  // eslint-disable-next-line react-hooks/exhaustive-deps
61
63
  [
@@ -69,6 +71,7 @@ export const useCreateChannelsContext = ({
69
71
  swipeActionsEnabled,
70
72
  refreshing,
71
73
  mutedStatusPosition,
74
+ pinnedStatusPosition,
72
75
  ],
73
76
  );
74
77
 
@@ -30,19 +30,37 @@ export const ChannelPreview = (props: ChannelPreviewProps) => {
30
30
 
31
31
  const client = propClient || contextClient;
32
32
 
33
- const { muted, unread, lastMessage } = useChannelPreviewData(channel, client, propForceUpdate);
33
+ const { muted, pinned, unread, lastMessage } = useChannelPreviewData(
34
+ channel,
35
+ client,
36
+ propForceUpdate,
37
+ );
34
38
 
35
39
  const translatedLastMessage = useTranslatedMessage(lastMessage);
36
40
 
37
41
  const message = translatedLastMessage ? translatedLastMessage : lastMessage;
38
42
 
39
43
  if (!swipeActionsEnabled) {
40
- return <ChannelPreview channel={channel} muted={muted} unread={unread} lastMessage={message} />;
44
+ return (
45
+ <ChannelPreview
46
+ channel={channel}
47
+ muted={muted}
48
+ pinned={pinned}
49
+ unread={unread}
50
+ lastMessage={message}
51
+ />
52
+ );
41
53
  }
42
54
 
43
55
  return (
44
56
  <ChannelSwipableWrapper channel={channel} getChannelActionItems={getChannelActionItems}>
45
- <ChannelPreview channel={channel} muted={muted} unread={unread} lastMessage={message} />
57
+ <ChannelPreview
58
+ channel={channel}
59
+ muted={muted}
60
+ pinned={pinned}
61
+ unread={unread}
62
+ lastMessage={message}
63
+ />
46
64
  </ChannelSwipableWrapper>
47
65
  );
48
66
  };
@@ -0,0 +1,25 @@
1
+ import React from 'react';
2
+
3
+ import { useA11yLabel } from '../../a11y/hooks/useA11yLabel';
4
+ import { useTheme } from '../../contexts/themeContext/ThemeContext';
5
+ import { Pin } from '../../icons';
6
+ import { CompositeAccessibilityProbe } from '../Accessibility/CompositeAccessibilityProbe';
7
+
8
+ /**
9
+ * This UI component displays a pinned indicator for a particular channel.
10
+ */
11
+ export const ChannelPreviewPinnedStatus = () => {
12
+ const {
13
+ theme: {
14
+ channelPreview: { pinnedStatus },
15
+ semantics,
16
+ },
17
+ } = useTheme();
18
+ const accessibilityLabel = useA11yLabel('a11y/Pinned');
19
+
20
+ return (
21
+ <CompositeAccessibilityProbe label={accessibilityLabel}>
22
+ <Pin height={20} stroke={semantics.textTertiary} width={20} {...pinnedStatus} />
23
+ </CompositeAccessibilityProbe>
24
+ );
25
+ };
@@ -17,7 +17,10 @@ import { useStableCallback } from '../../hooks';
17
17
  import { primitives } from '../../theme';
18
18
 
19
19
  export type ChannelPreviewViewPropsWithContext = Pick<ChannelPreviewProps, 'channel'> &
20
- Pick<ChannelsContextValue, 'maxUnreadCount' | 'onSelect' | 'mutedStatusPosition'> & {
20
+ Pick<
21
+ ChannelsContextValue,
22
+ 'maxUnreadCount' | 'onSelect' | 'mutedStatusPosition' | 'pinnedStatusPosition'
23
+ > & {
21
24
  /**
22
25
  * Formatter function for date of latest message.
23
26
  * @param date Message date
@@ -30,6 +33,8 @@ export type ChannelPreviewViewPropsWithContext = Pick<ChannelPreviewProps, 'chan
30
33
  formatLatestMessageDate?: (date: Date) => string;
31
34
  /** If the channel is muted. */
32
35
  muted?: boolean;
36
+ /** If the channel is pinned for the current user. */
37
+ pinned?: boolean;
33
38
  /** Number of unread messages on the channel */
34
39
  unread?: number;
35
40
  lastMessage?: LastMessageType;
@@ -42,14 +47,17 @@ const ChannelPreviewViewWithContext = (props: ChannelPreviewViewPropsWithContext
42
47
  maxUnreadCount,
43
48
  muted,
44
49
  onSelect,
50
+ pinned,
45
51
  unread,
46
52
  mutedStatusPosition,
53
+ pinnedStatusPosition,
47
54
  lastMessage,
48
55
  } = props;
49
56
  const {
50
57
  ChannelPreviewAvatar,
51
58
  ChannelPreviewMessage,
52
59
  ChannelPreviewMutedStatus,
60
+ ChannelPreviewPinnedStatus,
53
61
  ChannelPreviewStatus,
54
62
  ChannelPreviewTitle,
55
63
  ChannelPreviewUnreadCount,
@@ -111,6 +119,9 @@ const ChannelPreviewViewWithContext = (props: ChannelPreviewViewPropsWithContext
111
119
  {muted && mutedStatusPosition === 'inlineTitle' ? (
112
120
  <ChannelPreviewMutedStatus />
113
121
  ) : null}
122
+ {pinned && pinnedStatusPosition === 'inlineTitle' ? (
123
+ <ChannelPreviewPinnedStatus />
124
+ ) : null}
114
125
  </View>
115
126
 
116
127
  <View style={[styles.statusContainer, statusContainer]}>
@@ -132,6 +143,9 @@ const ChannelPreviewViewWithContext = (props: ChannelPreviewViewPropsWithContext
132
143
  {muted && mutedStatusPosition === 'trailingBottom' ? (
133
144
  <ChannelPreviewMutedStatus />
134
145
  ) : null}
146
+ {pinned && pinnedStatusPosition === 'trailingBottom' ? (
147
+ <ChannelPreviewPinnedStatus />
148
+ ) : null}
135
149
  </View>
136
150
  </View>
137
151
  </Pressable>
@@ -151,7 +165,8 @@ const MemoizedChannelPreviewViewWithContext = React.memo(
151
165
  * from the ChannelPreview component.
152
166
  */
153
167
  export const ChannelPreviewView = (props: ChannelPreviewViewProps) => {
154
- const { forceUpdate, maxUnreadCount, onSelect, mutedStatusPosition } = useChannelsContext();
168
+ const { forceUpdate, maxUnreadCount, onSelect, mutedStatusPosition, pinnedStatusPosition } =
169
+ useChannelsContext();
155
170
  return (
156
171
  <MemoizedChannelPreviewViewWithContext
157
172
  {...{
@@ -159,6 +174,7 @@ export const ChannelPreviewView = (props: ChannelPreviewViewProps) => {
159
174
  maxUnreadCount,
160
175
  onSelect,
161
176
  mutedStatusPosition,
177
+ pinnedStatusPosition,
162
178
  }}
163
179
  {...props}
164
180
  />
@@ -41,7 +41,11 @@ export const ChannelSwipableWrapper = ({
41
41
  }>) => {
42
42
  const { ChannelDetailsBottomSheet: ChannelDetailsBottomSheetComponent } = useComponentsContext();
43
43
  const [channelDetailSheetOpen, setChannelDetailSheetOpen] = useState(false);
44
- const channelActionItems = useChannelActionItems({ channel, getChannelActionItems });
44
+ const channelActionItems = useChannelActionItems({
45
+ channel,
46
+ getChannelActionItems,
47
+ surface: 'list',
48
+ });
45
49
  const sheetItems = useMemo(
46
50
  () => channelActionItems.filter((item) => item.placement !== 'swipe'),
47
51
  [channelActionItems],
@@ -0,0 +1,35 @@
1
+ import { renderHook } from '@testing-library/react-native';
2
+ import { Channel } from 'stream-chat';
3
+
4
+ import { useIsChannelPinned } from '../useIsChannelPinned';
5
+
6
+ describe('useIsChannelPinned', () => {
7
+ afterEach(() => {
8
+ jest.clearAllMocks();
9
+ });
10
+
11
+ const buildMockChannel = (membership: Record<string, unknown> = {}) =>
12
+ ({
13
+ initialized: true,
14
+ on: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }),
15
+ state: { membership },
16
+ }) as unknown as Channel;
17
+
18
+ it('returns false when membership has no pinned_at', () => {
19
+ const channel = buildMockChannel({ pinned_at: null });
20
+ const { result } = renderHook(() => useIsChannelPinned(channel));
21
+ expect(result.current).toBe(false);
22
+ });
23
+
24
+ it('returns true when membership has a pinned_at timestamp', () => {
25
+ const channel = buildMockChannel({ pinned_at: '2026-06-15T08:00:00.000Z' });
26
+ const { result } = renderHook(() => useIsChannelPinned(channel));
27
+ expect(result.current).toBe(true);
28
+ });
29
+
30
+ it('subscribes to member.updated events', () => {
31
+ const channel = buildMockChannel({ pinned_at: null });
32
+ renderHook(() => useIsChannelPinned(channel));
33
+ expect(channel.on).toHaveBeenCalledWith('member.updated', expect.any(Function));
34
+ });
35
+ });
@@ -4,4 +4,5 @@ export * from './useChannelPreviewPollLabel';
4
4
  export * from './useChannelPreviewDisplayName';
5
5
  export * from './useChannelPreviewDisplayPresence';
6
6
  export * from './useIsChannelMuted';
7
+ export * from './useIsChannelPinned';
7
8
  export * from './useChannelTypingState';
@@ -4,6 +4,7 @@ import throttle from 'lodash/throttle';
4
4
  import type { Channel, Event, LocalMessage, MessageResponse, StreamChat } from 'stream-chat';
5
5
 
6
6
  import { useIsChannelMuted } from './useIsChannelMuted';
7
+ import { useIsChannelPinned } from './useIsChannelPinned';
7
8
 
8
9
  import { useChannelsContext } from '../../../contexts';
9
10
  import { useStableCallback } from '../../../hooks';
@@ -39,6 +40,7 @@ export const useChannelPreviewData = (
39
40
  );
40
41
  const [unread, setUnread] = useState(channel.countUnread());
41
42
  const { muted } = useIsChannelMuted(channel);
43
+ const pinned = useIsChannelPinned(channel);
42
44
  const { forceUpdate: contextForceUpdate } = useChannelsContext();
43
45
  const channelListForceUpdate = forceUpdateOverride ?? contextForceUpdate;
44
46
 
@@ -170,5 +172,5 @@ export const useChannelPreviewData = (
170
172
  return () => listeners.forEach((l) => l.unsubscribe());
171
173
  }, [channel, refreshUnreadCount, forceUpdate, channelListForceUpdate, setLastMessage]);
172
174
 
173
- return { lastMessage, muted, unread };
175
+ return { lastMessage, muted, pinned, unread };
174
176
  };
@@ -0,0 +1,8 @@
1
+ import type { Channel } from 'stream-chat';
2
+
3
+ import { useChannelMembershipState } from '../../../hooks/useChannelMembershipState';
4
+
5
+ export const useIsChannelPinned = (channel: Channel) => {
6
+ const membership = useChannelMembershipState(channel);
7
+ return Boolean(membership?.pinned_at);
8
+ };
@@ -56,6 +56,7 @@ export * from './ChannelPreview/ChannelPreview';
56
56
  export * from './ChannelPreview/ChannelPreviewMessage';
57
57
  export * from './ChannelPreview/ChannelPreviewView';
58
58
  export * from './ChannelPreview/ChannelPreviewMutedStatus';
59
+ export * from './ChannelPreview/ChannelPreviewPinnedStatus';
59
60
  export * from './ChannelPreview/ChannelLastMessagePreview';
60
61
  export * from './ChannelPreview/ChannelPreviewStatus';
61
62
  export * from './ChannelPreview/ChannelPreviewTitle';
@@ -118,6 +118,7 @@ export type ChannelsContextValue = {
118
118
  swipeActionsEnabled?: boolean;
119
119
 
120
120
  mutedStatusPosition?: 'trailingBottom' | 'inlineTitle';
121
+ pinnedStatusPosition?: 'trailingBottom' | 'inlineTitle';
121
122
  };
122
123
 
123
124
  export const ChannelsContext = React.createContext(
@@ -53,6 +53,7 @@ import { ChannelLastMessagePreview } from '../../components/ChannelPreview/Chann
53
53
  import { ChannelMessagePreviewDeliveryStatus } from '../../components/ChannelPreview/ChannelMessagePreviewDeliveryStatus';
54
54
  import { ChannelPreviewMessage } from '../../components/ChannelPreview/ChannelPreviewMessage';
55
55
  import { ChannelPreviewMutedStatus } from '../../components/ChannelPreview/ChannelPreviewMutedStatus';
56
+ import { ChannelPreviewPinnedStatus } from '../../components/ChannelPreview/ChannelPreviewPinnedStatus';
56
57
  import { ChannelPreviewStatus } from '../../components/ChannelPreview/ChannelPreviewStatus';
57
58
  import { ChannelPreviewTitle } from '../../components/ChannelPreview/ChannelPreviewTitle';
58
59
  import { ChannelPreviewTypingIndicator } from '../../components/ChannelPreview/ChannelPreviewTypingIndicator';
@@ -271,6 +272,7 @@ const components = {
271
272
  ChannelPreviewMessage,
272
273
  ChannelPreviewMessageDeliveryStatus: ChannelMessagePreviewDeliveryStatus,
273
274
  ChannelPreviewMutedStatus,
275
+ ChannelPreviewPinnedStatus,
274
276
  ChannelPreviewStatus,
275
277
  ChannelPreviewTitle,
276
278
  ChannelPreviewTypingIndicator,
@@ -299,6 +299,7 @@ export type Theme = {
299
299
  contentContainer: ViewStyle;
300
300
  date: TextStyle;
301
301
  mutedStatus: IconProps;
302
+ pinnedStatus: IconProps;
302
303
  messageDeliveryStatus: {
303
304
  container: ViewStyle;
304
305
  text: TextStyle;
@@ -1357,6 +1358,7 @@ export const defaultTheme: Theme = {
1357
1358
  timeIcon: {},
1358
1359
  },
1359
1360
  mutedStatus: {},
1361
+ pinnedStatus: {},
1360
1362
  lowerRow: {},
1361
1363
  title: {},
1362
1364
  unreadContainer: {},
@@ -99,32 +99,45 @@ describe('useChannelActionItems', () => {
99
99
  });
100
100
 
101
101
  it('returns default channel action items', () => {
102
- const { result } = renderHook(() => useChannelActionItems({ channel }));
102
+ const { result } = renderHook(() => useChannelActionItems({ channel, surface: 'list' }));
103
103
 
104
- expect(result.current).toHaveLength(3);
104
+ expect(result.current).toHaveLength(4);
105
105
  expect(result.current.map((item) => item.action)).toEqual([
106
106
  channelActions.muteChannel,
107
+ channelActions.pin,
107
108
  channelActions.leave,
108
109
  expect.any(Function),
109
110
  ]);
110
- expect(result.current.map((item) => item.id)).toEqual(['mute', 'leave', 'deleteChannel']);
111
+ expect(result.current.map((item) => item.id)).toEqual([
112
+ 'mute',
113
+ 'pin',
114
+ 'leave',
115
+ 'deleteChannel',
116
+ ]);
111
117
  expect(result.current.map((item) => item.type)).toEqual([
118
+ 'standard',
112
119
  'standard',
113
120
  'destructive',
114
121
  'destructive',
115
122
  ]);
116
- expect(result.current.map((item) => item.placement)).toEqual(['swipe', 'sheet', 'sheet']);
123
+ expect(result.current.map((item) => item.placement)).toEqual([
124
+ 'swipe',
125
+ 'sheet',
126
+ 'sheet',
127
+ 'sheet',
128
+ ]);
117
129
  });
118
130
 
119
131
  it('returns muteUser only in direct chats', () => {
120
132
  jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(true);
121
133
 
122
- const { result } = renderHook(() => useChannelActionItems({ channel }));
134
+ const { result } = renderHook(() => useChannelActionItems({ channel, surface: 'list' }));
123
135
 
124
136
  expect(result.current.map((item) => item.id)).toEqual([
125
137
  'mute',
126
138
  'muteUser',
127
139
  'block',
140
+ 'pin',
128
141
  'leave',
129
142
  'deleteChannel',
130
143
  ]);
@@ -143,7 +156,7 @@ describe('useChannelActionItems', () => {
143
156
  });
144
157
  jest.spyOn(useMutedUsersModule, 'useMutedUsers').mockReturnValue([] as Mute[]);
145
158
 
146
- const { result } = renderHook(() => useChannelActionItems({ channel }));
159
+ const { result } = renderHook(() => useChannelActionItems({ channel, surface: 'list' }));
147
160
 
148
161
  const muteItem = result.current.find((item) => item.id === 'mute');
149
162
  const muteUserItem = result.current.find((item) => item.id === 'muteUser');
@@ -152,7 +165,7 @@ describe('useChannelActionItems', () => {
152
165
  });
153
166
 
154
167
  it('forwards options from item.action to the underlying channel action', async () => {
155
- const { result } = renderHook(() => useChannelActionItems({ channel }));
168
+ const { result } = renderHook(() => useChannelActionItems({ channel, surface: 'list' }));
156
169
 
157
170
  const muteItem = result.current.find((item) => item.id === 'mute');
158
171
  expect(muteItem).toBeDefined();
@@ -165,7 +178,7 @@ describe('useChannelActionItems', () => {
165
178
  it('marks block as destructive when user is not blocked', () => {
166
179
  jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(true);
167
180
 
168
- const { result } = renderHook(() => useChannelActionItems({ channel }));
181
+ const { result } = renderHook(() => useChannelActionItems({ channel, surface: 'list' }));
169
182
 
170
183
  const blockItem = result.current.find((item) => item.id === 'block');
171
184
  expect(blockItem?.type).toBe('destructive');
@@ -175,7 +188,9 @@ describe('useChannelActionItems', () => {
175
188
  jest.spyOn(useIsDirectChatModule, 'useIsDirectChat').mockReturnValue(true);
176
189
  const blockedChannel = createChannelMock({ blockedUserIds: ['other-user-id'] });
177
190
 
178
- const { result } = renderHook(() => useChannelActionItems({ channel: blockedChannel }));
191
+ const { result } = renderHook(() =>
192
+ useChannelActionItems({ channel: blockedChannel, surface: 'list' }),
193
+ );
179
194
 
180
195
  const blockItem = result.current.find((item) => item.id === 'block');
181
196
  expect(blockItem?.type).toBe('standard');
@@ -190,6 +205,7 @@ describe('useChannelActionItems', () => {
190
205
  useChannelActionItems({
191
206
  channel,
192
207
  getChannelActionItems: customGetChannelActionItems,
208
+ surface: 'list',
193
209
  }),
194
210
  );
195
211
 
@@ -202,6 +218,7 @@ describe('useChannelActionItems', () => {
202
218
  isBlocked: undefined,
203
219
  isDirectChat: false,
204
220
  isPinned: false,
221
+ surface: 'list',
205
222
  t: expect.any(Function),
206
223
  userMuteActive: false,
207
224
  },
@@ -228,6 +245,7 @@ describe('getChannelActionItems', () => {
228
245
  isBlocked: undefined,
229
246
  isDirectChat: false,
230
247
  isPinned: false,
248
+ surface: 'list',
231
249
  t: ((value: string) => value) as TranslationContextValue['t'],
232
250
  userMuteActive: false,
233
251
  });
@@ -240,6 +258,7 @@ describe('getChannelActionItems', () => {
240
258
  isBlocked: undefined,
241
259
  isDirectChat: false,
242
260
  isPinned: false,
261
+ surface: 'list',
243
262
  t: ((value: string) => value) as TranslationContextValue['t'],
244
263
  userMuteActive: false,
245
264
  },
@@ -248,11 +267,13 @@ describe('getChannelActionItems', () => {
248
267
 
249
268
  expect(actionItems.map((item) => item.action)).toEqual([
250
269
  channelActions.muteChannel,
270
+ channelActions.pin,
251
271
  channelActions.leave,
252
272
  expect.any(Function),
253
273
  ]);
254
- expect(actionItems.map((item) => item.id)).toEqual(['mute', 'leave', 'deleteChannel']);
274
+ expect(actionItems.map((item) => item.id)).toEqual(['mute', 'pin', 'leave', 'deleteChannel']);
255
275
  expect(actionItems.map((item) => item.type)).toEqual([
276
+ 'standard',
256
277
  'standard',
257
278
  'destructive',
258
279
  'destructive',
@@ -269,6 +290,7 @@ describe('getChannelActionItems', () => {
269
290
  isBlocked: true,
270
291
  isDirectChat: true,
271
292
  isPinned: false,
293
+ surface: 'list',
272
294
  t: ((value: string) => value) as TranslationContextValue['t'],
273
295
  userMuteActive: true,
274
296
  });
@@ -277,6 +299,7 @@ describe('getChannelActionItems', () => {
277
299
  'mute',
278
300
  'muteUser',
279
301
  'block',
302
+ 'pin',
280
303
  'leave',
281
304
  'deleteChannel',
282
305
  ]);
@@ -284,6 +307,7 @@ describe('getChannelActionItems', () => {
284
307
  channelActions.unmuteChannel,
285
308
  channelActions.unmuteUser,
286
309
  channelActions.unblockUser,
310
+ channelActions.pin,
287
311
  channelActions.leave,
288
312
  expect.any(Function),
289
313
  ]);
@@ -291,6 +315,7 @@ describe('getChannelActionItems', () => {
291
315
  'Unmute Chat',
292
316
  'Unmute User',
293
317
  'Unblock User',
318
+ 'Pin Chat',
294
319
  'Leave Chat',
295
320
  'Delete Chat',
296
321
  ]);
@@ -300,6 +325,7 @@ describe('getChannelActionItems', () => {
300
325
  'sheet',
301
326
  'sheet',
302
327
  'sheet',
328
+ 'sheet',
303
329
  ]);
304
330
  });
305
331
 
@@ -312,6 +338,7 @@ describe('getChannelActionItems', () => {
312
338
  isBlocked: undefined,
313
339
  isDirectChat: false,
314
340
  isPinned: false,
341
+ surface: 'list',
315
342
  t: ((value: string) => value) as TranslationContextValue['t'],
316
343
  userMuteActive: false,
317
344
  });
@@ -328,11 +355,12 @@ describe('getChannelActionItems', () => {
328
355
  isBlocked: undefined,
329
356
  isDirectChat: false,
330
357
  isPinned: false,
358
+ surface: 'list',
331
359
  t: ((value: string) => value) as TranslationContextValue['t'],
332
360
  userMuteActive: false,
333
361
  });
334
362
 
335
- expect(actionItems.map((item) => item.id)).toEqual(['mute', 'leave']);
363
+ expect(actionItems.map((item) => item.id)).toEqual(['mute', 'pin', 'leave']);
336
364
  });
337
365
 
338
366
  it('uses group mute variants for labels and placements', () => {
@@ -345,6 +373,7 @@ describe('getChannelActionItems', () => {
345
373
  isBlocked: undefined,
346
374
  isDirectChat: false,
347
375
  isPinned: false,
376
+ surface: 'list',
348
377
  t: ((value: string) => value) as TranslationContextValue['t'],
349
378
  userMuteActive: false,
350
379
  });
@@ -353,9 +382,101 @@ describe('getChannelActionItems', () => {
353
382
  expect(actionItems[0].label).toBe('Unmute Group');
354
383
  expect(actionItems[0].placement).toBe('swipe');
355
384
 
356
- expect(actionItems[1].action).toBe(channelActions.leave);
357
- expect(actionItems[1].label).toBe('Leave Group');
358
- expect(actionItems[1].placement).toBe('sheet');
385
+ const leaveItem = actionItems.find((item) => item.id === 'leave');
386
+ expect(leaveItem?.action).toBe(channelActions.leave);
387
+ expect(leaveItem?.label).toBe('Leave Group');
388
+ expect(leaveItem?.placement).toBe('sheet');
389
+ });
390
+
391
+ it('pin item toggles to unpin when channel is pinned', () => {
392
+ const channelActions = createChannelActions();
393
+ const actionItems = buildDefaultChannelActionItems({
394
+ actions: channelActions,
395
+ channel,
396
+ channelMuteActive: false,
397
+ isArchived: false,
398
+ isBlocked: undefined,
399
+ isDirectChat: false,
400
+ isPinned: true,
401
+ surface: 'list',
402
+ t: ((value: string) => value) as TranslationContextValue['t'],
403
+ userMuteActive: false,
404
+ });
405
+
406
+ const pinItem = actionItems.find((item) => item.id === 'pin');
407
+ expect(pinItem?.action).toBe(channelActions.unpin);
408
+ expect(pinItem?.label).toBe('Unpin Group');
409
+ expect(pinItem?.placement).toBe('sheet');
410
+ });
411
+
412
+ it('pin item uses direct-chat label variant', () => {
413
+ const channelActions = createChannelActions();
414
+ const actionItems = buildDefaultChannelActionItems({
415
+ actions: channelActions,
416
+ channel,
417
+ channelMuteActive: false,
418
+ isArchived: false,
419
+ isBlocked: undefined,
420
+ isDirectChat: true,
421
+ isPinned: false,
422
+ surface: 'list',
423
+ t: ((value: string) => value) as TranslationContextValue['t'],
424
+ userMuteActive: false,
425
+ });
426
+
427
+ const pinItem = actionItems.find((item) => item.id === 'pin');
428
+ expect(pinItem?.action).toBe(channelActions.pin);
429
+ expect(pinItem?.label).toBe('Pin Chat');
430
+ });
431
+
432
+ it('omits the pin item when building for the details surface', () => {
433
+ const actionItems = buildDefaultChannelActionItems({
434
+ actions: createChannelActions(),
435
+ channel,
436
+ channelMuteActive: false,
437
+ isArchived: false,
438
+ isBlocked: undefined,
439
+ isDirectChat: false,
440
+ isPinned: false,
441
+ surface: 'details',
442
+ t: ((value: string) => value) as TranslationContextValue['t'],
443
+ userMuteActive: false,
444
+ });
445
+
446
+ expect(actionItems.map((item) => item.id)).not.toContain('pin');
447
+ });
448
+
449
+ it('includes the pin item when building for the list surface', () => {
450
+ const actionItems = buildDefaultChannelActionItems({
451
+ actions: createChannelActions(),
452
+ channel,
453
+ channelMuteActive: false,
454
+ isArchived: false,
455
+ isBlocked: undefined,
456
+ isDirectChat: false,
457
+ isPinned: false,
458
+ surface: 'list',
459
+ t: ((value: string) => value) as TranslationContextValue['t'],
460
+ userMuteActive: false,
461
+ });
462
+
463
+ expect(actionItems.map((item) => item.id)).toContain('pin');
464
+ });
465
+
466
+ it('applies no surface-specific filtering when surface is omitted', () => {
467
+ const actionItems = buildDefaultChannelActionItems({
468
+ actions: createChannelActions(),
469
+ channel,
470
+ channelMuteActive: false,
471
+ isArchived: false,
472
+ isBlocked: undefined,
473
+ isDirectChat: false,
474
+ isPinned: false,
475
+ t: ((value: string) => value) as TranslationContextValue['t'],
476
+ userMuteActive: false,
477
+ });
478
+
479
+ expect(actionItems.map((item) => item.id)).toEqual(['mute', 'pin', 'leave', 'deleteChannel']);
359
480
  });
360
481
 
361
482
  it('mute and muteUser reflect their respective active states independently', () => {
@@ -368,6 +489,7 @@ describe('getChannelActionItems', () => {
368
489
  isBlocked: undefined,
369
490
  isDirectChat: true,
370
491
  isPinned: false,
492
+ surface: 'list',
371
493
  t: ((value: string) => value) as TranslationContextValue['t'],
372
494
  userMuteActive: true,
373
495
  });
@@ -393,6 +515,7 @@ describe('getChannelActionItems', () => {
393
515
  isBlocked: undefined,
394
516
  isDirectChat: false,
395
517
  isPinned: false,
518
+ surface: 'list',
396
519
  t: ((value: string) => value) as TranslationContextValue['t'],
397
520
  userMuteActive: false,
398
521
  });
@@ -41,7 +41,7 @@ describe('useChannelActionItemsById', () => {
41
41
  .spyOn(useChannelActionItemsModule, 'useChannelActionItems')
42
42
  .mockReturnValue(channelActionItems);
43
43
 
44
- const { result } = renderHook(() => useChannelActionItemsById({ channel }));
44
+ const { result } = renderHook(() => useChannelActionItemsById({ channel, surface: 'list' }));
45
45
 
46
46
  expect(result.current.pin).toBe(channelActionItems[0]);
47
47
  expect(result.current.deleteChannel).toBe(channelActionItems[1]);
@@ -59,12 +59,14 @@ describe('useChannelActionItemsById', () => {
59
59
  useChannelActionItemsById({
60
60
  channel,
61
61
  getChannelActionItems,
62
+ surface: 'list',
62
63
  }),
63
64
  );
64
65
 
65
66
  expect(useChannelActionItemsSpy).toHaveBeenCalledWith({
66
67
  channel,
67
68
  getChannelActionItems,
69
+ surface: 'list',
68
70
  });
69
71
  });
70
72
  });