@planningcenter/chat-react-native 3.35.0-rc.2 → 3.35.0-rc.4

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 (112) hide show
  1. package/README.md +1 -1
  2. package/build/components/conversation/jump_to_bottom_button.d.ts +2 -1
  3. package/build/components/conversation/jump_to_bottom_button.d.ts.map +1 -1
  4. package/build/components/conversation/jump_to_bottom_button.js +39 -7
  5. package/build/components/conversation/jump_to_bottom_button.js.map +1 -1
  6. package/build/components/conversation/reply_shadow_message.d.ts +1 -2
  7. package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
  8. package/build/components/conversation/reply_shadow_message.js.map +1 -1
  9. package/build/components/conversation/unread_divider.d.ts +6 -0
  10. package/build/components/conversation/unread_divider.d.ts.map +1 -0
  11. package/build/components/conversation/unread_divider.js +59 -0
  12. package/build/components/conversation/unread_divider.js.map +1 -0
  13. package/build/contexts/conversation_context.d.ts +2 -0
  14. package/build/contexts/conversation_context.d.ts.map +1 -1
  15. package/build/contexts/conversation_context.js +13 -5
  16. package/build/contexts/conversation_context.js.map +1 -1
  17. package/build/hooks/use_conversation_messages.d.ts +2 -0
  18. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  19. package/build/hooks/use_conversation_messages.js +9 -5
  20. package/build/hooks/use_conversation_messages.js.map +1 -1
  21. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  22. package/build/hooks/use_conversation_messages_jolt_events.js +4 -4
  23. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  24. package/build/hooks/use_conversations_actions.d.ts +5 -0
  25. package/build/hooks/use_conversations_actions.d.ts.map +1 -1
  26. package/build/hooks/use_conversations_actions.js +12 -0
  27. package/build/hooks/use_conversations_actions.js.map +1 -1
  28. package/build/hooks/use_flat_list_viewability.d.ts +20 -0
  29. package/build/hooks/use_flat_list_viewability.d.ts.map +1 -0
  30. package/build/hooks/use_flat_list_viewability.js +30 -0
  31. package/build/hooks/use_flat_list_viewability.js.map +1 -0
  32. package/build/hooks/use_jump_to_bottom_action.d.ts +9 -0
  33. package/build/hooks/use_jump_to_bottom_action.d.ts.map +1 -0
  34. package/build/hooks/use_jump_to_bottom_action.js +62 -0
  35. package/build/hooks/use_jump_to_bottom_action.js.map +1 -0
  36. package/build/hooks/use_jump_to_unread_anchor.d.ts +20 -0
  37. package/build/hooks/use_jump_to_unread_anchor.d.ts.map +1 -0
  38. package/build/hooks/use_jump_to_unread_anchor.js +53 -0
  39. package/build/hooks/use_jump_to_unread_anchor.js.map +1 -0
  40. package/build/hooks/use_jump_to_unread_gates.d.ts +5 -0
  41. package/build/hooks/use_jump_to_unread_gates.d.ts.map +1 -0
  42. package/build/hooks/use_jump_to_unread_gates.js +10 -0
  43. package/build/hooks/use_jump_to_unread_gates.js.map +1 -0
  44. package/build/hooks/use_mark_latest_message_read.d.ts +1 -1
  45. package/build/hooks/use_mark_latest_message_read.d.ts.map +1 -1
  46. package/build/hooks/use_mark_latest_message_read.js +17 -1
  47. package/build/hooks/use_mark_latest_message_read.js.map +1 -1
  48. package/build/hooks/use_scroll_tracking.d.ts +13 -0
  49. package/build/hooks/use_scroll_tracking.d.ts.map +1 -0
  50. package/build/hooks/use_scroll_tracking.js +45 -0
  51. package/build/hooks/use_scroll_tracking.js.map +1 -0
  52. package/build/hooks/use_track_highest_seen_message.d.ts +4 -0
  53. package/build/hooks/use_track_highest_seen_message.d.ts.map +1 -0
  54. package/build/hooks/use_track_highest_seen_message.js +35 -0
  55. package/build/hooks/use_track_highest_seen_message.js.map +1 -0
  56. package/build/navigation/index.d.ts.map +1 -1
  57. package/build/screens/conversation_screen.d.ts +0 -19
  58. package/build/screens/conversation_screen.d.ts.map +1 -1
  59. package/build/screens/conversation_screen.js +87 -139
  60. package/build/screens/conversation_screen.js.map +1 -1
  61. package/build/utils/cache/messages_cache.d.ts +1 -0
  62. package/build/utils/cache/messages_cache.d.ts.map +1 -1
  63. package/build/utils/cache/messages_cache.js +4 -0
  64. package/build/utils/cache/messages_cache.js.map +1 -1
  65. package/build/utils/group_messages.d.ts +28 -0
  66. package/build/utils/group_messages.d.ts.map +1 -0
  67. package/build/utils/group_messages.js +142 -0
  68. package/build/utils/group_messages.js.map +1 -0
  69. package/build/utils/highest_seen_tracker.d.ts +12 -0
  70. package/build/utils/highest_seen_tracker.d.ts.map +1 -0
  71. package/build/utils/highest_seen_tracker.js +37 -0
  72. package/build/utils/highest_seen_tracker.js.map +1 -0
  73. package/build/utils/message_viewability.d.ts +24 -0
  74. package/build/utils/message_viewability.d.ts.map +1 -0
  75. package/build/utils/message_viewability.js +29 -0
  76. package/build/utils/message_viewability.js.map +1 -0
  77. package/build/utils/unread_divider_helpers.d.ts +18 -0
  78. package/build/utils/unread_divider_helpers.d.ts.map +1 -0
  79. package/build/utils/unread_divider_helpers.js +13 -0
  80. package/build/utils/unread_divider_helpers.js.map +1 -0
  81. package/package.json +10 -4
  82. package/src/__tests__/contexts/session_context.tsx +1 -1
  83. package/src/__tests__/hooks/use_async_storage.test.tsx +1 -1
  84. package/src/__tests__/hooks/use_attachment_uploader.test.tsx +1 -1
  85. package/src/__tests__/hooks/use_chat_configuration.test.tsx +1 -1
  86. package/src/__tests__/hooks/use_conversation_messages.test.tsx +1 -1
  87. package/src/__tests__/hooks/use_mark_latest_message_read.test.tsx +154 -0
  88. package/src/__tests__/utils/cache/messages_cache.test.ts +54 -0
  89. package/src/components/conversation/jump_to_bottom_button.tsx +57 -8
  90. package/src/components/conversation/reply_shadow_message.tsx +4 -2
  91. package/src/components/conversation/unread_divider.tsx +90 -0
  92. package/src/contexts/conversation_context.tsx +15 -13
  93. package/src/hooks/use_conversation_messages.ts +19 -3
  94. package/src/hooks/use_conversation_messages_jolt_events.ts +4 -3
  95. package/src/hooks/use_conversations_actions.ts +15 -0
  96. package/src/hooks/use_flat_list_viewability.ts +50 -0
  97. package/src/hooks/use_jump_to_bottom_action.ts +75 -0
  98. package/src/hooks/use_jump_to_unread_anchor.ts +68 -0
  99. package/src/hooks/use_jump_to_unread_gates.ts +10 -0
  100. package/src/hooks/use_mark_latest_message_read.ts +16 -2
  101. package/src/hooks/use_scroll_tracking.ts +64 -0
  102. package/src/hooks/use_track_highest_seen_message.ts +43 -0
  103. package/src/screens/conversation_screen.tsx +173 -197
  104. package/src/utils/__tests__/group_messages.test.ts +214 -0
  105. package/src/utils/__tests__/highest_seen_tracker.test.ts +82 -0
  106. package/src/utils/__tests__/message_viewability.test.ts +168 -0
  107. package/src/utils/__tests__/unread_divider_helpers.test.ts +85 -0
  108. package/src/utils/cache/messages_cache.ts +5 -0
  109. package/src/utils/group_messages.ts +217 -0
  110. package/src/utils/highest_seen_tracker.ts +42 -0
  111. package/src/utils/message_viewability.ts +49 -0
  112. package/src/utils/unread_divider_helpers.ts +25 -0
