@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.
- package/README.md +1 -1
- package/build/components/conversation/jump_to_bottom_button.d.ts +2 -1
- package/build/components/conversation/jump_to_bottom_button.d.ts.map +1 -1
- package/build/components/conversation/jump_to_bottom_button.js +39 -7
- package/build/components/conversation/jump_to_bottom_button.js.map +1 -1
- package/build/components/conversation/reply_shadow_message.d.ts +1 -2
- package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
- package/build/components/conversation/reply_shadow_message.js.map +1 -1
- package/build/components/conversation/unread_divider.d.ts +6 -0
- package/build/components/conversation/unread_divider.d.ts.map +1 -0
- package/build/components/conversation/unread_divider.js +59 -0
- package/build/components/conversation/unread_divider.js.map +1 -0
- package/build/contexts/conversation_context.d.ts +2 -0
- package/build/contexts/conversation_context.d.ts.map +1 -1
- package/build/contexts/conversation_context.js +13 -5
- package/build/contexts/conversation_context.js.map +1 -1
- package/build/hooks/use_conversation_messages.d.ts +2 -0
- package/build/hooks/use_conversation_messages.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages.js +9 -5
- package/build/hooks/use_conversation_messages.js.map +1 -1
- package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages_jolt_events.js +4 -4
- package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
- package/build/hooks/use_conversations_actions.d.ts +5 -0
- package/build/hooks/use_conversations_actions.d.ts.map +1 -1
- package/build/hooks/use_conversations_actions.js +12 -0
- package/build/hooks/use_conversations_actions.js.map +1 -1
- package/build/hooks/use_flat_list_viewability.d.ts +20 -0
- package/build/hooks/use_flat_list_viewability.d.ts.map +1 -0
- package/build/hooks/use_flat_list_viewability.js +30 -0
- package/build/hooks/use_flat_list_viewability.js.map +1 -0
- package/build/hooks/use_jump_to_bottom_action.d.ts +9 -0
- package/build/hooks/use_jump_to_bottom_action.d.ts.map +1 -0
- package/build/hooks/use_jump_to_bottom_action.js +62 -0
- package/build/hooks/use_jump_to_bottom_action.js.map +1 -0
- package/build/hooks/use_jump_to_unread_anchor.d.ts +20 -0
- package/build/hooks/use_jump_to_unread_anchor.d.ts.map +1 -0
- package/build/hooks/use_jump_to_unread_anchor.js +53 -0
- package/build/hooks/use_jump_to_unread_anchor.js.map +1 -0
- package/build/hooks/use_jump_to_unread_gates.d.ts +5 -0
- package/build/hooks/use_jump_to_unread_gates.d.ts.map +1 -0
- package/build/hooks/use_jump_to_unread_gates.js +10 -0
- package/build/hooks/use_jump_to_unread_gates.js.map +1 -0
- package/build/hooks/use_mark_latest_message_read.d.ts +1 -1
- package/build/hooks/use_mark_latest_message_read.d.ts.map +1 -1
- package/build/hooks/use_mark_latest_message_read.js +17 -1
- package/build/hooks/use_mark_latest_message_read.js.map +1 -1
- package/build/hooks/use_scroll_tracking.d.ts +13 -0
- package/build/hooks/use_scroll_tracking.d.ts.map +1 -0
- package/build/hooks/use_scroll_tracking.js +45 -0
- package/build/hooks/use_scroll_tracking.js.map +1 -0
- package/build/hooks/use_track_highest_seen_message.d.ts +4 -0
- package/build/hooks/use_track_highest_seen_message.d.ts.map +1 -0
- package/build/hooks/use_track_highest_seen_message.js +35 -0
- package/build/hooks/use_track_highest_seen_message.js.map +1 -0
- package/build/navigation/index.d.ts.map +1 -1
- package/build/screens/conversation_screen.d.ts +0 -19
- package/build/screens/conversation_screen.d.ts.map +1 -1
- package/build/screens/conversation_screen.js +87 -139
- package/build/screens/conversation_screen.js.map +1 -1
- package/build/utils/cache/messages_cache.d.ts +1 -0
- package/build/utils/cache/messages_cache.d.ts.map +1 -1
- package/build/utils/cache/messages_cache.js +4 -0
- package/build/utils/cache/messages_cache.js.map +1 -1
- package/build/utils/group_messages.d.ts +28 -0
- package/build/utils/group_messages.d.ts.map +1 -0
- package/build/utils/group_messages.js +142 -0
- package/build/utils/group_messages.js.map +1 -0
- package/build/utils/highest_seen_tracker.d.ts +12 -0
- package/build/utils/highest_seen_tracker.d.ts.map +1 -0
- package/build/utils/highest_seen_tracker.js +37 -0
- package/build/utils/highest_seen_tracker.js.map +1 -0
- package/build/utils/message_viewability.d.ts +24 -0
- package/build/utils/message_viewability.d.ts.map +1 -0
- package/build/utils/message_viewability.js +29 -0
- package/build/utils/message_viewability.js.map +1 -0
- package/build/utils/unread_divider_helpers.d.ts +18 -0
- package/build/utils/unread_divider_helpers.d.ts.map +1 -0
- package/build/utils/unread_divider_helpers.js +13 -0
- package/build/utils/unread_divider_helpers.js.map +1 -0
- package/package.json +10 -4
- package/src/__tests__/contexts/session_context.tsx +1 -1
- package/src/__tests__/hooks/use_async_storage.test.tsx +1 -1
- package/src/__tests__/hooks/use_attachment_uploader.test.tsx +1 -1
- package/src/__tests__/hooks/use_chat_configuration.test.tsx +1 -1
- package/src/__tests__/hooks/use_conversation_messages.test.tsx +1 -1
- package/src/__tests__/hooks/use_mark_latest_message_read.test.tsx +154 -0
- package/src/__tests__/utils/cache/messages_cache.test.ts +54 -0
- package/src/components/conversation/jump_to_bottom_button.tsx +57 -8
- package/src/components/conversation/reply_shadow_message.tsx +4 -2
- package/src/components/conversation/unread_divider.tsx +90 -0
- package/src/contexts/conversation_context.tsx +15 -13
- package/src/hooks/use_conversation_messages.ts +19 -3
- package/src/hooks/use_conversation_messages_jolt_events.ts +4 -3
- package/src/hooks/use_conversations_actions.ts +15 -0
- package/src/hooks/use_flat_list_viewability.ts +50 -0
- package/src/hooks/use_jump_to_bottom_action.ts +75 -0
- package/src/hooks/use_jump_to_unread_anchor.ts +68 -0
- package/src/hooks/use_jump_to_unread_gates.ts +10 -0
- package/src/hooks/use_mark_latest_message_read.ts +16 -2
- package/src/hooks/use_scroll_tracking.ts +64 -0
- package/src/hooks/use_track_highest_seen_message.ts +43 -0
- package/src/screens/conversation_screen.tsx +173 -197
- package/src/utils/__tests__/group_messages.test.ts +214 -0
- package/src/utils/__tests__/highest_seen_tracker.test.ts +82 -0
- package/src/utils/__tests__/message_viewability.test.ts +168 -0
- package/src/utils/__tests__/unread_divider_helpers.test.ts +85 -0
- package/src/utils/cache/messages_cache.ts +5 -0
- package/src/utils/group_messages.ts +217 -0
- package/src/utils/highest_seen_tracker.ts +42 -0
- package/src/utils/message_viewability.ts +49 -0
- 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":"AACA,OAAO,KAAK,MAAM,SAAS,CAAA;AAC3B,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AAEnD,MAAM,eAAe,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,CAAA;AAErC,MAAM,CAAC,MAAM,kBAAkB,GAAG,gBAAgB,CAAA;AA2BlD,MAAM,UAAU,aAAa,CAAC,EAC5B,EAAE,EACF,aAAa,EACb,kBAAkB,EAClB,gBAAgB,GACG;IACnB,MAAM,KAAK,GAAsB,EAAE,CAAA;IACnC,IAAI,YAAY,GAAG,KAAK,CAAA;IACxB,IAAI,oBAAiD,CAAA;IAErD,EAAE,CAAC,OAAO,CAAC,CAAC,OAAO,EAAE,CAAC,EAAE,EAAE;QACxB,MAAM,EAAE,IAAI,EAAE,GAAG,WAAW,CAAC,EAAE,EAAE,CAAC,CAAC,CAAA;QACnC,MAAM,IAAI,GAAG,oBAAoB,CAAA;QAEjC,IAAI,eAAe,CAAC,OAAO,CAAC,EAAE,CAAC;YAC7B,MAAM,QAAQ,GAAG,mBAAmB,CAAC,OAAO,EAAE,IAAI,CAAC,CAAA;YACnD,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;YACpB,IAAI,qBAAqB,CAAC,OAAO,EAAE,IAAI,EAAE,kBAAkB,EAAE,gBAAgB,CAAC,EAAE,CAAC;gBAC/E,KAAK,CAAC,IAAI,CAAC,aAAa,EAAE,CAAC,CAAA;YAC7B,CAAC;YACD,IAAI,kBAAkB,CAAC,OAAO,EAAE,IAAI,CAAC;gBAAE,KAAK,CAAC,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC,CAAA;YACzE,oBAAoB,GAAG,QAAQ,CAAA;YAC/B,OAAM;QACR,CAAC;QAED,MAAM,UAAU,GAAG,CAAC,YAAY,IAAI,OAAO,CAAC,IAAI,CAAA;QAChD,IAAI,UAAU;YAAE,YAAY,GAAG,IAAI,CAAA;QAEnC,MAAM,QAAQ,GAAG,oBAAoB,CAAC,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,UAAU,EAAE,CAAC,CAAC,aAAa,CAAC,CAAA;QACvF,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;QAEpB,IAAI,qBAAqB,CAAC,OAAO,EAAE,IAAI,EAAE,kBAAkB,EAAE,gBAAgB,CAAC,EAAE,CAAC;YAC/E,KAAK,CAAC,IAAI,CAAC,aAAa,EAAE,CAAC,CAAA;QAC7B,CAAC;QAED,MAAM,MAAM,GAAG,cAAc,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAA;QAC7C,IAAI,MAAM;YAAE,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QAE9B,IAAI,CAAC,IAAI,IAAI,kBAAkB,CAAC,OAAO,EAAE,IAAI,CAAC,EAAE,CAAC;YAC/C,KAAK,CAAC,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC,CAAA;QACpC,CAAC;QAED,oBAAoB,GAAG,QAAQ,CAAA;IACjC,CAAC,CAAC,CAAA;IAEF,OAAO,KAAK,CAAA;AACd,CAAC;AAED,SAAS,qBAAqB,CAC5B,OAAwB,EACxB,IAAiC,EACjC,kBAAuC,EACvC,gBAA2C;IAE3C,IAAI,CAAC,kBAAkB;QAAE,OAAO,KAAK,CAAA;IACrC,IAAI,CAAC,gBAAgB;QAAE,OAAO,KAAK,CAAA;IACnC,IAAI,CAAC,IAAI;QAAE,OAAO,KAAK,CAAA;IACvB,OAAO,CACL,IAAI,CAAC,EAAE,CAAC,aAAa,CAAC,gBAAgB,CAAC,IAAI,CAAC,IAAI,OAAO,CAAC,EAAE,CAAC,aAAa,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAC/F,CAAA;AACH,CAAC;AAED,SAAS,aAAa;IACpB,OAAO,EAAE,IAAI,EAAE,eAAe,EAAE,EAAE,EAAE,kBAAkB,EAAE,CAAA;AAC1D,CAAC;AAED,SAAS,WAAW,CAAI,GAAQ,EAAE,CAAS;IACzC,OAAO,EAAE,IAAI,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,EAAE,CAAA;AAC/C,CAAC;AAED,SAAS,mBAAmB,CAC1B,OAAwB,EACxB,IAAiC;IAEjC,OAAO;QACL,GAAG,OAAO;QACV,sBAAsB,EAAE,KAAK;QAC7B,WAAW,EAAE,IAAI;QACjB,YAAY,EAAE,KAAK;QACnB,iBAAiB,EAAE,IAAI,EAAE,YAAY;QACrC,oBAAoB,EAAE,KAAK;QAC3B,wBAAwB,EAAE,KAAK;QAC/B,cAAc,EAAE,IAAI;KACrB,CAAA;AACH,CAAC;AAED,SAAS,oBAAoB,CAC3B,OAAwB,EACxB,IAAiC,EACjC,IAAiC,EACjC,UAAmB,EACnB,aAAsB;IAEtB,MAAM,QAAQ,GAAG,OAAO,CAAC,WAAW,KAAK,IAAI,CAAA;IAC7C,MAAM,iBAAiB,GAAG,CAAC,aAAa,IAAI,QAAQ,CAAA;IAEpD,OAAO;QACL,GAAG,OAAO;QACV,sBAAsB,EAAE,UAAU;QAClC,WAAW,EAAE,CAAC,IAAI,IAAI,cAAc,CAAC,IAAI,EAAE,OAAO,CAAC;QACnD,YAAY,EAAE,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC,CAAC,IAAI,IAAI,cAAc,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;QACvE,iBAAiB,EAAE,IAAI,EAAE,YAAY;QACrC,oBAAoB,EAAE,KAAK;QAC3B,wBAAwB,EAAE,yBAAyB,CAAC,IAAI,EAAE,OAAO,CAAC;QAClE,cAAc,EAAE,iBAAiB,CAAC,CAAC,CAAC,iBAAiB,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI;QAC3E,aAAa,EAAE,iBAAiB,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,SAAS;QACzD,aAAa,EAAE,iBAAiB,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,SAAS;KAC1D,CAAA;AACH,CAAC;AAED,SAAS,cAAc,CAAC,CAAkB,EAAE,CAAkB;IAC5D,OAAO,CACL,CAAC,CAAC,MAAM,EAAE,EAAE,KAAK,CAAC,CAAC,MAAM,EAAE,EAAE;QAC7B,qBAAqB,CAAC,CAAC,EAAE,CAAC,CAAC;QAC3B,CAAC,CAAC,WAAW,KAAK,CAAC,CAAC,WAAW;QAC/B,kBAAkB,CAAC,CAAC,EAAE,CAAC,CAAC,CACzB,CAAA;AACH,CAAC;AAED,SAAS,qBAAqB,CAAC,CAAkB,EAAE,CAAkB;IACnE,OAAO,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,GAAG,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,GAAG,eAAe,CAAA;AAClF,CAAC;AAED,SAAS,QAAQ,CAAC,GAAW;IAC3B,OAAO,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,CAAA;AAChC,CAAC;AAED,SAAS,kBAAkB,CAAC,CAAkB,EAAE,CAA8B;IAC5E,IAAI,CAAC,CAAC;QAAE,OAAO,KAAK,CAAA;IACpB,OAAO,OAAO,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAAA;AAClC,CAAC;AAED,SAAS,OAAO,CAAC,OAAwB;IACvC,OAAO,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC,YAAY,CAAC,CAAA;AACtD,CAAC;AAED,SAAS,aAAa,CAAC,OAAwB;IAC7C,OAAO,EAAE,IAAI,EAAE,eAAe,EAAE,EAAE,EAAE,eAAe,OAAO,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,EAAE,CAAA;AAC3F,CAAC;AAED,SAAS,iBAAiB,CACxB,OAAwB,EACxB,IAAiC;IAEjC,MAAM,YAAY,GAAG,OAAO,CAAC,WAAW,KAAK,OAAO,CAAC,EAAE,CAAA;IACvD,MAAM,MAAM,GACV,CAAC,IAAI,IAAI,IAAI,CAAC,WAAW,KAAK,OAAO,CAAC,WAAW,IAAI,kBAAkB,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;IAExF,IAAI,YAAY,IAAI,MAAM;QAAE,OAAO,IAAI,CAAA;IACvC,IAAI,YAAY;QAAE,OAAO,OAAO,CAAA;IAChC,IAAI,MAAM;QAAE,OAAO,MAAM,CAAA;IACzB,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED,SAAS,yBAAyB,CAChC,IAAiC,EACjC,OAAwB;IAExB,IAAI,CAAC,IAAI;QAAE,OAAO,KAAK,CAAA;IACvB,MAAM,YAAY,GAAG,IAAI,CAAC,WAAW,KAAK,IAAI,CAAA;IAC9C,MAAM,gBAAgB,GAAG,IAAI,CAAC,WAAW,KAAK,IAAI,CAAC,EAAE,CAAA;IACrD,MAAM,eAAe,GAAG,IAAI,CAAC,WAAW,KAAK,OAAO,CAAC,WAAW,CAAA;IAChE,OAAO,YAAY,IAAI,CAAC,gBAAgB,IAAI,CAAC,eAAe,IAAI,kBAAkB,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,CAAA;AACpG,CAAC;AAED,SAAS,cAAc,CACrB,OAAwB,EACxB,IAAiC;IAEjC,IAAI,CAAC,OAAO,CAAC,WAAW;QAAE,OAAO,SAAS,CAAA;IAC1C,IAAI,OAAO,CAAC,WAAW,KAAK,OAAO,CAAC,EAAE;QAAE,OAAO,SAAS,CAAA;IAExD,MAAM,iBAAiB,GACrB,CAAC,IAAI,IAAI,IAAI,CAAC,WAAW,KAAK,OAAO,CAAC,WAAW,IAAI,kBAAkB,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;IACxF,IAAI,CAAC,iBAAiB;QAAE,OAAO,SAAS,CAAA;IAExC,OAAO;QACL,IAAI,EAAE,oBAAoB;QAC1B,EAAE,EAAE,GAAG,OAAO,CAAC,EAAE,IAAI,OAAO,CAAC,WAAW,EAAE;QAC1C,SAAS,EAAE,OAAO,CAAC,WAAW;QAC9B,oBAAoB,EAAE,IAAI;QAC1B,iBAAiB,EAAE,OAAO,CAAC,YAAY,IAAI,KAAK;KACjD,CAAA;AACH,CAAC","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.
|
|
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-
|
|
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.
|
|
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": "
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
+
})
|