@appsupportkit/support-sdk 0.1.1 → 0.2.0
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/lib/commonjs/api.js +29 -0
- package/lib/commonjs/api.js.map +1 -1
- package/lib/commonjs/components/BottomSheet.js +67 -47
- package/lib/commonjs/components/BottomSheet.js.map +1 -1
- package/lib/commonjs/components/LauncherButton.js +177 -0
- package/lib/commonjs/components/LauncherButton.js.map +1 -0
- package/lib/commonjs/components/SupportButton.js +76 -146
- package/lib/commonjs/components/SupportButton.js.map +1 -1
- package/lib/commonjs/components/icons.js +74 -23
- package/lib/commonjs/components/icons.js.map +1 -1
- package/lib/commonjs/components/primitives.js +183 -38
- package/lib/commonjs/components/primitives.js.map +1 -1
- package/lib/commonjs/components/screens/ComposeScreen.js +115 -58
- package/lib/commonjs/components/screens/ComposeScreen.js.map +1 -1
- package/lib/commonjs/components/screens/HomeScreen.js +170 -115
- package/lib/commonjs/components/screens/HomeScreen.js.map +1 -1
- package/lib/commonjs/components/screens/ThreadScreen.js +222 -108
- package/lib/commonjs/components/screens/ThreadScreen.js.map +1 -1
- package/lib/commonjs/index.js +7 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/push.js +103 -0
- package/lib/commonjs/push.js.map +1 -0
- package/lib/commonjs/theme.js +136 -17
- package/lib/commonjs/theme.js.map +1 -1
- package/lib/commonjs/types.js +7 -0
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/unread.js +92 -0
- package/lib/commonjs/unread.js.map +1 -0
- package/lib/commonjs/useSupportThreads.js +286 -0
- package/lib/commonjs/useSupportThreads.js.map +1 -0
- package/lib/commonjs/utils.js +33 -0
- package/lib/commonjs/utils.js.map +1 -1
- package/lib/module/api.js +27 -0
- package/lib/module/api.js.map +1 -1
- package/lib/module/components/BottomSheet.js +68 -48
- package/lib/module/components/BottomSheet.js.map +1 -1
- package/lib/module/components/LauncherButton.js +172 -0
- package/lib/module/components/LauncherButton.js.map +1 -0
- package/lib/module/components/SupportButton.js +79 -149
- package/lib/module/components/SupportButton.js.map +1 -1
- package/lib/module/components/icons.js +72 -22
- package/lib/module/components/icons.js.map +1 -1
- package/lib/module/components/primitives.js +179 -39
- package/lib/module/components/primitives.js.map +1 -1
- package/lib/module/components/screens/ComposeScreen.js +118 -61
- package/lib/module/components/screens/ComposeScreen.js.map +1 -1
- package/lib/module/components/screens/HomeScreen.js +172 -117
- package/lib/module/components/screens/HomeScreen.js.map +1 -1
- package/lib/module/components/screens/ThreadScreen.js +228 -112
- package/lib/module/components/screens/ThreadScreen.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/push.js +98 -0
- package/lib/module/push.js.map +1 -0
- package/lib/module/theme.js +135 -16
- package/lib/module/theme.js.map +1 -1
- package/lib/module/types.js +7 -0
- package/lib/module/types.js.map +1 -1
- package/lib/module/unread.js +83 -0
- package/lib/module/unread.js.map +1 -0
- package/lib/module/useSupportThreads.js +281 -0
- package/lib/module/useSupportThreads.js.map +1 -0
- package/lib/module/utils.js +30 -0
- package/lib/module/utils.js.map +1 -1
- package/lib/typescript/api.d.ts +17 -1
- package/lib/typescript/api.d.ts.map +1 -1
- package/lib/typescript/components/BottomSheet.d.ts.map +1 -1
- package/lib/typescript/components/LauncherButton.d.ts +14 -0
- package/lib/typescript/components/LauncherButton.d.ts.map +1 -0
- package/lib/typescript/components/SupportButton.d.ts +3 -3
- package/lib/typescript/components/SupportButton.d.ts.map +1 -1
- package/lib/typescript/components/icons.d.ts +13 -2
- package/lib/typescript/components/icons.d.ts.map +1 -1
- package/lib/typescript/components/primitives.d.ts +35 -4
- package/lib/typescript/components/primitives.d.ts.map +1 -1
- package/lib/typescript/components/screens/ComposeScreen.d.ts.map +1 -1
- package/lib/typescript/components/screens/HomeScreen.d.ts +3 -2
- package/lib/typescript/components/screens/HomeScreen.d.ts.map +1 -1
- package/lib/typescript/components/screens/ThreadScreen.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +2 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/push.d.ts +18 -0
- package/lib/typescript/push.d.ts.map +1 -0
- package/lib/typescript/theme.d.ts +44 -3
- package/lib/typescript/theme.d.ts.map +1 -1
- package/lib/typescript/types.d.ts +37 -4
- package/lib/typescript/types.d.ts.map +1 -1
- package/lib/typescript/unread.d.ts +22 -0
- package/lib/typescript/unread.d.ts.map +1 -0
- package/lib/typescript/useSupportThreads.d.ts +28 -0
- package/lib/typescript/useSupportThreads.d.ts.map +1 -0
- package/lib/typescript/utils.d.ts +5 -0
- package/lib/typescript/utils.d.ts.map +1 -1
- package/package.json +8 -2
- package/src/api.ts +41 -1
- package/src/components/BottomSheet.tsx +61 -43
- package/src/components/LauncherButton.tsx +217 -0
- package/src/components/SupportButton.tsx +81 -162
- package/src/components/icons.tsx +77 -19
- package/src/components/primitives.tsx +217 -36
- package/src/components/screens/ComposeScreen.tsx +131 -61
- package/src/components/screens/HomeScreen.tsx +168 -117
- package/src/components/screens/ThreadScreen.tsx +289 -119
- package/src/index.tsx +3 -0
- package/src/push.ts +97 -0
- package/src/theme.ts +165 -18
- package/src/types.ts +39 -4
- package/src/unread.ts +91 -0
- package/src/useSupportThreads.ts +372 -0
- package/src/utils.ts +33 -0
package/src/theme.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// The SDK consumes the same design tokens as the admin dashboard, so both
|
|
2
|
-
// surfaces look like one product.
|
|
2
|
+
// surfaces look like one product. On top of those it adds the few values
|
|
3
|
+
// that only exist on mobile: sheet/bubble radii and the elevation used by
|
|
4
|
+
// the launcher and the sheet (the web side gets those from CSS).
|
|
3
5
|
import {
|
|
4
6
|
darkColors,
|
|
5
7
|
lightColors,
|
|
@@ -8,32 +10,177 @@ import {
|
|
|
8
10
|
spacing,
|
|
9
11
|
type ThemeColors,
|
|
10
12
|
} from '@appsupportkit/design-tokens';
|
|
13
|
+
import type { ViewStyle } from 'react-native';
|
|
11
14
|
import type { ThemeName } from './types';
|
|
12
15
|
|
|
16
|
+
/** Rounder than the dashboard: touch targets read better with more radius. */
|
|
17
|
+
export const supportRadii = {
|
|
18
|
+
...radii,
|
|
19
|
+
control: 10,
|
|
20
|
+
card: 14,
|
|
21
|
+
cardLg: 18,
|
|
22
|
+
/** Message bubbles. */
|
|
23
|
+
bubble: 18,
|
|
24
|
+
/** The tight corner on a bubble's sender side. */
|
|
25
|
+
bubbleTail: 6,
|
|
26
|
+
sheet: 24,
|
|
27
|
+
pill: 999,
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
export interface SupportShadows {
|
|
31
|
+
/** Floating launcher button. */
|
|
32
|
+
launcher: ViewStyle;
|
|
33
|
+
/** The bottom sheet itself. */
|
|
34
|
+
sheet: ViewStyle;
|
|
35
|
+
/** Unread badge, so it lifts off the button. */
|
|
36
|
+
badge: ViewStyle;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The launcher sits on the host app's own background rather than on an SDK
|
|
41
|
+
* surface, so the two themes need different treatment: on near-black a
|
|
42
|
+
* solid fill needs a hairline to separate from the background and a black
|
|
43
|
+
* shadow, while on white the accent-tinted shadow does that work and a
|
|
44
|
+
* borderless mark has to be darkened to stay legible.
|
|
45
|
+
*/
|
|
46
|
+
export interface LauncherPalette {
|
|
47
|
+
/** Hairline drawn over the solid fill. */
|
|
48
|
+
solidRing: string;
|
|
49
|
+
/** Mark color for the borderless (minimal) variant. */
|
|
50
|
+
minimalMark: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
13
53
|
export interface SupportTheme {
|
|
14
54
|
name: ThemeName;
|
|
15
55
|
colors: ThemeColors;
|
|
16
56
|
spacing: typeof spacing;
|
|
17
|
-
radii: typeof
|
|
57
|
+
radii: typeof supportRadii;
|
|
18
58
|
motion: typeof motion;
|
|
59
|
+
shadows: SupportShadows;
|
|
60
|
+
launcher: LauncherPalette;
|
|
19
61
|
}
|
|
20
62
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
radii,
|
|
26
|
-
motion,
|
|
27
|
-
};
|
|
63
|
+
// Color helpers come first: the themes below are built at module load, so
|
|
64
|
+
// anything they reach for has to already be initialized. (A `const` used
|
|
65
|
+
// before its definition survives tsc but lands as `undefined` on Hermes.)
|
|
66
|
+
const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
28
67
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
68
|
+
function parseHex(color: string): [number, number, number] | null {
|
|
69
|
+
const match = HEX.exec(color.trim());
|
|
70
|
+
const digits = match?.[1];
|
|
71
|
+
if (digits === undefined) return null;
|
|
72
|
+
const hex =
|
|
73
|
+
digits.length === 3
|
|
74
|
+
? digits
|
|
75
|
+
.split('')
|
|
76
|
+
.map((c) => c + c)
|
|
77
|
+
.join('')
|
|
78
|
+
: digits;
|
|
79
|
+
return [
|
|
80
|
+
parseInt(hex.slice(0, 2), 16),
|
|
81
|
+
parseInt(hex.slice(2, 4), 16),
|
|
82
|
+
parseInt(hex.slice(4, 6), 16),
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function mix([r, g, b]: [number, number, number], target: number, amount: number) {
|
|
87
|
+
const channel = (c: number) => Math.round(c + (target - c) * amount);
|
|
88
|
+
return `rgb(${channel(r)}, ${channel(g)}, ${channel(b)})`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function shadowsFor(name: ThemeName, accent: string): SupportShadows {
|
|
92
|
+
const dark = name === 'dark';
|
|
93
|
+
return {
|
|
94
|
+
launcher: {
|
|
95
|
+
// Tinting the launcher's shadow with the accent is what makes it read
|
|
96
|
+
// as lifted rather than pasted on.
|
|
97
|
+
shadowColor: dark ? '#000000' : accent,
|
|
98
|
+
shadowOpacity: dark ? 0.45 : 0.3,
|
|
99
|
+
shadowRadius: 14,
|
|
100
|
+
shadowOffset: { width: 0, height: 6 },
|
|
101
|
+
elevation: 8,
|
|
102
|
+
},
|
|
103
|
+
sheet: {
|
|
104
|
+
shadowColor: '#000000',
|
|
105
|
+
shadowOpacity: dark ? 0.5 : 0.16,
|
|
106
|
+
shadowRadius: 24,
|
|
107
|
+
shadowOffset: { width: 0, height: -6 },
|
|
108
|
+
elevation: 24,
|
|
109
|
+
},
|
|
110
|
+
badge: {
|
|
111
|
+
shadowColor: '#000000',
|
|
112
|
+
shadowOpacity: 0.25,
|
|
113
|
+
shadowRadius: 3,
|
|
114
|
+
shadowOffset: { width: 0, height: 1 },
|
|
115
|
+
elevation: 3,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function launcherFor(name: ThemeName, accent: string): LauncherPalette {
|
|
121
|
+
const rgb = parseHex(accent);
|
|
122
|
+
return {
|
|
123
|
+
solidRing:
|
|
124
|
+
name === 'dark' ? 'rgba(255, 255, 255, 0.14)' : 'rgba(0, 0, 0, 0.06)',
|
|
125
|
+
// On a dark app background the raw accent is too close to the surface
|
|
126
|
+
// to read as a glyph; lift it toward white.
|
|
127
|
+
minimalMark:
|
|
128
|
+
name === 'dark' && rgb !== null ? mix(rgb, 255, 0.35) : accent,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildTheme(name: ThemeName, colors: ThemeColors): SupportTheme {
|
|
133
|
+
return {
|
|
134
|
+
name,
|
|
135
|
+
colors,
|
|
136
|
+
spacing,
|
|
137
|
+
radii: supportRadii,
|
|
138
|
+
motion,
|
|
139
|
+
shadows: shadowsFor(name, colors.accent),
|
|
140
|
+
launcher: launcherFor(name, colors.accent),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const supportDarkTheme: SupportTheme = buildTheme('dark', darkColors);
|
|
145
|
+
export const supportLightTheme: SupportTheme = buildTheme('light', lightColors);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Derives the accent family from a single brand color: a pressed shade, the
|
|
149
|
+
* 12% wash used behind chips and own-message bubbles, and a foreground that
|
|
150
|
+
* stays legible on top (dark text on light brands, white on dark ones).
|
|
151
|
+
*/
|
|
152
|
+
function withAccent(theme: SupportTheme, accent: string): SupportTheme {
|
|
153
|
+
const rgb = parseHex(accent);
|
|
154
|
+
if (!rgb) return theme;
|
|
155
|
+
const [r, g, b] = rgb;
|
|
156
|
+
// Relative luminance, sRGB coefficients — good enough to pick a foreground.
|
|
157
|
+
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
158
|
+
const colors: ThemeColors = {
|
|
159
|
+
...theme.colors,
|
|
160
|
+
accent,
|
|
161
|
+
accentHover: mix(rgb, theme.name === 'dark' ? 255 : 0, 0.12),
|
|
162
|
+
accentMuted: `rgba(${r}, ${g}, ${b}, 0.12)`,
|
|
163
|
+
accentForeground: luminance > 0.62 ? '#111113' : '#FFFFFF',
|
|
164
|
+
statusOpen: accent,
|
|
165
|
+
statusOpenBg: `rgba(${r}, ${g}, ${b}, 0.10)`,
|
|
166
|
+
focusRing: accent,
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
...theme,
|
|
170
|
+
colors,
|
|
171
|
+
shadows: shadowsFor(theme.name, accent),
|
|
172
|
+
launcher: launcherFor(theme.name, accent),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
36
175
|
|
|
37
|
-
|
|
38
|
-
|
|
176
|
+
/**
|
|
177
|
+
* @param name "light" | "dark" (default dark).
|
|
178
|
+
* @param accentColor Optional brand color (hex) replacing the default accent.
|
|
179
|
+
*/
|
|
180
|
+
export function resolveTheme(
|
|
181
|
+
name: ThemeName | undefined,
|
|
182
|
+
accentColor?: string,
|
|
183
|
+
): SupportTheme {
|
|
184
|
+
const base = name === 'light' ? supportLightTheme : supportDarkTheme;
|
|
185
|
+
return accentColor ? withAccent(base, accentColor) : base;
|
|
39
186
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
2
|
+
|
|
1
3
|
export type QueryType = 'feedback' | 'feature_request' | 'bug_report';
|
|
2
4
|
export type QueryStatus = 'open' | 'resolved';
|
|
3
5
|
export type MessageSender = 'user' | 'admin';
|
|
4
6
|
export type ThemeName = 'dark' | 'light';
|
|
7
|
+
export type PushPermissionStatus = 'undetermined' | 'granted' | 'denied';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How the launcher renders:
|
|
11
|
+
* solid — accent fill, elevated. The floating action button.
|
|
12
|
+
* soft — app surface + hairline border. Sits in a header or toolbar.
|
|
13
|
+
* minimal — bare mark, no fill or border. Sits in a nav bar.
|
|
14
|
+
*/
|
|
15
|
+
export type LauncherVariant = 'solid' | 'soft' | 'minimal';
|
|
5
16
|
|
|
6
17
|
export interface SupportUser {
|
|
7
18
|
/** Display name shown to the support team. */
|
|
@@ -30,12 +41,36 @@ export interface SupportButtonProps {
|
|
|
30
41
|
/** Visual theme. Matches the admin dashboard tokens. Default "dark". */
|
|
31
42
|
theme?: ThemeName;
|
|
32
43
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
|
|
44
|
+
* Brand color (hex, e.g. "#FF5A1F") replacing the default accent across
|
|
45
|
+
* the launcher, buttons, and the user's own message bubbles. The pressed
|
|
46
|
+
* shade, the 12% wash, and a legible foreground are derived from it.
|
|
47
|
+
*/
|
|
48
|
+
accentColor?: string;
|
|
49
|
+
/** Launcher style. Default "solid" — the floating action button. */
|
|
50
|
+
variant?: LauncherVariant;
|
|
51
|
+
/** Launcher diameter in px (height when `label` is set). Default 52. */
|
|
52
|
+
size?: number;
|
|
53
|
+
/** Renders the launcher as a pill with this text beside the mark. */
|
|
54
|
+
label?: string;
|
|
55
|
+
/** Style applied to the launcher — position it, add margins, etc. */
|
|
56
|
+
style?: StyleProp<ViewStyle>;
|
|
57
|
+
/**
|
|
58
|
+
* Idle poll interval (ms) — how often the unread badge is refreshed while
|
|
59
|
+
* the sheet is CLOSED, using a lightweight count-only endpoint. Default
|
|
60
|
+
* 60000; set 0 to disable idle polling (mount / sheet-open / foreground
|
|
61
|
+
* refreshes still happen).
|
|
62
|
+
*
|
|
63
|
+
* While the sheet is open the SDK polls faster on its own: every 15s on
|
|
64
|
+
* the thread list and every 5s inside a thread, where latency is felt.
|
|
37
65
|
*/
|
|
38
66
|
pollIntervalMs?: number;
|
|
67
|
+
/**
|
|
68
|
+
* Optional EAS project id, forwarded to expo-notifications when minting a
|
|
69
|
+
* push token. Only needed if the host app's config doesn't already
|
|
70
|
+
* provide one. Push is skipped entirely when expo-notifications isn't
|
|
71
|
+
* installed — it is an optional peer dependency.
|
|
72
|
+
*/
|
|
73
|
+
expoProjectId?: string;
|
|
39
74
|
}
|
|
40
75
|
|
|
41
76
|
export interface ThreadMessage {
|
package/src/unread.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Unread accounting.
|
|
2
|
+
//
|
|
3
|
+
// The server counts a thread's unread as "admin messages newer than the
|
|
4
|
+
// user's read receipt". That number is authoritative, but it is always a
|
|
5
|
+
// little behind the device: between the moment the user opens a thread and
|
|
6
|
+
// the moment `mark-read` lands, every already-in-flight poll still reports
|
|
7
|
+
// the old count — which is what made the badge flicker back on after being
|
|
8
|
+
// cleared, and what made a thread the user was actively reading accumulate
|
|
9
|
+
// unread replies.
|
|
10
|
+
//
|
|
11
|
+
// So the client keeps its own watermark per thread: the `created_at` of the
|
|
12
|
+
// newest admin message the user has actually seen. Those are *server*
|
|
13
|
+
// timestamps, so nothing here compares against the device clock.
|
|
14
|
+
//
|
|
15
|
+
// Displayed unread is then the stricter of the two counts, which is correct
|
|
16
|
+
// in both directions:
|
|
17
|
+
//
|
|
18
|
+
// read here, receipt not written yet → local watermark wins (0)
|
|
19
|
+
// read on another device → server count wins (0)
|
|
20
|
+
|
|
21
|
+
import type { Thread } from './types';
|
|
22
|
+
|
|
23
|
+
/** Watermark map: thread id → `created_at` (ms) of the newest seen reply. */
|
|
24
|
+
export type ReadWatermarks = Map<string, number>;
|
|
25
|
+
|
|
26
|
+
/** `created_at` (ms) of the newest admin message, or 0 if there are none. */
|
|
27
|
+
export function latestAdminMessageAt(thread: Thread): number {
|
|
28
|
+
let latest = 0;
|
|
29
|
+
for (const message of thread.messages) {
|
|
30
|
+
if (message.sender !== 'admin') continue;
|
|
31
|
+
const at = Date.parse(message.created_at);
|
|
32
|
+
if (Number.isFinite(at) && at > latest) latest = at;
|
|
33
|
+
}
|
|
34
|
+
return latest;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Admin messages in `thread` newer than `watermark`. */
|
|
38
|
+
export function unreadSince(thread: Thread, watermark: number): number {
|
|
39
|
+
let count = 0;
|
|
40
|
+
for (const message of thread.messages) {
|
|
41
|
+
if (message.sender !== 'admin') continue;
|
|
42
|
+
const at = Date.parse(message.created_at);
|
|
43
|
+
if (Number.isFinite(at) && at > watermark) count += 1;
|
|
44
|
+
}
|
|
45
|
+
return count;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns `thread` with `unread_count` narrowed by what this device knows
|
|
50
|
+
* the user has read. Identity is preserved when nothing changes so lists
|
|
51
|
+
* stay referentially stable between polls.
|
|
52
|
+
*/
|
|
53
|
+
export function reconcileThread(
|
|
54
|
+
thread: Thread,
|
|
55
|
+
watermarks: ReadWatermarks,
|
|
56
|
+
): Thread {
|
|
57
|
+
const watermark = watermarks.get(thread.id);
|
|
58
|
+
if (watermark === undefined || thread.unread_count === 0) return thread;
|
|
59
|
+
const unread = Math.min(thread.unread_count, unreadSince(thread, watermark));
|
|
60
|
+
return unread === thread.unread_count ? thread : { ...thread, unread_count: unread };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function reconcileThreads(
|
|
64
|
+
threads: Thread[],
|
|
65
|
+
watermarks: ReadWatermarks,
|
|
66
|
+
): Thread[] {
|
|
67
|
+
return threads.map((thread) => reconcileThread(thread, watermarks));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function sumUnread(threads: Thread[]): number {
|
|
71
|
+
return threads.reduce((total, thread) => total + thread.unread_count, 0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Drops watermarks the server has caught up on (its own count is already 0)
|
|
76
|
+
* and ones for threads that no longer exist, so the map can't grow without
|
|
77
|
+
* bound over a long session.
|
|
78
|
+
*/
|
|
79
|
+
export function pruneWatermarks(
|
|
80
|
+
watermarks: ReadWatermarks,
|
|
81
|
+
serverThreads: Thread[],
|
|
82
|
+
): ReadWatermarks {
|
|
83
|
+
const keep: ReadWatermarks = new Map();
|
|
84
|
+
for (const thread of serverThreads) {
|
|
85
|
+
const watermark = watermarks.get(thread.id);
|
|
86
|
+
if (watermark !== undefined && thread.unread_count > 0) {
|
|
87
|
+
keep.set(thread.id, watermark);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return keep;
|
|
91
|
+
}
|