@@ -0,0 +1,142 @@
1
+ import dayjs from './dayjs';
2
+ import { isSystemMessage } from './system_messages';
3
+ const FIVE_MINUTES_MS = 5 * 60 * 1000;
4
+ export const UNREAD_DIVIDER_KEY = 'unread-divider';
5
+ export function groupMessages({ ms, inReplyScreen, jumpToUnreadActive, initialMessageId, }) {
6
+ const items = [];
7
+ let myLatestSeen = false;
8
+ let nextNeighborEnriched;
9
+ ms.forEach((message, i) => {
10
+ const { prev } = neighborsOf(ms, i);
11
+ const next = nextNeighborEnriched;
12
+ if (isSystemMessage(message)) {
13
+ const enriched = enrichSystemMessage(message, next);
14
+ items.push(enriched);
15
+ if (crossesUnreadBoundary(message, prev, jumpToUnreadActive, initialMessageId)) {
16
+ items.push(unreadDivider());
17
+ }
18
+ if (datesDifferBetween(message, prev))
19
+ items.push(dateSeparator(message));
20
+ nextNeighborEnriched = enriched;
21
+ return;
22
+ }
23
+ const isMyLatest = !myLatestSeen && message.mine;
24
+ if (isMyLatest)
25
+ myLatestSeen = true;
26
+ const enriched = enrichRegularMessage(message, prev, next, isMyLatest, !!inReplyScreen);
27
+ items.push(enriched);
28
+ if (crossesUnreadBoundary(message, prev, jumpToUnreadActive, initialMessageId)) {
29
+ items.push(unreadDivider());
30
+ }
31
+ const shadow = replyShadowFor(enriched, prev);
32
+ if (shadow)
33
+ items.push(shadow);
34
+ if (!prev || datesDifferBetween(message, prev)) {
35
+ items.push(dateSeparator(message));
36
+ }
37
+ nextNeighborEnriched = enriched;
38
+ });
39
+ return items;
40
+ }
41
+ function crossesUnreadBoundary(message, prev, jumpToUnreadActive, initialMessageId) {
42
+ if (!jumpToUnreadActive)
43
+ return false;
44
+ if (!initialMessageId)
45
+ return false;
46
+ if (!prev)
47
+ return false;
48
+ return (prev.id.localeCompare(initialMessageId) <= 0 && message.id.localeCompare(initialMessageId) > 0);
49
+ }
50
+ function unreadDivider() {
51
+ return { type: 'UnreadDivider', id: UNREAD_DIVIDER_KEY };
52
+ }
53
+ function neighborsOf(arr, i) {
54
+ return { prev: arr[i + 1], next: arr[i - 1] };
55
+ }
56
+ function enrichSystemMessage(message, next) {
57
+ return {
58
+ ...message,
59
+ myLatestInConversation: false,
60
+ lastInGroup: true,
61
+ renderAuthor: false,
62
+ nextRendersAuthor: next?.renderAuthor,
63
+ isReplyShadowMessage: false,
64
+ nextIsReplyShadowMessage: false,
65
+ threadPosition: null,
66
+ };
67
+ }
68
+ function enrichRegularMessage(message, prev, next, isMyLatest, inReplyScreen) {
69
+ const inThread = message.replyRootId !== null;
70
+ const showThreadDetails = !inReplyScreen && inThread;
71
+ return {
72
+ ...message,
73
+ myLatestInConversation: isMyLatest,
74
+ lastInGroup: !next || startsNewGroup(next, message),
75
+ renderAuthor: !message.mine && (!prev || startsNewGroup(message, prev)),
76
+ nextRendersAuthor: next?.renderAuthor,
77
+ isReplyShadowMessage: false,
78
+ nextIsReplyShadowMessage: nextIntroducesReplyShadow(next, message),
79
+ threadPosition: showThreadDetails ? threadPositionFor(message, next) : null,
80
+ prevIsMyReply: showThreadDetails ? prev?.mine : undefined,
81
+ nextIsMyReply: showThreadDetails ? next?.mine : undefined,
82
+ };
83
+ }
84
+ function startsNewGroup(a, b) {
85
+ return (a.author?.id !== b.author?.id ||
86
+ differsByMoreThan5Min(a, b) ||
87
+ a.replyRootId !== b.replyRootId ||
88
+ datesDifferBetween(a, b));
89
+ }
90
+ function differsByMoreThan5Min(a, b) {
91
+ return Math.abs(toMillis(a.createdAt) - toMillis(b.createdAt)) > FIVE_MINUTES_MS;
92
+ }
93
+ function toMillis(iso) {
94
+ return new Date(iso).getTime();
95
+ }
96
+ function datesDifferBetween(a, b) {
97
+ if (!b)
98
+ return false;
99
+ return dateKey(a) !== dateKey(b);
100
+ }
101
+ function dateKey(message) {
102
+ return dayjs(message.createdAt).format('YYYY-MM-DD');
103
+ }
104
+ function dateSeparator(message) {
105
+ return { type: 'DateSeparator', id: `day-divider-${message.id}`, date: dateKey(message) };
106
+ }
107
+ function threadPositionFor(message, next) {
108
+ const isThreadRoot = message.replyRootId === message.id;
109
+ const isLast = !next || next.replyRootId !== message.replyRootId || datesDifferBetween(next, message);
110
+ if (isThreadRoot && isLast)
111
+ return null;
112
+ if (isThreadRoot)
113
+ return 'first';
114
+ if (isLast)
115
+ return 'last';
116
+ return 'center';
117
+ }
118
+ function nextIntroducesReplyShadow(next, current) {
119
+ if (!next)
120
+ return false;
121
+ const nextInThread = next.replyRootId !== null;
122
+ const nextIsThreadRoot = next.replyRootId === next.id;
123
+ const differentThread = next.replyRootId !== current.replyRootId;
124
+ return nextInThread && !nextIsThreadRoot && (differentThread || datesDifferBetween(next, current));
125
+ }
126
+ function replyShadowFor(message, prev) {
127
+ if (!message.replyRootId)
128
+ return undefined;
129
+ if (message.replyRootId === message.id)
130
+ return undefined;
131
+ const enteringNewThread = !prev || prev.replyRootId !== message.replyRootId || datesDifferBetween(prev, message);
132
+ if (!enteringNewThread)
133
+ return undefined;
134
+ return {
135
+ type: 'ReplyShadowMessage',
136
+ id: `${message.id}-${message.replyRootId}`,
137
+ messageId: message.replyRootId,
138
+ isReplyShadowMessage: true,
139
+ nextRendersAuthor: message.renderAuthor ?? false,
140
+ };
141
+ }
142
+ //# sourceMappingURL=group_messages.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"group_messages.js","sourceRoot":"","sources":["../../src/utils/group_messages.ts"],"names":[],"mappings":"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","sourcesContent":["import type { MessageResource } from '../types/resources/message'\nimport dayjs from './dayjs'\nimport { isSystemMessage } from './system_messages'\n\nconst FIVE_MINUTES_MS = 5 * 60 * 1000\n\nexport const UNREAD_DIVIDER_KEY = 'unread-divider'\n\nexport type DateSeparator = { type: 'DateSeparator'; id: string; date: string }\n\nexport type UnreadDividerItem = { type: 'UnreadDivider'; id: typeof UNREAD_DIVIDER_KEY }\n\nexport type ReplyShadowMessage = {\n type: 'ReplyShadowMessage'\n id: string\n messageId: string\n isReplyShadowMessage: boolean\n nextRendersAuthor: boolean\n}\n\nexport type EnrichedMessage =\n | MessageResource\n | DateSeparator\n | UnreadDividerItem\n | ReplyShadowMessage\n\ninterface GroupMessagesProps {\n ms: MessageResource[]\n inReplyScreen?: boolean\n jumpToUnreadActive?: boolean\n initialMessageId?: string | null\n}\n\nexport function groupMessages({\n ms,\n inReplyScreen,\n jumpToUnreadActive,\n initialMessageId,\n}: GroupMessagesProps): EnrichedMessage[] {\n const items: EnrichedMessage[] = []\n let myLatestSeen = false\n let nextNeighborEnriched: MessageResource | undefined\n\n ms.forEach((message, i) => {\n const { prev } = neighborsOf(ms, i)\n const next = nextNeighborEnriched\n\n if (isSystemMessage(message)) {\n const enriched = enrichSystemMessage(message, next)\n items.push(enriched)\n if (crossesUnreadBoundary(message, prev, jumpToUnreadActive, initialMessageId)) {\n items.push(unreadDivider())\n }\n if (datesDifferBetween(message, prev)) items.push(dateSeparator(message))\n nextNeighborEnriched = enriched\n return\n }\n\n const isMyLatest = !myLatestSeen && message.mine\n if (isMyLatest) myLatestSeen = true\n\n const enriched = enrichRegularMessage(message, prev, next, isMyLatest, !!inReplyScreen)\n items.push(enriched)\n\n if (crossesUnreadBoundary(message, prev, jumpToUnreadActive, initialMessageId)) {\n items.push(unreadDivider())\n }\n\n const shadow = replyShadowFor(enriched, prev)\n if (shadow) items.push(shadow)\n\n if (!prev || datesDifferBetween(message, prev)) {\n items.push(dateSeparator(message))\n }\n\n nextNeighborEnriched = enriched\n })\n\n return items\n}\n\nfunction crossesUnreadBoundary(\n message: MessageResource,\n prev: MessageResource | undefined,\n jumpToUnreadActive: boolean | undefined,\n initialMessageId: string | null | undefined\n): boolean {\n if (!jumpToUnreadActive) return false\n if (!initialMessageId) return false\n if (!prev) return false\n return (\n prev.id.localeCompare(initialMessageId) <= 0 && message.id.localeCompare(initialMessageId) > 0\n )\n}\n\nfunction unreadDivider(): UnreadDividerItem {\n return { type: 'UnreadDivider', id: UNREAD_DIVIDER_KEY }\n}\n\nfunction neighborsOf<T>(arr: T[], i: number): { prev: T | undefined; next: T | undefined } {\n return { prev: arr[i + 1], next: arr[i - 1] }\n}\n\nfunction enrichSystemMessage(\n message: MessageResource,\n next: MessageResource | undefined\n): MessageResource {\n return {\n ...message,\n myLatestInConversation: false,\n lastInGroup: true,\n renderAuthor: false,\n nextRendersAuthor: next?.renderAuthor,\n isReplyShadowMessage: false,\n nextIsReplyShadowMessage: false,\n threadPosition: null,\n }\n}\n\nfunction enrichRegularMessage(\n message: MessageResource,\n prev: MessageResource | undefined,\n next: MessageResource | undefined,\n isMyLatest: boolean,\n inReplyScreen: boolean\n): MessageResource {\n const inThread = message.replyRootId !== null\n const showThreadDetails = !inReplyScreen && inThread\n\n return {\n ...message,\n myLatestInConversation: isMyLatest,\n lastInGroup: !next || startsNewGroup(next, message),\n renderAuthor: !message.mine && (!prev || startsNewGroup(message, prev)),\n nextRendersAuthor: next?.renderAuthor,\n isReplyShadowMessage: false,\n nextIsReplyShadowMessage: nextIntroducesReplyShadow(next, message),\n threadPosition: showThreadDetails ? threadPositionFor(message, next) : null,\n prevIsMyReply: showThreadDetails ? prev?.mine : undefined,\n nextIsMyReply: showThreadDetails ? next?.mine : undefined,\n }\n}\n\nfunction startsNewGroup(a: MessageResource, b: MessageResource): boolean {\n return (\n a.author?.id !== b.author?.id ||\n differsByMoreThan5Min(a, b) ||\n a.replyRootId !== b.replyRootId ||\n datesDifferBetween(a, b)\n )\n}\n\nfunction differsByMoreThan5Min(a: MessageResource, b: MessageResource): boolean {\n return Math.abs(toMillis(a.createdAt) - toMillis(b.createdAt)) > FIVE_MINUTES_MS\n}\n\nfunction toMillis(iso: string): number {\n return new Date(iso).getTime()\n}\n\nfunction datesDifferBetween(a: MessageResource, b: MessageResource | undefined): boolean {\n if (!b) return false\n return dateKey(a) !== dateKey(b)\n}\n\nfunction dateKey(message: MessageResource): string {\n return dayjs(message.createdAt).format('YYYY-MM-DD')\n}\n\nfunction dateSeparator(message: MessageResource): DateSeparator {\n return { type: 'DateSeparator', id: `day-divider-${message.id}`, date: dateKey(message) }\n}\n\nfunction threadPositionFor(\n message: MessageResource,\n next: MessageResource | undefined\n): 'first' | 'center' | 'last' | null {\n const isThreadRoot = message.replyRootId === message.id\n const isLast =\n !next || next.replyRootId !== message.replyRootId || datesDifferBetween(next, message)\n\n if (isThreadRoot && isLast) return null\n if (isThreadRoot) return 'first'\n if (isLast) return 'last'\n return 'center'\n}\n\nfunction nextIntroducesReplyShadow(\n next: MessageResource | undefined,\n current: MessageResource\n): boolean {\n if (!next) return false\n const nextInThread = next.replyRootId !== null\n const nextIsThreadRoot = next.replyRootId === next.id\n const differentThread = next.replyRootId !== current.replyRootId\n return nextInThread && !nextIsThreadRoot && (differentThread || datesDifferBetween(next, current))\n}\n\nfunction replyShadowFor(\n message: MessageResource,\n prev: MessageResource | undefined\n): ReplyShadowMessage | undefined {\n if (!message.replyRootId) return undefined\n if (message.replyRootId === message.id) return undefined\n\n const enteringNewThread =\n !prev || prev.replyRootId !== message.replyRootId || datesDifferBetween(prev, message)\n if (!enteringNewThread) return undefined\n\n return {\n type: 'ReplyShadowMessage',\n id: `${message.id}-${message.replyRootId}`,\n messageId: message.replyRootId,\n isReplyShadowMessage: true,\n nextRendersAuthor: message.renderAuthor ?? false,\n }\n}\n"]}
@@ -0,0 +1,12 @@
1
+ export declare const FLUSH_DELAY_MS = 2000;
2
+ type SendFn = (args: {
3
+ conversationId: number;
4
+ sortKey: string;
5
+ }) => void;
6
+ export declare function makeHighestSeenTracker(conversationId: number, send: SendFn, flushDelayMs?: number): {
7
+ onSeen(sortKey: string): void;
8
+ flushNow(): void;
9
+ cancel(): void;
10
+ };
11
+ export {};
12
+ //# sourceMappingURL=highest_seen_tracker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"highest_seen_tracker.d.ts","sourceRoot":"","sources":["../../src/utils/highest_seen_tracker.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,cAAc,OAAO,CAAA;AAElC,KAAK,MAAM,GAAG,CAAC,IAAI,EAAE;IAAE,cAAc,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,KAAK,IAAI,CAAA;AAEzE,wBAAgB,sBAAsB,CACpC,cAAc,EAAE,MAAM,EACtB,IAAI,EAAE,MAAM,EACZ,YAAY,GAAE,MAAuB;oBAcnB,MAAM;;;EAoBzB"}
@@ -0,0 +1,37 @@
1
+ export const FLUSH_DELAY_MS = 2000;
2
+ export function makeHighestSeenTracker(conversationId, send, flushDelayMs = FLUSH_DELAY_MS) {
3
+ let highest = null;
4
+ let lastSent = null;
5
+ let timer = null;
6
+ const fire = () => {
7
+ timer = null;
8
+ if (!highest || highest === lastSent)
9
+ return;
10
+ lastSent = highest;
11
+ send({ conversationId, sortKey: highest });
12
+ };
13
+ return {
14
+ onSeen(sortKey) {
15
+ if (highest && sortKey.localeCompare(highest) <= 0)
16
+ return;
17
+ highest = sortKey;
18
+ if (timer)
19
+ clearTimeout(timer);
20
+ timer = setTimeout(fire, flushDelayMs);
21
+ },
22
+ flushNow() {
23
+ if (timer) {
24
+ clearTimeout(timer);
25
+ timer = null;
26
+ }
27
+ fire();
28
+ },
29
+ cancel() {
30
+ if (timer) {
31
+ clearTimeout(timer);
32
+ timer = null;
33
+ }
34
+ },
35
+ };
36
+ }
37
+ //# sourceMappingURL=highest_seen_tracker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"highest_seen_tracker.js","sourceRoot":"","sources":["../../src/utils/highest_seen_tracker.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,cAAc,GAAG,IAAI,CAAA;AAIlC,MAAM,UAAU,sBAAsB,CACpC,cAAsB,EACtB,IAAY,EACZ,eAAuB,cAAc;IAErC,IAAI,OAAO,GAAkB,IAAI,CAAA;IACjC,IAAI,QAAQ,GAAkB,IAAI,CAAA;IAClC,IAAI,KAAK,GAAyC,IAAI,CAAA;IAEtD,MAAM,IAAI,GAAG,GAAG,EAAE;QAChB,KAAK,GAAG,IAAI,CAAA;QACZ,IAAI,CAAC,OAAO,IAAI,OAAO,KAAK,QAAQ;YAAE,OAAM;QAC5C,QAAQ,GAAG,OAAO,CAAA;QAClB,IAAI,CAAC,EAAE,cAAc,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,CAAA;IAC5C,CAAC,CAAA;IAED,OAAO;QACL,MAAM,CAAC,OAAe;YACpB,IAAI,OAAO,IAAI,OAAO,CAAC,aAAa,CAAC,OAAO,CAAC,IAAI,CAAC;gBAAE,OAAM;YAC1D,OAAO,GAAG,OAAO,CAAA;YACjB,IAAI,KAAK;gBAAE,YAAY,CAAC,KAAK,CAAC,CAAA;YAC9B,KAAK,GAAG,UAAU,CAAC,IAAI,EAAE,YAAY,CAAC,CAAA;QACxC,CAAC;QACD,QAAQ;YACN,IAAI,KAAK,EAAE,CAAC;gBACV,YAAY,CAAC,KAAK,CAAC,CAAA;gBACnB,KAAK,GAAG,IAAI,CAAA;YACd,CAAC;YACD,IAAI,EAAE,CAAA;QACR,CAAC;QACD,MAAM;YACJ,IAAI,KAAK,EAAE,CAAC;gBACV,YAAY,CAAC,KAAK,CAAC,CAAA;gBACnB,KAAK,GAAG,IAAI,CAAA;YACd,CAAC;QACH,CAAC;KACF,CAAA;AACH,CAAC","sourcesContent":["export const FLUSH_DELAY_MS = 2000\n\ntype SendFn = (args: { conversationId: number; sortKey: string }) => void\n\nexport function makeHighestSeenTracker(\n conversationId: number,\n send: SendFn,\n flushDelayMs: number = FLUSH_DELAY_MS\n) {\n let highest: string | null = null\n let lastSent: string | null = null\n let timer: ReturnType<typeof setTimeout> | null = null\n\n const fire = () => {\n timer = null\n if (!highest || highest === lastSent) return\n lastSent = highest\n send({ conversationId, sortKey: highest })\n }\n\n return {\n onSeen(sortKey: string) {\n if (highest && sortKey.localeCompare(highest) <= 0) return\n highest = sortKey\n if (timer) clearTimeout(timer)\n timer = setTimeout(fire, flushDelayMs)\n },\n flushNow() {\n if (timer) {\n clearTimeout(timer)\n timer = null\n }\n fire()\n },\n cancel() {\n if (timer) {\n clearTimeout(timer)\n timer = null\n }\n },\n }\n}\n"]}
@@ -0,0 +1,24 @@
1
+ export interface ViewableEntry<Item> {
2
+ key: string;
3
+ isViewable: boolean;
4
+ item: Item;
5
+ }
6
+ export interface ViewabilityEvent<Item> {
7
+ viewableItems: ViewableEntry<Item>[];
8
+ changed: ViewableEntry<Item>[];
9
+ userHasScrolled: boolean;
10
+ }
11
+ export type ViewabilityObserver<Item> = (event: ViewabilityEvent<Item>) => void;
12
+ export declare function reportViewableMessages<Item extends {
13
+ id?: string;
14
+ type?: string;
15
+ }>(onMessageSeen: (id: string) => void): ViewabilityObserver<Item>;
16
+ export declare function detectDividerExitTowardNewer<Item extends {
17
+ id?: string;
18
+ type?: string;
19
+ }>({ dividerKey, initialMessageId, onExited, }: {
20
+ dividerKey: string;
21
+ initialMessageId: string | null;
22
+ onExited: () => void;
23
+ }): ViewabilityObserver<Item>;
24
+ //# sourceMappingURL=message_viewability.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"message_viewability.d.ts","sourceRoot":"","sources":["../../src/utils/message_viewability.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,aAAa,CAAC,IAAI;IACjC,GAAG,EAAE,MAAM,CAAA;IACX,UAAU,EAAE,OAAO,CAAA;IACnB,IAAI,EAAE,IAAI,CAAA;CACX;AAED,MAAM,WAAW,gBAAgB,CAAC,IAAI;IACpC,aAAa,EAAE,aAAa,CAAC,IAAI,CAAC,EAAE,CAAA;IACpC,OAAO,EAAE,aAAa,CAAC,IAAI,CAAC,EAAE,CAAA;IAC9B,eAAe,EAAE,OAAO,CAAA;CACzB;AAED,MAAM,MAAM,mBAAmB,CAAC,IAAI,IAAI,CAAC,KAAK,EAAE,gBAAgB,CAAC,IAAI,CAAC,KAAK,IAAI,CAAA;AAE/E,wBAAgB,sBAAsB,CAAC,IAAI,SAAS;IAAE,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,EAChF,aAAa,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,GAClC,mBAAmB,CAAC,IAAI,CAAC,CAS3B;AAED,wBAAgB,4BAA4B,CAAC,IAAI,SAAS;IAAE,EAAE,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,EACxF,UAAU,EACV,gBAAgB,EAChB,QAAQ,GACT,EAAE;IACD,UAAU,EAAE,MAAM,CAAA;IAClB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAA;IAC/B,QAAQ,EAAE,MAAM,IAAI,CAAA;CACrB,GAAG,mBAAmB,CAAC,IAAI,CAAC,CAW5B"}
@@ -0,0 +1,29 @@
1
+ import { dividerExitedTowardNewer } from './unread_divider_helpers';
2
+ export function reportViewableMessages(onMessageSeen) {
3
+ return ({ viewableItems, userHasScrolled }) => {
4
+ if (!userHasScrolled)
5
+ return;
6
+ for (const entry of viewableItems) {
7
+ if (entry.item?.type !== 'Message')
8
+ continue;
9
+ const id = entry.item?.id;
10
+ if (typeof id === 'string')
11
+ onMessageSeen(id);
12
+ }
13
+ };
14
+ }
15
+ export function detectDividerExitTowardNewer({ dividerKey, initialMessageId, onExited, }) {
16
+ return ({ viewableItems, changed, userHasScrolled }) => {
17
+ if (!userHasScrolled || !initialMessageId)
18
+ return;
19
+ const exited = dividerExitedTowardNewer({
20
+ changed,
21
+ viewableItems,
22
+ dividerKey,
23
+ initialMessageId,
24
+ });
25
+ if (exited)
26
+ onExited();
27
+ };
28
+ }
29
+ //# sourceMappingURL=message_viewability.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"message_viewability.js","sourceRoot":"","sources":["../../src/utils/message_viewability.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAAE,MAAM,0BAA0B,CAAA;AAgBnE,MAAM,UAAU,sBAAsB,CACpC,aAAmC;IAEnC,OAAO,CAAC,EAAE,aAAa,EAAE,eAAe,EAAE,EAAE,EAAE;QAC5C,IAAI,CAAC,eAAe;YAAE,OAAM;QAC5B,KAAK,MAAM,KAAK,IAAI,aAAa,EAAE,CAAC;YAClC,IAAI,KAAK,CAAC,IAAI,EAAE,IAAI,KAAK,SAAS;gBAAE,SAAQ;YAC5C,MAAM,EAAE,GAAG,KAAK,CAAC,IAAI,EAAE,EAAE,CAAA;YACzB,IAAI,OAAO,EAAE,KAAK,QAAQ;gBAAE,aAAa,CAAC,EAAE,CAAC,CAAA;QAC/C,CAAC;IACH,CAAC,CAAA;AACH,CAAC;AAED,MAAM,UAAU,4BAA4B,CAA8C,EACxF,UAAU,EACV,gBAAgB,EAChB,QAAQ,GAKT;IACC,OAAO,CAAC,EAAE,aAAa,EAAE,OAAO,EAAE,eAAe,EAAE,EAAE,EAAE;QACrD,IAAI,CAAC,eAAe,IAAI,CAAC,gBAAgB;YAAE,OAAM;QACjD,MAAM,MAAM,GAAG,wBAAwB,CAAC;YACtC,OAAO;YACP,aAAa;YACb,UAAU;YACV,gBAAgB;SACjB,CAAC,CAAA;QACF,IAAI,MAAM;YAAE,QAAQ,EAAE,CAAA;IACxB,CAAC,CAAA;AACH,CAAC","sourcesContent":["import { dividerExitedTowardNewer } from './unread_divider_helpers'\n\nexport interface ViewableEntry<Item> {\n key: string\n isViewable: boolean\n item: Item\n}\n\nexport interface ViewabilityEvent<Item> {\n viewableItems: ViewableEntry<Item>[]\n changed: ViewableEntry<Item>[]\n userHasScrolled: boolean\n}\n\nexport type ViewabilityObserver<Item> = (event: ViewabilityEvent<Item>) => void\n\nexport function reportViewableMessages<Item extends { id?: string; type?: string }>(\n onMessageSeen: (id: string) => void\n): ViewabilityObserver<Item> {\n return ({ viewableItems, userHasScrolled }) => {\n if (!userHasScrolled) return\n for (const entry of viewableItems) {\n if (entry.item?.type !== 'Message') continue\n const id = entry.item?.id\n if (typeof id === 'string') onMessageSeen(id)\n }\n }\n}\n\nexport function detectDividerExitTowardNewer<Item extends { id?: string; type?: string }>({\n dividerKey,\n initialMessageId,\n onExited,\n}: {\n dividerKey: string\n initialMessageId: string | null\n onExited: () => void\n}): ViewabilityObserver<Item> {\n return ({ viewableItems, changed, userHasScrolled }) => {\n if (!userHasScrolled || !initialMessageId) return\n const exited = dividerExitedTowardNewer({\n changed,\n viewableItems,\n dividerKey,\n initialMessageId,\n })\n if (exited) onExited()\n }\n}\n"]}
@@ -0,0 +1,18 @@
1
+ type ViewableChangeEntry = {
2
+ key: string;
3
+ isViewable: boolean;
4
+ };
5
+ type ViewableItem = {
6
+ item: {
7
+ id?: string;
8
+ type?: string;
9
+ };
10
+ };
11
+ export declare function dividerExitedTowardNewer({ changed, viewableItems, dividerKey, initialMessageId, }: {
12
+ changed: ViewableChangeEntry[];
13
+ viewableItems: ViewableItem[];
14
+ dividerKey: string;
15
+ initialMessageId: string;
16
+ }): boolean;
17
+ export {};
18
+ //# sourceMappingURL=unread_divider_helpers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unread_divider_helpers.d.ts","sourceRoot":"","sources":["../../src/utils/unread_divider_helpers.ts"],"names":[],"mappings":"AAAA,KAAK,mBAAmB,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,CAAA;AAC/D,KAAK,YAAY,GAAG;IAAE,IAAI,EAAE;QAAE,EAAE,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAA;AAE5D,wBAAgB,wBAAwB,CAAC,EACvC,OAAO,EACP,aAAa,EACb,UAAU,EACV,gBAAgB,GACjB,EAAE;IACD,OAAO,EAAE,mBAAmB,EAAE,CAAA;IAC9B,aAAa,EAAE,YAAY,EAAE,CAAA;IAC7B,UAAU,EAAE,MAAM,CAAA;IAClB,gBAAgB,EAAE,MAAM,CAAA;CACzB,GAAG,OAAO,CAWV"}
@@ -0,0 +1,13 @@
1
+ export function dividerExitedTowardNewer({ changed, viewableItems, dividerKey, initialMessageId, }) {
2
+ const dividerExited = changed.some(c => c.key === dividerKey && !c.isViewable);
3
+ if (!dividerExited)
4
+ return false;
5
+ const visibleMessageIds = viewableItems
6
+ .filter(v => v.item?.type === 'Message')
7
+ .map(v => v.item?.id)
8
+ .filter((id) => !!id);
9
+ if (visibleMessageIds.length === 0)
10
+ return false;
11
+ return visibleMessageIds.every(id => id.localeCompare(initialMessageId) > 0);
12
+ }
13
+ //# sourceMappingURL=unread_divider_helpers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unread_divider_helpers.js","sourceRoot":"","sources":["../../src/utils/unread_divider_helpers.ts"],"names":[],"mappings":"AAGA,MAAM,UAAU,wBAAwB,CAAC,EACvC,OAAO,EACP,aAAa,EACb,UAAU,EACV,gBAAgB,GAMjB;IACC,MAAM,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,KAAK,UAAU,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,CAAA;IAC9E,IAAI,CAAC,aAAa;QAAE,OAAO,KAAK,CAAA;IAEhC,MAAM,iBAAiB,GAAG,aAAa;SACpC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,KAAK,SAAS,CAAC;SACvC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC;SACpB,MAAM,CAAC,CAAC,EAAE,EAAgB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;IACrC,IAAI,iBAAiB,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAA;IAEhD,OAAO,iBAAiB,CAAC,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,aAAa,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC,CAAA;AAC9E,CAAC","sourcesContent":["type ViewableChangeEntry = { key: string; isViewable: boolean }\ntype ViewableItem = { item: { id?: string; type?: string } }\n\nexport function dividerExitedTowardNewer({\n changed,\n viewableItems,\n dividerKey,\n initialMessageId,\n}: {\n changed: ViewableChangeEntry[]\n viewableItems: ViewableItem[]\n dividerKey: string\n initialMessageId: string\n}): boolean {\n const dividerExited = changed.some(c => c.key === dividerKey && !c.isViewable)\n if (!dividerExited) return false\n\n const visibleMessageIds = viewableItems\n .filter(v => v.item?.type === 'Message')\n .map(v => v.item?.id)\n .filter((id): id is string => !!id)\n if (visibleMessageIds.length === 0) return false\n\n return visibleMessageIds.every(id => id.localeCompare(initialMessageId) > 0)\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planningcenter/chat-react-native",
3
- "version": "3.35.0-rc.2",
3
+ "version": "3.35.0-rc.4",
4
4
  "description": "",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -52,9 +52,12 @@
