@marktiderman/genesis-chat 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/README.md +304 -13
- package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
- package/dist/chunk-5D5A7I4Q.js.map +1 -0
- package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
- package/dist/chunk-7S6HYNWW.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-PZTOY2LY.js +576 -0
- package/dist/chunk-PZTOY2LY.js.map +1 -0
- package/dist/chunk-VEPZROQB.js +187 -0
- package/dist/chunk-VEPZROQB.js.map +1 -0
- package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1910 -1
- package/dist/native.js +4900 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B0y3phE0.d.ts +151 -0
- package/dist/push.d.ts +206 -0
- package/dist/push.js +3 -0
- package/dist/push.js.map +1 -0
- package/dist/react.d.ts +149 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +510 -21
- package/dist/server.js +863 -21
- package/dist/server.js.map +1 -1
- package/dist/thread-DWXHKPdw.d.ts +57 -0
- package/dist/web.d.ts +313 -10
- package/dist/web.js +1731 -60
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
- package/package.json +74 -6
- package/src/__tests__/coaching-core.test.ts +332 -0
- package/src/__tests__/coaching-react.test.tsx +248 -0
- package/src/__tests__/coaching-web.test.tsx +449 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +402 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fakes.ts +90 -0
- package/src/__tests__/server-integrations.test.ts +247 -0
- package/src/__tests__/server-providers.test.ts +246 -0
- package/src/__tests__/server-runner.test.ts +277 -0
- package/src/__tests__/server-safety.test.ts +290 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +333 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +757 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +429 -0
- package/src/core/thread.ts +171 -0
- package/src/core/types.ts +311 -0
- package/src/index.ts +13 -0
- package/src/native/__tests__/auto-retry.test.tsx +128 -0
- package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
- package/src/native/__tests__/composer-state.test.ts +188 -0
- package/src/native/__tests__/composer.test.tsx +133 -0
- package/src/native/__tests__/emoji.test.tsx +238 -0
- package/src/native/__tests__/inbox-screen.test.tsx +48 -0
- package/src/native/__tests__/message-bubble.test.tsx +127 -0
- package/src/native/__tests__/organise-logic.test.ts +164 -0
- package/src/native/__tests__/organise-screens.test.tsx +344 -0
- package/src/native/__tests__/photo-viewer.test.tsx +226 -0
- package/src/native/__tests__/push-payload.test.ts +66 -0
- package/src/native/__tests__/push.test.ts +209 -0
- package/src/native/__tests__/recorder-machine.test.ts +96 -0
- package/src/native/__tests__/rows-actions.test.ts +76 -0
- package/src/native/__tests__/thread-layout.test.ts +41 -0
- package/src/native/__tests__/thread-screen.test.tsx +116 -0
- package/src/native/__tests__/tus.test.ts +154 -0
- package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
- package/src/native/__tests__/video-media.test.tsx +421 -0
- package/src/native/__tests__/video-recorder-state.test.ts +87 -0
- package/src/native/__tests__/video-upload.test.ts +227 -0
- package/src/native/access-error.tsx +31 -0
- package/src/native/actions.ts +42 -0
- package/src/native/attachment-cards.tsx +116 -0
- package/src/native/auto-retry.ts +69 -0
- package/src/native/availability.tsx +376 -0
- package/src/native/broadcast-sheet.tsx +193 -0
- package/src/native/composer-state.ts +302 -0
- package/src/native/composer.tsx +453 -0
- package/src/native/context.tsx +70 -0
- package/src/native/copy.ts +296 -0
- package/src/native/emoji-data.ts +21 -0
- package/src/native/emoji-picker.tsx +229 -0
- package/src/native/emoji.ts +118 -0
- package/src/native/icon-button.tsx +40 -0
- package/src/native/inbox-screen.tsx +116 -0
- package/src/native/index.ts +184 -0
- package/src/native/link-preview.tsx +104 -0
- package/src/native/media-colors.ts +15 -0
- package/src/native/media.tsx +187 -0
- package/src/native/message-actions-sheet.tsx +121 -0
- package/src/native/message-bubble.tsx +374 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +275 -0
- package/src/native/organise-copy.ts +195 -0
- package/src/native/photo-viewer.tsx +531 -0
- package/src/native/push.ts +375 -0
- package/src/native/recorder-machine.ts +176 -0
- package/src/native/rows.ts +81 -0
- package/src/native/schedule-sheet.tsx +314 -0
- package/src/native/search-screen.tsx +168 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +620 -0
- package/src/native/topics.tsx +179 -0
- package/src/native/tus.ts +282 -0
- package/src/native/use-composer.ts +71 -0
- package/src/native/use-jump.ts +91 -0
- package/src/native/use-voice-recorder.ts +234 -0
- package/src/native/video-player.tsx +370 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +271 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +283 -0
- package/src/react/index.tsx +810 -0
- package/src/server/assistant.ts +235 -0
- package/src/server/crypto.ts +86 -0
- package/src/server/expo.ts +119 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +57 -0
- package/src/server/moderation.ts +155 -0
- package/src/server/mux.ts +290 -0
- package/src/server/notifications.ts +130 -0
- package/src/server/runner.ts +468 -0
- package/src/server/transcribers.ts +111 -0
- package/src/server/types.ts +171 -0
- package/src/server/unfurl.ts +480 -0
- package/src/server/upload.ts +158 -0
- package/src/web/attachments.tsx +379 -0
- package/src/web/availability.tsx +299 -0
- package/src/web/broadcast.tsx +166 -0
- package/src/web/bubble.tsx +563 -0
- package/src/web/cards.tsx +136 -0
- package/src/web/composer.tsx +571 -0
- package/src/web/emoji-picker.tsx +84 -0
- package/src/web/error-boundary.tsx +33 -0
- package/src/web/format.ts +55 -0
- package/src/web/inbox.tsx +195 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +706 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +192 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +802 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +522 -0
- package/src/web/voice-note.tsx +197 -0
- package/dist/chunk-27UTR5FW.js +0 -322
- package/dist/chunk-27UTR5FW.js.map +0 -1
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* Push glue for genesis-native's push block, data only: the `messages`
|
|
4
|
+
* category (a switch in Notification settings and an Android channel), its
|
|
5
|
+
* notification actions (Reply with a text field, Mark read), what a tap opens
|
|
6
|
+
* (`data.url` = `/messages/<conversationId>`), the reply and mark-read
|
|
7
|
+
* handlers for the block's `onResponse`, and the match that keeps a thread's
|
|
8
|
+
* own pushes out of the foreground while it is open (the block's `suppress`).
|
|
9
|
+
*
|
|
10
|
+
* const push = createPush({ …, categories: [...yours, chatPushCategory()],
|
|
11
|
+
* onResponse: (r) => void handleChatPushResponse(r, { client: chatClient, notifications: Notifications }) });
|
|
12
|
+
* void registerChatNotificationActions(Notifications); // once, at start
|
|
13
|
+
* useSuppressNotifications(chatThreadMatch(conversationId)); // in the thread screen
|
|
14
|
+
*
|
|
15
|
+
* The sender sets `categoryId: "messages"` (iOS) / the `messages` channel
|
|
16
|
+
* (Android) and `data: { url: "/messages/<id>", conversationId, seq, messageId }`;
|
|
17
|
+
* `chatPushPayload` builds those and the fields iOS rich push reads (the
|
|
18
|
+
* sender's name and photo, the group, a photo), and it is server safe: import
|
|
19
|
+
* it from `@marktiderman/genesis-chat/push`.
|
|
20
|
+
*
|
|
21
|
+
* A reply goes through the outbox, so a phone that is offline keeps it: pass
|
|
22
|
+
* the live one (`useChat().outbox`) when the chat screens are mounted, and the
|
|
23
|
+
* persisted store and user id (`ChatProvider outboxStore`) for when they
|
|
24
|
+
* aren't. A reply that can't be sent then waits there, failed, with Retry in
|
|
25
|
+
* its thread.
|
|
26
|
+
*/
|
|
27
|
+
import type { ChatClient } from "../core/client";
|
|
28
|
+
import type { Outbox, OutboxStore, QueuedMessage } from "../core/outbox";
|
|
29
|
+
|
|
30
|
+
export const CHAT_PUSH_CATEGORY = "messages";
|
|
31
|
+
export const CHAT_REPLY_ACTION = "reply";
|
|
32
|
+
export const CHAT_MARK_READ_ACTION = "mark-read";
|
|
33
|
+
|
|
34
|
+
export interface ChatPushCopy {
|
|
35
|
+
title: string;
|
|
36
|
+
description: string;
|
|
37
|
+
reply: string;
|
|
38
|
+
replySubmit: string;
|
|
39
|
+
replyPlaceholder: string;
|
|
40
|
+
markRead: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const chatPushCopy: ChatPushCopy = {
|
|
44
|
+
title: "Messages",
|
|
45
|
+
description: "New messages in your conversations.",
|
|
46
|
+
reply: "Reply",
|
|
47
|
+
replySubmit: "Send",
|
|
48
|
+
replyPlaceholder: "Message",
|
|
49
|
+
markRead: "Mark read",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The path a message's push opens. */
|
|
53
|
+
export function chatPath(conversationId: string): string {
|
|
54
|
+
return `/messages/${conversationId}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The category for createPush's `categories` (genesis-native's NotificationCategory). */
|
|
58
|
+
export function chatPushCategory(copy: Partial<ChatPushCopy> = {}) {
|
|
59
|
+
const c = { ...chatPushCopy, ...copy };
|
|
60
|
+
return { id: CHAT_PUSH_CATEGORY, title: c.title, description: c.description, importance: "high" as const };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** expo-notifications' `setNotificationCategoryAsync`, the one call this needs. */
|
|
64
|
+
export interface NotificationCategoriesLike {
|
|
65
|
+
setNotificationCategoryAsync(
|
|
66
|
+
identifier: string,
|
|
67
|
+
actions: {
|
|
68
|
+
identifier: string;
|
|
69
|
+
buttonTitle: string;
|
|
70
|
+
textInput?: { submitButtonTitle: string; placeholder: string };
|
|
71
|
+
options?: { opensAppToForeground?: boolean; isDestructive?: boolean; isAuthenticationRequired?: boolean };
|
|
72
|
+
}[],
|
|
73
|
+
options?: Record<string, unknown>,
|
|
74
|
+
): Promise<unknown>;
|
|
75
|
+
dismissNotificationAsync?(identifier: string): Promise<void>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The actions on a message's notification: Reply (a text field) and Mark read, neither opening the app. */
|
|
79
|
+
export function chatNotificationActions(copy: Partial<ChatPushCopy> = {}) {
|
|
80
|
+
const c = { ...chatPushCopy, ...copy };
|
|
81
|
+
return [
|
|
82
|
+
{
|
|
83
|
+
identifier: CHAT_REPLY_ACTION,
|
|
84
|
+
buttonTitle: c.reply,
|
|
85
|
+
textInput: { submitButtonTitle: c.replySubmit, placeholder: c.replyPlaceholder },
|
|
86
|
+
options: { opensAppToForeground: false },
|
|
87
|
+
},
|
|
88
|
+
{ identifier: CHAT_MARK_READ_ACTION, buttonTitle: c.markRead, options: { opensAppToForeground: false } },
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Registers the actions with the OS (once, at start; it is idempotent). */
|
|
93
|
+
export function registerChatNotificationActions(
|
|
94
|
+
notifications: NotificationCategoriesLike,
|
|
95
|
+
copy?: Partial<ChatPushCopy>,
|
|
96
|
+
onError?: (error: unknown) => void,
|
|
97
|
+
): Promise<unknown> {
|
|
98
|
+
// Never throws, at module load least of all: a module without categories (web, a test's mock) is reported.
|
|
99
|
+
try {
|
|
100
|
+
if (typeof notifications?.setNotificationCategoryAsync !== "function") {
|
|
101
|
+
onError?.(new Error("chat push: setNotificationCategoryAsync is missing, so Reply and Mark read aren't on message notifications"));
|
|
102
|
+
return Promise.resolve(undefined);
|
|
103
|
+
}
|
|
104
|
+
return Promise.resolve(notifications.setNotificationCategoryAsync(CHAT_PUSH_CATEGORY, chatNotificationActions(copy))).catch((error: unknown) => {
|
|
105
|
+
onError?.(error);
|
|
106
|
+
});
|
|
107
|
+
} catch (error) {
|
|
108
|
+
onError?.(error);
|
|
109
|
+
return Promise.resolve(undefined);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
114
|
+
|
|
115
|
+
/** The conversation a push is about: `data.conversationId`, else the id in `data.url`. */
|
|
116
|
+
export function conversationIdFromData(data: Record<string, unknown> | null | undefined): string | null {
|
|
117
|
+
if (!data) return null;
|
|
118
|
+
const direct = typeof data.conversationId === "string" ? data.conversationId : typeof data.conversation_id === "string" ? data.conversation_id : null;
|
|
119
|
+
if (direct && UUID.test(direct)) return direct.toLowerCase();
|
|
120
|
+
const url = typeof data.url === "string" ? data.url : null;
|
|
121
|
+
const m = url ? /^\/messages\/([0-9a-f-]{36})(?:[/?#]|$)/i.exec(url) : null;
|
|
122
|
+
return m && UUID.test(m[1]!) ? m[1]!.toLowerCase() : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The client id a reply from this notification sends with: the notification's
|
|
127
|
+
* own id, as a uuid (it already is one on iOS; anything else is hashed into
|
|
128
|
+
* one), so the same reply delivered twice posts once.
|
|
129
|
+
*/
|
|
130
|
+
export function replyClientId(notificationId: string): string {
|
|
131
|
+
if (UUID.test(notificationId)) return notificationId.toLowerCase();
|
|
132
|
+
// Four 32-bit FNV-1a hashes with different seeds: 128 stable bits.
|
|
133
|
+
const words = [0x811c9dc5, 0x01000193, 0x050c5d1f, 0x6f4f2a3b].map((seed) => {
|
|
134
|
+
let h = seed >>> 0;
|
|
135
|
+
for (let i = 0; i < notificationId.length; i++) {
|
|
136
|
+
h ^= notificationId.charCodeAt(i);
|
|
137
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
138
|
+
}
|
|
139
|
+
return h.toString(16).padStart(8, "0");
|
|
140
|
+
});
|
|
141
|
+
const hex = words.join("");
|
|
142
|
+
// Version 8 (custom) and the RFC 4122 variant, so it is a valid uuid.
|
|
143
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-8${hex.slice(13, 16)}-${((parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16)}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The client id for one reply: from the notification's id, the message it is
|
|
148
|
+
* about (`data.messageId`) and the reply's text. The same reply delivered
|
|
149
|
+
* twice posts once; two replies to one conversation never share an id, even
|
|
150
|
+
* when iOS reuses the notification's id for a collapsed push.
|
|
151
|
+
*/
|
|
152
|
+
export function replyClientIdFor(notificationId: string, data: Record<string, unknown> | null | undefined, body: string): string {
|
|
153
|
+
const messageId = typeof data?.messageId === "string" ? data.messageId : typeof data?.message_id === "string" ? data.message_id : "";
|
|
154
|
+
return replyClientId(`${notificationId}|m:${messageId.toLowerCase()}|t:${body}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The part of a notification response this reads (expo-notifications' `NotificationResponse`). */
|
|
158
|
+
export interface ChatPushResponseLike {
|
|
159
|
+
actionIdentifier: string;
|
|
160
|
+
/** The text typed into Reply. */
|
|
161
|
+
userText?: string;
|
|
162
|
+
notification: { request: { identifier: string; content: { data?: Record<string, unknown> | null } } };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** "queued": the reply couldn't go now and waits in the outbox, failed, for Retry. */
|
|
166
|
+
export type ChatPushOutcome = "replied" | "queued" | "marked-read" | "ignored" | "failed";
|
|
167
|
+
|
|
168
|
+
type LiveOutbox = Pick<Outbox, "send" | "pending"> & Partial<Pick<Outbox, "load">>;
|
|
169
|
+
|
|
170
|
+
/** How long a reply queued in the live outbox is waited for before it counts as queued, not sent. */
|
|
171
|
+
export const PUSH_REPLY_WAIT_MS = 15_000;
|
|
172
|
+
|
|
173
|
+
/** Where a reply waits when it can't go now. */
|
|
174
|
+
export interface ChatPushOutboxDeps {
|
|
175
|
+
/** The live outbox (`useChat().outbox`), or a getter for it (read again after a slow failure): the reply is queued there and sent in order. */
|
|
176
|
+
outbox?: LiveOutbox | null | (() => LiveOutbox | null | undefined);
|
|
177
|
+
/** Without a live outbox: the persisted store (`ChatProvider outboxStore`) a reply that failed is added to. */
|
|
178
|
+
store?: OutboxStore | null;
|
|
179
|
+
/** Whose store it is (the signed-in user's id). */
|
|
180
|
+
userId?: string | null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A reply that couldn't be sent, as the outbox keeps it: shown failed in its thread, with Retry. */
|
|
184
|
+
export function failedReply(input: { conversationId: string; clientId: string; body: string }, userId: string, error = "Not sent. Check your connection."): QueuedMessage {
|
|
185
|
+
return {
|
|
186
|
+
clientId: input.clientId,
|
|
187
|
+
input: { conversationId: input.conversationId, clientId: input.clientId, body: input.body },
|
|
188
|
+
senderId: userId,
|
|
189
|
+
createdAt: new Date().toISOString(),
|
|
190
|
+
status: "failed",
|
|
191
|
+
error,
|
|
192
|
+
previews: {},
|
|
193
|
+
files: [],
|
|
194
|
+
pendingFiles: [],
|
|
195
|
+
attempts: 1,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Handles Reply and Mark read from a message's notification (pass it as the
|
|
201
|
+
* push block's `onResponse`). Taps on the notification itself are the block's
|
|
202
|
+
* routing, not this. Never throws: a failure is the outcome "failed" (and
|
|
203
|
+
* `onError`).
|
|
204
|
+
*/
|
|
205
|
+
export async function handleChatPushResponse(
|
|
206
|
+
response: ChatPushResponseLike,
|
|
207
|
+
deps: {
|
|
208
|
+
client: Pick<ChatClient, "send" | "markRead">;
|
|
209
|
+
notifications?: Pick<NotificationCategoriesLike, "dismissNotificationAsync">;
|
|
210
|
+
onError?: (error: unknown) => void;
|
|
211
|
+
/** How long to wait for a reply queued in the live outbox to go. Default PUSH_REPLY_WAIT_MS. */
|
|
212
|
+
waitMs?: number;
|
|
213
|
+
} & ChatPushOutboxDeps,
|
|
214
|
+
): Promise<ChatPushOutcome> {
|
|
215
|
+
const action = response.actionIdentifier;
|
|
216
|
+
if (action !== CHAT_REPLY_ACTION && action !== CHAT_MARK_READ_ACTION) return "ignored";
|
|
217
|
+
const data = response.notification.request.content.data ?? {};
|
|
218
|
+
const conversationId = conversationIdFromData(data);
|
|
219
|
+
if (!conversationId) return "ignored";
|
|
220
|
+
const notificationId = response.notification.request.identifier;
|
|
221
|
+
try {
|
|
222
|
+
let outcome: ChatPushOutcome;
|
|
223
|
+
if (action === CHAT_REPLY_ACTION) {
|
|
224
|
+
const body = (response.userText ?? "").trim();
|
|
225
|
+
if (!body) return "ignored";
|
|
226
|
+
const input = { conversationId, clientId: replyClientIdFor(notificationId, data, body), body };
|
|
227
|
+
outcome = await sendReply(input, deps);
|
|
228
|
+
// A reply reads the conversation too, as answering in the app would.
|
|
229
|
+
if (outcome === "replied") await deps.client.markRead(conversationId).catch(() => undefined);
|
|
230
|
+
} else {
|
|
231
|
+
const seq = typeof data.seq === "number" ? data.seq : typeof data.seq === "string" ? Number(data.seq) : undefined;
|
|
232
|
+
await deps.client.markRead(conversationId, Number.isFinite(seq) ? seq : undefined);
|
|
233
|
+
outcome = "marked-read";
|
|
234
|
+
}
|
|
235
|
+
await deps.notifications?.dismissNotificationAsync?.(notificationId).catch(() => undefined);
|
|
236
|
+
return outcome;
|
|
237
|
+
} catch (error) {
|
|
238
|
+
deps.onError?.(error);
|
|
239
|
+
return "failed";
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function sendReply(
|
|
244
|
+
input: { conversationId: string; clientId: string; body: string },
|
|
245
|
+
deps: { client: Pick<ChatClient, "send">; onError?: (error: unknown) => void; waitMs?: number } & ChatPushOutboxDeps,
|
|
246
|
+
): Promise<ChatPushOutcome> {
|
|
247
|
+
const liveOutbox = () => (typeof deps.outbox === "function" ? deps.outbox() : deps.outbox) ?? null;
|
|
248
|
+
const live = liveOutbox();
|
|
249
|
+
if (live) return queueLive(live, input, deps.waitMs);
|
|
250
|
+
try {
|
|
251
|
+
await deps.client.send(input);
|
|
252
|
+
return "replied";
|
|
253
|
+
} catch (error) {
|
|
254
|
+
// The chat screens may have opened while the send failed: their outbox owns the saved copy now.
|
|
255
|
+
const opened = liveOutbox();
|
|
256
|
+
if (opened) return queueLive(opened, input, 0);
|
|
257
|
+
if (!deps.store || !deps.userId) throw error;
|
|
258
|
+
deps.onError?.(error);
|
|
259
|
+
const queue = await deps.store.load();
|
|
260
|
+
if (!queue.some((q) => q.clientId === input.clientId)) await deps.store.save([...queue, failedReply(input, deps.userId)]);
|
|
261
|
+
return "queued";
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Queues the reply in the live outbox (once) and waits, briefly, for it to go: "replied" once it
|
|
267
|
+
* has left the queue, "queued" while it is still there or failed (it waits there for Retry).
|
|
268
|
+
*/
|
|
269
|
+
async function queueLive(live: LiveOutbox, input: { conversationId: string; clientId: string; body: string }, waitMs = PUSH_REPLY_WAIT_MS): Promise<ChatPushOutcome> {
|
|
270
|
+
await live.load?.();
|
|
271
|
+
const mine = () => live.pending(input.conversationId).find((p) => p.clientId === input.clientId);
|
|
272
|
+
if (!mine()) await live.send(input);
|
|
273
|
+
const until = Date.now() + waitMs;
|
|
274
|
+
for (;;) {
|
|
275
|
+
const p = mine();
|
|
276
|
+
if (!p) return "replied";
|
|
277
|
+
if (p.status === "failed" || Date.now() >= until) return "queued";
|
|
278
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** For the push block's `suppress` / `useSuppressNotifications`: this conversation's pushes, while its thread is open. */
|
|
283
|
+
export function chatThreadMatch(conversationId: string | null | undefined): ((data: Record<string, unknown>) => boolean) | null {
|
|
284
|
+
if (!conversationId) return null;
|
|
285
|
+
const id = conversationId.toLowerCase();
|
|
286
|
+
return (data) => conversationIdFromData(data) === id;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** What a message's push says about itself, for `chatPushPayload`. */
|
|
290
|
+
export interface ChatPushPayloadInput {
|
|
291
|
+
conversationId: string;
|
|
292
|
+
messageId: string;
|
|
293
|
+
seq?: number | null | undefined;
|
|
294
|
+
/** Who sent it: an opaque id (never an email or phone), the name to show and an https photo. */
|
|
295
|
+
sender?: { id: string; name: string; avatarUrl?: string | null | undefined } | null | undefined;
|
|
296
|
+
/** The conversation's name when it is a group; leave it out for a direct conversation. */
|
|
297
|
+
groupName?: string | null | undefined;
|
|
298
|
+
/** An https photo in the message, attached to the notification. */
|
|
299
|
+
imageUrl?: string | null | undefined;
|
|
300
|
+
/**
|
|
301
|
+
* The recipient hides previews: the push carries the link and ids only (no
|
|
302
|
+
* sender, group or photo) and the service extension doesn't run. Send neutral
|
|
303
|
+
* title and body text with it.
|
|
304
|
+
*/
|
|
305
|
+
hidePreviews?: boolean | undefined;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The `data` of a message's push. Flat strings and numbers, as genesis-notifications' `PushMessage.data` takes. */
|
|
309
|
+
export interface ChatPushData {
|
|
310
|
+
[key: string]: string | number;
|
|
311
|
+
url: string;
|
|
312
|
+
conversationId: string;
|
|
313
|
+
messageId: string;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The fields to spread into an Expo push message (with `to`, `title`, `body`, `badge`, `channelId`). */
|
|
317
|
+
export interface ChatPushPayload {
|
|
318
|
+
categoryId: typeof CHAT_PUSH_CATEGORY;
|
|
319
|
+
/** Groups the conversation's notifications, and is the conversation iOS files a Communication Notification under. */
|
|
320
|
+
threadId: string;
|
|
321
|
+
/** iOS runs genesis-native's Notification Service Extension only when this is true (`mutable-content: 1`). */
|
|
322
|
+
mutableContent: boolean;
|
|
323
|
+
data: ChatPushData;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The extension fetches only https URLs (no credentials, at most 1024 characters): genesis-native's `isRichPushUrl`. */
|
|
327
|
+
function httpsUrl(value: unknown): string | null {
|
|
328
|
+
if (typeof value !== "string" || value.length > 1024) return null;
|
|
329
|
+
try {
|
|
330
|
+
const url = new URL(value);
|
|
331
|
+
return url.protocol === "https:" && url.hostname.length > 0 && !url.username && !url.password ? value : null;
|
|
332
|
+
} catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function shortText(value: unknown): string | null {
|
|
338
|
+
if (typeof value !== "string") return null;
|
|
339
|
+
const t = value.trim();
|
|
340
|
+
return t ? t.slice(0, 200) : null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The category, thread, `mutableContent` and `data` of a message's push, for
|
|
345
|
+
* the server that sends it:
|
|
346
|
+
*
|
|
347
|
+
* const message = { to, title, body, badge, channelId: "messages", ...chatPushPayload({ conversationId, messageId, seq, sender, groupName, imageUrl, hidePreviews }) };
|
|
348
|
+
*
|
|
349
|
+
* On iOS, with the rich push plugin, a push with a sender is a Communication
|
|
350
|
+
* Notification (the sender's photo; Focus treats it as a message) and `imageUrl`
|
|
351
|
+
* is attached. Fields that aren't usable (a URL that isn't https, an empty
|
|
352
|
+
* name) are left out rather than sent.
|
|
353
|
+
*/
|
|
354
|
+
export function chatPushPayload(input: ChatPushPayloadInput): ChatPushPayload {
|
|
355
|
+
const conversationId = input.conversationId.toLowerCase();
|
|
356
|
+
if (!UUID.test(conversationId)) throw new Error("chatPushPayload: conversationId must be a uuid");
|
|
357
|
+
const data: ChatPushData = { url: chatPath(conversationId), conversationId, messageId: input.messageId };
|
|
358
|
+
if (typeof input.seq === "number" && Number.isFinite(input.seq)) data.seq = input.seq;
|
|
359
|
+
if (input.hidePreviews) {
|
|
360
|
+
return { categoryId: CHAT_PUSH_CATEGORY, threadId: conversationId, mutableContent: false, data };
|
|
361
|
+
}
|
|
362
|
+
const senderName = shortText(input.sender?.name);
|
|
363
|
+
const senderId = shortText(input.sender?.id);
|
|
364
|
+
if (senderName && senderId) {
|
|
365
|
+
data.senderId = senderId;
|
|
366
|
+
data.senderName = senderName;
|
|
367
|
+
const avatar = httpsUrl(input.sender?.avatarUrl);
|
|
368
|
+
if (avatar) data.senderAvatar = avatar;
|
|
369
|
+
}
|
|
370
|
+
const groupName = shortText(input.groupName);
|
|
371
|
+
if (groupName) data.groupName = groupName;
|
|
372
|
+
const image = httpsUrl(input.imageUrl);
|
|
373
|
+
if (image) data.image = image;
|
|
374
|
+
return { categoryId: CHAT_PUSH_CATEGORY, threadId: conversationId, mutableContent: true, data };
|
|
375
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* The voice recorder as a state machine, apart from expo-audio so it can be
|
|
4
|
+
* tested: hold the mic to record, slide left to cancel, slide up to lock and
|
|
5
|
+
* record hands-free, release (or tap send when locked) to send. A recording
|
|
6
|
+
* shorter than `minMs` is dropped, and one that reaches the role's limit is
|
|
7
|
+
* sent. Each step returns the effects the hook carries out.
|
|
8
|
+
*/
|
|
9
|
+
import type { HapticEvent } from "./modules";
|
|
10
|
+
|
|
11
|
+
export type RecorderPhase = "idle" | "starting" | "recording" | "finishing";
|
|
12
|
+
|
|
13
|
+
export type RecorderNotice = "denied" | "too-short" | "error" | "interrupted";
|
|
14
|
+
|
|
15
|
+
export interface RecorderState {
|
|
16
|
+
phase: RecorderPhase;
|
|
17
|
+
/** Hands-free: released without stopping. */
|
|
18
|
+
locked: boolean;
|
|
19
|
+
/** The finger has slid far enough left that letting go cancels. */
|
|
20
|
+
cancelArmed: boolean;
|
|
21
|
+
/** Let go while the microphone was still starting. */
|
|
22
|
+
releasedEarly: boolean;
|
|
23
|
+
durationMs: number;
|
|
24
|
+
/** Level readings (dB) since the start, for the live waveform and the sent one. */
|
|
25
|
+
levels: number[];
|
|
26
|
+
/**
|
|
27
|
+
* Why the last attempt ended without a voice note: "denied" (no microphone), "too-short",
|
|
28
|
+
* "error" (the recording couldn't be saved), "interrupted" (a call, Siri, another app or the
|
|
29
|
+
* app going to the background took the microphone; what was recorded is thrown away).
|
|
30
|
+
*/
|
|
31
|
+
notice: RecorderNotice | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const idleRecorder: RecorderState = {
|
|
35
|
+
phase: "idle",
|
|
36
|
+
locked: false,
|
|
37
|
+
cancelArmed: false,
|
|
38
|
+
releasedEarly: false,
|
|
39
|
+
durationMs: 0,
|
|
40
|
+
levels: [],
|
|
41
|
+
notice: null,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type RecorderEvent =
|
|
45
|
+
| { type: "press" }
|
|
46
|
+
| { type: "ready" }
|
|
47
|
+
| { type: "failed"; reason: "denied" | "error" }
|
|
48
|
+
| { type: "move"; dx: number; dy: number }
|
|
49
|
+
| { type: "release" }
|
|
50
|
+
| { type: "send" }
|
|
51
|
+
| { type: "cancel" }
|
|
52
|
+
| { type: "tick"; durationMs: number; meteringDb?: number }
|
|
53
|
+
/** Something else took the microphone (a call, Siri, the app went to the background). */
|
|
54
|
+
| { type: "interrupted" }
|
|
55
|
+
/** The stop is done: `ok` false when no voice note came of it (the stop threw, no file). */
|
|
56
|
+
| { type: "finished"; ok?: boolean };
|
|
57
|
+
|
|
58
|
+
export type RecorderEffect =
|
|
59
|
+
| { type: "prepare" }
|
|
60
|
+
| { type: "record" }
|
|
61
|
+
/** Stop and send what was recorded. */
|
|
62
|
+
| { type: "finish" }
|
|
63
|
+
/** Stop and throw it away. */
|
|
64
|
+
| { type: "discard" }
|
|
65
|
+
| { type: "haptic"; event: HapticEvent }
|
|
66
|
+
/** Tell the others you are recording. */
|
|
67
|
+
| { type: "signal" };
|
|
68
|
+
|
|
69
|
+
export interface RecorderConfig {
|
|
70
|
+
/** The longest recording (recordingLimitMs(role)). */
|
|
71
|
+
limitMs: number;
|
|
72
|
+
/** Shorter than this is a tap, not a voice note. Default 700 ms. */
|
|
73
|
+
minMs?: number;
|
|
74
|
+
/** Points left that cancel. Default 120. */
|
|
75
|
+
cancelDistance?: number;
|
|
76
|
+
/** Points up that lock. Default 80. */
|
|
77
|
+
lockDistance?: number;
|
|
78
|
+
/** Most level readings kept (one every ~100 ms). Default 3000 (5 minutes). */
|
|
79
|
+
maxLevels?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface Step {
|
|
83
|
+
state: RecorderState;
|
|
84
|
+
effects: RecorderEffect[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const stay = (state: RecorderState): Step => ({ state, effects: [] });
|
|
88
|
+
|
|
89
|
+
export function recorderStep(state: RecorderState, event: RecorderEvent, config: RecorderConfig): Step {
|
|
90
|
+
const minMs = config.minMs ?? 700;
|
|
91
|
+
const cancelDistance = config.cancelDistance ?? 120;
|
|
92
|
+
const lockDistance = config.lockDistance ?? 80;
|
|
93
|
+
const maxLevels = config.maxLevels ?? 3000;
|
|
94
|
+
|
|
95
|
+
switch (event.type) {
|
|
96
|
+
case "press":
|
|
97
|
+
if (state.phase !== "idle") return stay(state);
|
|
98
|
+
return { state: { ...idleRecorder, phase: "starting" }, effects: [{ type: "prepare" }] };
|
|
99
|
+
|
|
100
|
+
case "ready":
|
|
101
|
+
// A prepare that finished after its attempt was cancelled: let the microphone go.
|
|
102
|
+
if (state.phase !== "starting") return { state, effects: [{ type: "discard" }] };
|
|
103
|
+
if (state.releasedEarly) {
|
|
104
|
+
return { state: { ...idleRecorder, notice: "too-short" }, effects: [{ type: "discard" }] };
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
state: { ...state, phase: "recording" },
|
|
108
|
+
effects: [{ type: "record" }, { type: "haptic", event: "record-start" }, { type: "signal" }],
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
case "failed":
|
|
112
|
+
if (state.phase === "idle") return stay(state);
|
|
113
|
+
return { state: { ...idleRecorder, notice: event.reason }, effects: [{ type: "discard" }, { type: "haptic", event: "error" }] };
|
|
114
|
+
|
|
115
|
+
case "move": {
|
|
116
|
+
if (state.phase !== "recording" || state.locked) return stay(state);
|
|
117
|
+
if (event.dx <= -cancelDistance) {
|
|
118
|
+
return { state: idleRecorder, effects: [{ type: "discard" }, { type: "haptic", event: "record-cancel" }] };
|
|
119
|
+
}
|
|
120
|
+
if (event.dy <= -lockDistance) {
|
|
121
|
+
return { state: { ...state, locked: true, cancelArmed: false }, effects: [{ type: "haptic", event: "record-lock" }] };
|
|
122
|
+
}
|
|
123
|
+
const cancelArmed = event.dx <= -cancelDistance / 2;
|
|
124
|
+
return cancelArmed === state.cancelArmed ? stay(state) : stay({ ...state, cancelArmed });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case "release":
|
|
128
|
+
if (state.phase === "starting") return stay({ ...state, releasedEarly: true });
|
|
129
|
+
if (state.phase !== "recording" || state.locked) return stay(state);
|
|
130
|
+
if (state.cancelArmed) {
|
|
131
|
+
return { state: idleRecorder, effects: [{ type: "discard" }, { type: "haptic", event: "record-cancel" }] };
|
|
132
|
+
}
|
|
133
|
+
if (state.durationMs < minMs) {
|
|
134
|
+
return { state: { ...idleRecorder, notice: "too-short" }, effects: [{ type: "discard" }] };
|
|
135
|
+
}
|
|
136
|
+
return { state: { ...state, phase: "finishing" }, effects: [{ type: "finish" }, { type: "haptic", event: "send" }] };
|
|
137
|
+
|
|
138
|
+
case "send":
|
|
139
|
+
if (state.phase !== "recording") return stay(state);
|
|
140
|
+
if (state.durationMs < minMs) {
|
|
141
|
+
return { state: { ...idleRecorder, notice: "too-short" }, effects: [{ type: "discard" }] };
|
|
142
|
+
}
|
|
143
|
+
return { state: { ...state, phase: "finishing" }, effects: [{ type: "finish" }, { type: "haptic", event: "send" }] };
|
|
144
|
+
|
|
145
|
+
case "cancel":
|
|
146
|
+
if (state.phase === "idle" || state.phase === "finishing") return stay(state);
|
|
147
|
+
return { state: idleRecorder, effects: [{ type: "discard" }, { type: "haptic", event: "record-cancel" }] };
|
|
148
|
+
|
|
149
|
+
case "interrupted":
|
|
150
|
+
// Never sent on its own: the person didn't say send. A stop already under way finishes.
|
|
151
|
+
if (state.phase === "idle" || state.phase === "finishing") return stay(state);
|
|
152
|
+
return { state: { ...idleRecorder, notice: "interrupted" }, effects: [{ type: "discard" }, { type: "haptic", event: "error" }] };
|
|
153
|
+
|
|
154
|
+
case "tick": {
|
|
155
|
+
if (state.phase !== "recording") return stay(state);
|
|
156
|
+
const levels =
|
|
157
|
+
event.meteringDb === undefined ? state.levels : [...state.levels, event.meteringDb].slice(-maxLevels);
|
|
158
|
+
const next = { ...state, durationMs: event.durationMs, levels };
|
|
159
|
+
if (event.durationMs >= config.limitMs) {
|
|
160
|
+
return { state: { ...next, phase: "finishing" }, effects: [{ type: "finish" }, { type: "haptic", event: "send" }] };
|
|
161
|
+
}
|
|
162
|
+
return { state: next, effects: [{ type: "signal" }] };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
case "finished":
|
|
166
|
+
if (event.ok === false) return { state: { ...idleRecorder, notice: "error" }, effects: [{ type: "haptic", event: "error" }] };
|
|
167
|
+
return stay(idleRecorder);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The last `count` levels as bar heights 0–1, for the live waveform while recording. */
|
|
172
|
+
export function liveBars(levelsDb: readonly number[], count = 32): number[] {
|
|
173
|
+
const recent = levelsDb.slice(-count);
|
|
174
|
+
const bars = recent.map((db) => (Number.isFinite(db) ? Math.max(0, Math.min(1, (Math.max(-60, db) + 60) / 60)) : 0));
|
|
175
|
+
return [...new Array(Math.max(0, count - bars.length)).fill(0), ...bars];
|
|
176
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* The thread as rows for the list: day separators, the "New messages" line,
|
|
4
|
+
* and each message with where its group of one sender starts and ends (the
|
|
5
|
+
* name goes over the first, the avatar beside the last, in a group
|
|
6
|
+
* conversation). Oldest first, top to bottom: the order the thread's list
|
|
7
|
+
* shows and VoiceOver reads (a day's heading and the "New messages" line
|
|
8
|
+
* before the messages they head).
|
|
9
|
+
* A row is keyed by the message's client id, so a message that lands keeps
|
|
10
|
+
* its row.
|
|
11
|
+
*/
|
|
12
|
+
import { dayLabel, startsDay, startsGroup, itemSender, itemTime, type ThreadItem } from "../core/thread";
|
|
13
|
+
|
|
14
|
+
export type ThreadRow =
|
|
15
|
+
| { type: "day"; key: string; label: string }
|
|
16
|
+
| { type: "unread"; key: string }
|
|
17
|
+
| {
|
|
18
|
+
type: "message";
|
|
19
|
+
key: string;
|
|
20
|
+
item: ThreadItem;
|
|
21
|
+
mine: boolean;
|
|
22
|
+
senderId: string | null;
|
|
23
|
+
groupStart: boolean;
|
|
24
|
+
groupEnd: boolean;
|
|
25
|
+
/** Over the first of a run, in a group, for someone else's. */
|
|
26
|
+
showName: boolean;
|
|
27
|
+
/** Beside the last of a run, in a group, for someone else's. */
|
|
28
|
+
showAvatar: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export interface BuildRowsOptions {
|
|
32
|
+
me: string;
|
|
33
|
+
firstUnreadId?: string | null;
|
|
34
|
+
/** More than two people: names and avatars show. */
|
|
35
|
+
group: boolean;
|
|
36
|
+
now?: Date;
|
|
37
|
+
locale?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function rowKey(item: ThreadItem): string {
|
|
41
|
+
return `c:${item.type === "message" ? item.message.clientId : item.pending.clientId}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Oldest first. */
|
|
45
|
+
export function buildThreadRows(items: ThreadItem[], options: BuildRowsOptions): ThreadRow[] {
|
|
46
|
+
const { me, firstUnreadId, group, now = new Date(), locale } = options;
|
|
47
|
+
const rows: ThreadRow[] = [];
|
|
48
|
+
for (let i = 0; i < items.length; i++) {
|
|
49
|
+
const item = items[i]!;
|
|
50
|
+
if (startsDay(items, i)) rows.push({ type: "day", key: `d:${rowKey(item)}`, label: dayLabel(itemTime(item), now, locale) });
|
|
51
|
+
if (item.type === "message" && item.message.id === firstUnreadId) rows.push({ type: "unread", key: "unread" });
|
|
52
|
+
const senderId = itemSender(item);
|
|
53
|
+
const mine = item.type === "pending" || senderId === me;
|
|
54
|
+
const system = item.type === "message" && item.message.kind === "system";
|
|
55
|
+
const groupStart = startsGroup(items, i) || (item.type === "message" && item.message.id === firstUnreadId);
|
|
56
|
+
const nextItem = items[i + 1];
|
|
57
|
+
const groupEnd = !nextItem || startsGroup(items, i + 1) || (nextItem.type === "message" && nextItem.message.id === firstUnreadId);
|
|
58
|
+
rows.push({
|
|
59
|
+
type: "message",
|
|
60
|
+
key: rowKey(item),
|
|
61
|
+
item,
|
|
62
|
+
mine,
|
|
63
|
+
senderId,
|
|
64
|
+
groupStart,
|
|
65
|
+
groupEnd,
|
|
66
|
+
showName: group && !mine && !system && groupStart,
|
|
67
|
+
showAvatar: group && !mine && !system && groupEnd,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return rows;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Newest first, for an inverted list (its first row sits at the bottom). The
|
|
75
|
+
* thread screen no longer uses it: VoiceOver reads an inverted list's rows
|
|
76
|
+
* newest first, each day's heading after its messages.
|
|
77
|
+
* @deprecated Use `buildThreadRows` in a list that isn't inverted.
|
|
78
|
+
*/
|
|
79
|
+
export function invertedRows(items: ThreadItem[], options: BuildRowsOptions): ThreadRow[] {
|
|
80
|
+
return buildThreadRows(items, options).reverse();
|
|
81
|
+
}
|