52
52
  },
53
53
  "devDependencies": {
54
54
  "@react-native/eslint-config": "~0.83.0",
55
- "@testing-library/react-hooks": "^8.0.1",
55
+ "@testing-library/react-native": "^13.2.0",
56
+ "@types/color": "^3.0.0",
56
57
  "@types/jest": "^29.5.14",
57
- "@typescript-eslint/parser": "^8.32.0",
58
+ "@typescript-eslint/parser": "^8.36.0",
59
+ "color": "^3.1.2",
60
+ "eslint": "8.57.1",
58
61
  "eslint-plugin-react-compiler": "^19.1.0-rc.2",
59
62
  "expo-module-scripts": "^55.0.0",
60
63
  "fast-text-encoding": "^1.0.6",
@@ -62,8 +65,11 @@
62
65
  "jest-fetch-mock": "^3.0.3",
63
66
  "msw": "^2.7.3",
64
67
  "prettier": "^3.4.2",
68
+ "react": "19.2.0",
69
+ "react-native": "0.83.6",
70
+ "react-native-svg": "15.15.3",
65
71
  "react-native-url-polyfill": "^2.0.0",
66
72
  "typescript": "~5.9.2"
67
73
  },
68
- "gitHead": "eecec62a4683e528bb8e2f275d19fb9915544679"
74
+ "gitHead": "033e05c3a604adc1e36af2b8673719455e9e0f86"
69
75
  }
@@ -1,6 +1,6 @@
1
1
  import AsyncStorage from '@react-native-async-storage/async-storage'
2
2
  import { QueryClientProvider } from '@tanstack/react-query'
3
- import { renderHook, act } from '@testing-library/react-hooks'
3
+ import { renderHook, act } from '@testing-library/react-native'
4
4
  import React, { useContext, Suspense } from 'react'
5
5
  import { buildTestQueryClient } from '../../__utils__/query_client'
6
6
  import {
@@ -1,6 +1,6 @@
1
1
  import AsyncStorage from '@react-native-async-storage/async-storage'
2
2
  import { QueryClientProvider } from '@tanstack/react-query'
3
- import { renderHook, act } from '@testing-library/react-hooks'
3
+ import { renderHook, act } from '@testing-library/react-native'
4
4
  import React, { Suspense } from 'react'
5
5
  import { buildTestQueryClient } from '../../__utils__/query_client'
6
6
  import { useAsyncStorage } from '../../hooks/use_async_storage'
@@ -1,5 +1,5 @@
1
1
  import { QueryClientProvider } from '@tanstack/react-query'
2
- import { renderHook, act } from '@testing-library/react-hooks'
2
+ import { renderHook, act } from '@testing-library/react-native'
3
3
  import React from 'react'
4
4
  import { buildTestQueryClient } from '../../__utils__/query_client'
5
5
  import { useApiClient } from '../../hooks/use_api_client'
@@ -1,5 +1,5 @@
1
1
  import { QueryClientProvider } from '@tanstack/react-query'
2
- import { renderHook, act } from '@testing-library/react-hooks'
2
+ import { renderHook, act } from '@testing-library/react-native'
3
3
  import React, { Suspense } from 'react'
4
4
  import { buildTestQueryClient } from '../../__utils__/query_client'
5
5
  import {
@@ -1,5 +1,5 @@
1
1
  import { QueryClientProvider } from '@tanstack/react-query'
2
- import { act, renderHook } from '@testing-library/react-hooks'
2
+ import { act, renderHook } from '@testing-library/react-native'
3
3
  import React, { Suspense } from 'react'
4
4
  import { buildTestQueryClient } from '../../__utils__/query_client'
5
5
  import { ConversationContextProvider } from '../../contexts/conversation_context'
@@ -0,0 +1,154 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
+ import { renderHook } from '@testing-library/react-native'
3
+ import React, { useEffect } from 'react'
4
+ import { buildTestQueryClient } from '../../__utils__/query_client'
5
+ import {
6
+ ConversationContextProvider,
7
+ useConversationContext,
8
+ } from '../../contexts/conversation_context'
9
+ import * as appStateModule from '../../hooks/use_app_state'
10
+ import * as conversationsActionsModule from '../../hooks/use_conversations_actions'
11
+ import * as featuresModule from '../../hooks/use_features'
12
+ import { useMarkLatestMessageRead } from '../../hooks/use_mark_latest_message_read'
13
+ import { ConversationResource } from '../../types'
14
+
15
+ const conversation = {
16
+ id: 1,
17
+ type: 'Conversation',
18
+ unreadCount: 3,
19
+ unreadReactionCount: 0,
20
+ conversationMembership: { lastReadMessageSortKey: '01A' },
21
+ } as unknown as ConversationResource
22
+
23
+ interface WrapperProps {
24
+ replyRootId?: string | null
25
+ initialMessageIdIsAnchor?: boolean
26
+ atEndOfMessageHistory?: boolean
27
+ }
28
+
29
+ const createWrapper = ({
30
+ replyRootId = null,
31
+ initialMessageIdIsAnchor = false,
32
+ atEndOfMessageHistory = !initialMessageIdIsAnchor,
33
+ }: WrapperProps = {}) => {
34
+ const queryClient = buildTestQueryClient()
35
+ const Wrapper = ({ children }: { children: React.ReactNode }) => (
36
+ <QueryClientProvider client={queryClient}>
37
+ <ConversationContextProvider
38
+ conversationId={1}
39
+ currentPageReplyRootId={replyRootId}
40
+ initialMessageId={initialMessageIdIsAnchor ? '01A' : null}
41
+ initialMessageIdIsAnchor={initialMessageIdIsAnchor}
42
+ >
43
+ <AtEndPrimer atEndOfMessageHistory={atEndOfMessageHistory}>{children}</AtEndPrimer>
44
+ </ConversationContextProvider>
45
+ </QueryClientProvider>
46
+ )
47
+ return Wrapper
48
+ }
49
+
50
+ const AtEndPrimer = ({
51
+ atEndOfMessageHistory,
52
+ children,
53
+ }: {
54
+ atEndOfMessageHistory: boolean
55
+ children: React.ReactNode
56
+ }) => {
57
+ const { setAtEndOfMessageHistory } = useConversationContext()
58
+ useEffect(() => {
59
+ setAtEndOfMessageHistory(atEndOfMessageHistory)
60
+ }, [atEndOfMessageHistory, setAtEndOfMessageHistory])
61
+ return <>{children}</>
62
+ }
63
+
64
+ const mockFeatures = (enabled: boolean) => {
65
+ jest.spyOn(featuresModule, 'useFeatures').mockReturnValue({
66
+ features: [],
67
+ featureEnabled: () => enabled,
68
+ })
69
+ }
70
+
71
+ const mockMarkRead = () => {
72
+ const markRead = jest.fn()
73
+ jest.spyOn(conversationsActionsModule, 'useConversationsMarkRead').mockReturnValue({
74
+ markRead,
75
+ read: false,
76
+ } as unknown as ReturnType<typeof conversationsActionsModule.useConversationsMarkRead>)
77
+ return markRead
78
+ }
79
+
80
+ const mockAppState = (state: string = 'active') => {
81
+ jest.spyOn(appStateModule, 'useAppState').mockReturnValue(state)
82
+ }
83
+
84
+ describe('useMarkLatestMessageRead', () => {
85
+ afterEach(() => {
86
+ jest.restoreAllMocks()
87
+ })
88
+
89
+ it('fires markRead when JTU is off (backward compat preserved)', () => {
90
+ mockAppState()
91
+ mockFeatures(false)
92
+ const markRead = mockMarkRead()
93
+
94
+ renderHook(() => useMarkLatestMessageRead({ conversation }), {
95
+ wrapper: createWrapper(),
96
+ })
97
+
98
+ expect(markRead).toHaveBeenCalledWith(true)
99
+ })
100
+
101
+ it('does NOT fire markRead when in a reply view (parity fix, ships flag-off)', () => {
102
+ mockAppState()
103
+ mockFeatures(false)
104
+ const markRead = mockMarkRead()
105
+
106
+ renderHook(() => useMarkLatestMessageRead({ conversation }), {
107
+ wrapper: createWrapper({ replyRootId: 'root-1' }),
108
+ })
109
+
110
+ expect(markRead).not.toHaveBeenCalled()
111
+ })
112
+
113
+ it('does NOT fire markRead when JTU is active and user is not at end of history', () => {
114
+ mockAppState()
115
+ mockFeatures(true)
116
+ const markRead = mockMarkRead()
117
+
118
+ renderHook(() => useMarkLatestMessageRead({ conversation }), {
119
+ wrapper: createWrapper({
120
+ initialMessageIdIsAnchor: true,
121
+ atEndOfMessageHistory: false,
122
+ }),
123
+ })
124
+
125
+ expect(markRead).not.toHaveBeenCalled()
126
+ })
127
+
128
+ it('fires markRead when JTU is active and user reaches end of history', () => {
129
+ mockAppState()
130
+ mockFeatures(true)
131
+ const markRead = mockMarkRead()
132
+
133
+ renderHook(() => useMarkLatestMessageRead({ conversation }), {
134
+ wrapper: createWrapper({
135
+ initialMessageIdIsAnchor: true,
136
+ atEndOfMessageHistory: true,
137
+ }),
138
+ })
139
+
140
+ expect(markRead).toHaveBeenCalledWith(true)
141
+ })
142
+
143
+ it('does NOT fire when app state is not active', () => {
144
+ mockAppState('background')
145
+ mockFeatures(false)
146
+ const markRead = mockMarkRead()
147
+
148
+ renderHook(() => useMarkLatestMessageRead({ conversation }), {
149
+ wrapper: createWrapper(),
150
+ })
151
+
152
+ expect(markRead).not.toHaveBeenCalled()
153
+ })
154
+ })
@@ -0,0 +1,54 @@
1
+ import { buildTestQueryClient } from '../../../__utils__/query_client'
2
+ import { hasUnloadedNewerPages } from '../../../utils/cache/messages_cache'
3
+
4
+ const queryKey = ['messages-test']
5
+
6
+ const buildClient = (data: unknown) => {
7
+ const client = buildTestQueryClient()
8
+ client.setQueryData(queryKey, data)
9
+ return client
10
+ }
11
+
12
+ describe('hasUnloadedNewerPages', () => {
13
+ it('returns false when the query has no cached data', () => {
14
+ const client = buildTestQueryClient()
15
+ expect(hasUnloadedNewerPages(client, queryKey)).toBe(false)
16
+ })
17
+
18
+ it('returns false when pages[0] has no next.idGt cursor', () => {
19
+ const client = buildClient({
20
+ pageParams: [{}],
21
+ pages: [{ data: [], links: {}, meta: { count: 0, totalCount: 0 } }],
22
+ })
23
+ expect(hasUnloadedNewerPages(client, queryKey)).toBe(false)
24
+ })
25
+
26
+ it('returns false when next exists but idGt is absent', () => {
27
+ const client = buildClient({
28
+ pageParams: [{}],
29
+ pages: [{ data: [], links: {}, meta: { count: 0, totalCount: 0, next: { idLt: '01A' } } }],
30
+ })
31
+ expect(hasUnloadedNewerPages(client, queryKey)).toBe(false)
32
+ })
33
+
34
+ it('returns true when pages[0].meta.next.idGt is set', () => {
35
+ const client = buildClient({
36
+ pageParams: [{}],
37
+ pages: [
38
+ { data: [], links: {}, meta: { count: 0, totalCount: 0, next: { idGt: '01KQSTAY' } } },
39
+ ],
40
+ })
41
+ expect(hasUnloadedNewerPages(client, queryKey)).toBe(true)
42
+ })
43
+
44
+ it('inspects only the first page (the newest side after sort)', () => {
45
+ const client = buildClient({
46
+ pageParams: [{}, {}],
47
+ pages: [
48
+ { data: [], links: {}, meta: { count: 0, totalCount: 0 } },
49
+ { data: [], links: {}, meta: { count: 0, totalCount: 0, next: { idGt: '01KQSTAY' } } },
50
+ ],
51
+ })
52
+ expect(hasUnloadedNewerPages(client, queryKey)).toBe(false)
53
+ })
54
+ })