@joinskips/brand 5.0.0 → 6.0.0-develop.126
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 +7 -0
- package/chat/attachments.d.ts +11 -0
- package/chat/attachments.js +34 -0
- package/chat/components/Bubble.d.ts +5 -0
- package/chat/components/Bubble.js +9 -0
- package/chat/components/Conversation.d.ts +28 -0
- package/chat/components/Conversation.js +117 -0
- package/chat/components/Markdown.d.ts +4 -0
- package/chat/components/Markdown.js +46 -0
- package/chat/components/Welcome.d.ts +78 -0
- package/chat/components/Welcome.js +145 -0
- package/chat/components/parts.d.ts +38 -0
- package/chat/components/parts.js +6 -0
- package/chat/index.d.ts +10 -0
- package/chat/index.js +9 -0
- package/chat/markdown.d.ts +47 -0
- package/chat/markdown.js +129 -0
- package/chat/message.d.ts +46 -0
- package/chat/message.js +12 -0
- package/chat/skipTyping.d.ts +26 -0
- package/chat/skipTyping.js +29 -0
- package/chat/thread.d.ts +43 -0
- package/chat/thread.js +107 -0
- package/chat/typing.d.ts +13 -0
- package/chat/typing.js +68 -0
- package/chat/visibleArea.d.ts +15 -0
- package/chat/visibleArea.js +11 -0
- package/chat/welcome.d.ts +25 -0
- package/chat/welcome.js +51 -0
- package/logos/png-2048/skips-wordmark-dark-2048.png +0 -0
- package/manifest.json +9 -1
- package/package.json +33 -1
- package/tokens/skips-tailwind-theme.css +106 -0
package/README.md
CHANGED
|
@@ -13,4 +13,11 @@ import tokens from '@joinskips/brand/tokens/skips-design-tokens.json' with { typ
|
|
|
13
13
|
@import '@joinskips/brand/fonts/onest/onest.css';
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
A Tailwind 4 app imports the tokens as its theme. The colours sit under `@variant light` and `@variant dark`, the way
|
|
17
|
+
Uniwind reads them.
|
|
18
|
+
|
|
19
|
+
```css
|
|
20
|
+
@import '@joinskips/brand/tokens/skips-tailwind-theme.css';
|
|
21
|
+
```
|
|
22
|
+
|
|
16
23
|
`manifest.json` lists every file with its hash and size. Onest's licence is `fonts/onest/OFL.txt`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const ATTACHMENT_TYPES: string[];
|
|
2
|
+
export declare const MAX_ATTACHMENT_BYTES: number;
|
|
3
|
+
/** A picked file's type: the picker's own, else the one its name says, else none. */
|
|
4
|
+
export declare function attachmentType(name: string, given?: string | null): string;
|
|
5
|
+
/** What attachmentProblem reads of a picked file. */
|
|
6
|
+
export interface FileFacts {
|
|
7
|
+
type: string;
|
|
8
|
+
size: number;
|
|
9
|
+
}
|
|
10
|
+
/** Why a picked file cannot be sent, or null when it can. */
|
|
11
|
+
export declare function attachmentProblem(file: FileFacts): 'type' | 'size' | null;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const ATTACHMENT_TYPES = [
|
|
2
|
+
'image/jpeg',
|
|
3
|
+
'image/png',
|
|
4
|
+
'image/gif',
|
|
5
|
+
'image/webp',
|
|
6
|
+
'image/heic',
|
|
7
|
+
'image/heif',
|
|
8
|
+
'application/pdf',
|
|
9
|
+
];
|
|
10
|
+
export const MAX_ATTACHMENT_BYTES = 40 * 1024 * 1024;
|
|
11
|
+
// Pickers can leave the type out, so it comes from the name.
|
|
12
|
+
const TYPE_BY_EXTENSION = new Map([
|
|
13
|
+
['jpg', 'image/jpeg'],
|
|
14
|
+
['jpeg', 'image/jpeg'],
|
|
15
|
+
['png', 'image/png'],
|
|
16
|
+
['gif', 'image/gif'],
|
|
17
|
+
['webp', 'image/webp'],
|
|
18
|
+
['heic', 'image/heic'],
|
|
19
|
+
['heif', 'image/heif'],
|
|
20
|
+
['pdf', 'application/pdf'],
|
|
21
|
+
]);
|
|
22
|
+
/** A picked file's type: the picker's own, else the one its name says, else none. */
|
|
23
|
+
export function attachmentType(name, given) {
|
|
24
|
+
if (given)
|
|
25
|
+
return given;
|
|
26
|
+
const dot = name.lastIndexOf('.');
|
|
27
|
+
return dot === -1 ? '' : (TYPE_BY_EXTENSION.get(name.slice(dot + 1).toLowerCase()) ?? '');
|
|
28
|
+
}
|
|
29
|
+
/** Why a picked file cannot be sent, or null when it can. */
|
|
30
|
+
export function attachmentProblem(file) {
|
|
31
|
+
if (!ATTACHMENT_TYPES.includes(file.type))
|
|
32
|
+
return 'type';
|
|
33
|
+
return file.size > MAX_ATTACHMENT_BYTES ? 'size' : null;
|
|
34
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text, View } from 'react-native';
|
|
3
|
+
import { Markdown } from "./Markdown.js";
|
|
4
|
+
export function Bubble({ mine = false, text }) {
|
|
5
|
+
if (mine) {
|
|
6
|
+
return (_jsx(Text, { className: "rounded-[20px] rounded-br-[7px] bg-accent px-4 py-2.5 font-ui text-md text-accent-ink", children: text }));
|
|
7
|
+
}
|
|
8
|
+
return (_jsx(View, { className: "shrink rounded-[20px] rounded-bl-[7px] border border-line-subtle bg-surface px-4 py-2.5", children: _jsx(Markdown, { text: text }) }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ImageSourcePropType } from 'react-native';
|
|
2
|
+
import type { PickedFile } from '../message.ts';
|
|
3
|
+
import { type Thread } from '../thread.ts';
|
|
4
|
+
import { type ChatIcon, type ChatWords } from './parts.ts';
|
|
5
|
+
export type LoadState = 'loading' | 'ready' | 'failed';
|
|
6
|
+
export interface ConversationProps {
|
|
7
|
+
thread: Thread;
|
|
8
|
+
load: LoadState;
|
|
9
|
+
feedDown: boolean;
|
|
10
|
+
typists: string[];
|
|
11
|
+
skipUserId: number | null;
|
|
12
|
+
onSend: (text: string, file: PickedFile | null) => void;
|
|
13
|
+
onRetry: (id: number) => void;
|
|
14
|
+
onTyped: (text: string) => void;
|
|
15
|
+
onLoadOlder: () => void;
|
|
16
|
+
onReload: () => void;
|
|
17
|
+
pickFile?: () => Promise<PickedFile | null>;
|
|
18
|
+
initialText?: string;
|
|
19
|
+
keyboardOffset?: number;
|
|
20
|
+
bottomInset?: number;
|
|
21
|
+
words: ChatWords;
|
|
22
|
+
Icon: ChatIcon;
|
|
23
|
+
skipMark: ImageSourcePropType;
|
|
24
|
+
}
|
|
25
|
+
export declare const PHOTO_RETRIES = 5;
|
|
26
|
+
export declare const PHOTO_RETRY_MS = 1500;
|
|
27
|
+
/** One conversation: its messages oldest at the top, the care team's typing, and the box to write in. */
|
|
28
|
+
export declare function Conversation(props: ConversationProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { ActivityIndicator, FlatList, Image, KeyboardAvoidingView, Linking, Platform, Pressable, Text, TextInput, View, } from 'react-native';
|
|
4
|
+
import { attachmentProblem } from "../attachments.js";
|
|
5
|
+
import { groupThread } from "../thread.js";
|
|
6
|
+
import { Bubble } from "./Bubble.js";
|
|
7
|
+
import { useColor } from "./parts.js";
|
|
8
|
+
// An image from require needs its size in style: the web draws it at its own size otherwise.
|
|
9
|
+
const AVATAR_SIZE = 32;
|
|
10
|
+
export const PHOTO_RETRIES = 5;
|
|
11
|
+
export const PHOTO_RETRY_MS = 1_500;
|
|
12
|
+
const MIN_BOTTOM_SPACE = 12;
|
|
13
|
+
function Line({ className, ...props }) {
|
|
14
|
+
return _jsx(Text, { className: `font-ui ${className}`, ...props });
|
|
15
|
+
}
|
|
16
|
+
const isPhoto = (file) => file.type.startsWith('image/');
|
|
17
|
+
/** One conversation: its messages oldest at the top, the care team's typing, and the box to write in. */
|
|
18
|
+
export function Conversation(props) {
|
|
19
|
+
const { thread, load, feedDown, typists, words, onReload, onLoadOlder } = props;
|
|
20
|
+
const secondary = useColor('ink-secondary');
|
|
21
|
+
// The list is inverted so it rests at the newest message and stays put while the member reads older ones.
|
|
22
|
+
const items = groupThread(thread.messages, props.skipUserId).reverse();
|
|
23
|
+
return (_jsxs(KeyboardAvoidingView, { behavior: "padding", keyboardVerticalOffset: props.keyboardOffset ?? 0, className: "flex-1", children: [feedDown ? (_jsx(Line, { role: "status", className: "bg-muted px-gutter-phone py-2 text-center text-sm text-ink-secondary", children: words.feedDown })) : null, load === 'loading' ? (_jsx(ActivityIndicator, { accessibilityLabel: words.loading, className: "flex-1", color: secondary })) : load === 'failed' ? (_jsxs(View, { role: "alert", className: "flex-1 items-center justify-center gap-3 px-gutter-phone", children: [_jsx(Line, { className: "text-center text-base text-ink-secondary", children: words.loadFailed }), _jsx(Pressable, { role: "button", onPress: onReload, className: "h-control items-center justify-center rounded-pill border border-line-control px-6 active:opacity-80", children: _jsx(Line, { className: "text-md font-semibold text-ink", children: words.retry }) })] })) : (_jsx(FlatList, { inverted: true, data: items, keyExtractor: itemKey, onEndReached: onLoadOlder, contentContainerClassName: "gap-4 px-gutter-phone py-4 md:mx-auto md:w-full md:max-w-reading", ListHeaderComponent: typists.length ? _jsx(Line, { className: "text-sm text-ink-secondary", children: words.typingLine(typists) }) : undefined, renderItem: ({ item }) => _jsx(ThreadRow, { item: item, ...props }) })), _jsx(Composer, { ...props })] }));
|
|
24
|
+
}
|
|
25
|
+
function itemKey(item) {
|
|
26
|
+
if (item.kind === 'run')
|
|
27
|
+
return `r${String(item.messages[0]?.id)}`;
|
|
28
|
+
return `${item.kind === 'notice' ? 'n' : 'u'}${String(item.message.id)}`;
|
|
29
|
+
}
|
|
30
|
+
function Composer({ onSend, onTyped, pickFile, initialText, bottomInset, words, Icon }) {
|
|
31
|
+
const secondary = useColor('ink-secondary');
|
|
32
|
+
const placeholder = useColor('ink-placeholder');
|
|
33
|
+
const actionInk = useColor('action-ink');
|
|
34
|
+
const [text, setText] = useState(initialText ?? '');
|
|
35
|
+
const [picked, setPicked] = useState(null);
|
|
36
|
+
const [refused, setRefused] = useState(null);
|
|
37
|
+
const canSend = Boolean(text.trim() || picked);
|
|
38
|
+
const attach = async (pick) => {
|
|
39
|
+
const file = await pick();
|
|
40
|
+
if (!file)
|
|
41
|
+
return;
|
|
42
|
+
const problem = attachmentProblem(file);
|
|
43
|
+
setRefused(problem ? words.attachmentRefused[problem] : null);
|
|
44
|
+
if (!problem)
|
|
45
|
+
setPicked(file);
|
|
46
|
+
};
|
|
47
|
+
const submit = () => {
|
|
48
|
+
onSend(text, picked);
|
|
49
|
+
setText('');
|
|
50
|
+
setPicked(null);
|
|
51
|
+
setRefused(null);
|
|
52
|
+
};
|
|
53
|
+
return (_jsxs(View, { style: { paddingBottom: Math.max(bottomInset ?? 0, MIN_BOTTOM_SPACE) }, className: "gap-2 px-gutter-phone pt-2 md:mx-auto md:w-full md:max-w-reading", children: [refused ? (_jsx(Line, { role: "alert", className: "text-sm text-error-ink", children: refused })) : null, picked ? (_jsxs(View, { className: "flex-row items-center gap-3 self-start rounded-control border border-line-subtle bg-surface p-2 pr-3", children: [isPhoto(picked) ? (_jsx(Image, { source: { uri: picked.uri }, resizeMode: "cover", className: "h-10 w-10 rounded-control bg-muted" })) : (_jsx(View, { className: "h-10 w-10 items-center justify-center", children: _jsx(Icon, { name: "file", color: secondary, size: 20 }) })), _jsxs(View, { className: "max-w-56", children: [_jsx(Line, { className: "text-sm text-ink", numberOfLines: 1, children: picked.name }), _jsx(Line, { className: "text-xs text-ink-secondary", children: `${isPhoto(picked) ? words.photo : words.pdf} · ${words.fileSize(picked.size)}` })] }), _jsx(Pressable, { role: "button", accessibilityLabel: `${words.remove} ${picked.name}`, hitSlop: 8, onPress: () => {
|
|
54
|
+
setPicked(null);
|
|
55
|
+
}, children: _jsx(Icon, { name: "close", color: secondary, size: 18 }) })] })) : null, _jsxs(View, { className: "flex-row items-end gap-1 rounded-[24px] border border-line-subtle bg-surface p-1.5 shadow-card", children: [pickFile ? (_jsx(Pressable, { role: "button", accessibilityLabel: words.attach, onPress: () => void attach(pickFile), className: "h-[42px] w-[42px] items-center justify-center rounded-pill active:bg-muted", children: _jsx(Icon, { name: "attach", color: secondary, size: 18 }) })) : null, _jsx(TextInput, { value: text, onChangeText: (value) => {
|
|
56
|
+
setText(value);
|
|
57
|
+
onTyped(value);
|
|
58
|
+
}, placeholder: words.composerPlaceholder, placeholderTextColor: placeholder, accessibilityLabel: words.composerPlaceholder, multiline: true, ...Platform.select({ web: { rows: 1 }, default: {} }), className: "max-h-40 flex-1 self-center px-1 py-2 font-ui text-md leading-6 text-ink" }), _jsx(Pressable, { role: "button", accessibilityLabel: words.send, disabled: !canSend, onPress: submit, className: `h-[42px] w-[42px] items-center justify-center rounded-pill bg-action active:bg-action-pressed ${canSend ? '' : 'opacity-40'}`, children: _jsx(View, { style: { transform: [{ rotate: '-90deg' }] }, children: _jsx(Icon, { name: "arrow-right", color: actionInk, size: 18 }) }) })] }), _jsx(Line, { className: "text-center text-xs text-ink-secondary", children: words.emergencyNote })] }));
|
|
59
|
+
}
|
|
60
|
+
function ThreadRow({ item, words, onRetry, ...props }) {
|
|
61
|
+
if (item.kind === 'notice') {
|
|
62
|
+
return (_jsx(Line, { className: "self-center rounded-control border border-line-subtle px-3 py-2 text-sm text-ink-secondary", children: item.message.text }));
|
|
63
|
+
}
|
|
64
|
+
if (item.kind === 'unsent')
|
|
65
|
+
return _jsx(Unsent, { message: item.message, words: words, onRetry: onRetry, Icon: props.Icon });
|
|
66
|
+
const { sender, messages } = item;
|
|
67
|
+
const mine = sender === 'mine';
|
|
68
|
+
const name = mine ? words.you : sender === 'skip' ? words.skip : sender.name;
|
|
69
|
+
// The run is in time order, so its time is its last line's.
|
|
70
|
+
const sentAt = Math.max(...messages.map((m) => m.sentAt));
|
|
71
|
+
return (_jsxs(View, { className: `max-w-[82%] gap-1 ${mine ? 'items-end self-end' : 'items-start self-start'}`, children: [messages.map((message, index) => (_jsxs(View, { className: "flex-row items-end gap-2", children: [mine ? null : index === messages.length - 1 ? (_jsx(SenderAvatar, { sender: sender, name: name, skipMark: props.skipMark })) : (_jsx(View, { className: "w-8" })), _jsxs(View, { className: `shrink gap-2 ${mine ? 'items-end' : 'items-start'}`, children: [message.attachments.map((attachment) => (_jsx(Attachment, { attachment: attachment, words: words, Icon: props.Icon }, attachment.url))), message.text ? _jsx(Bubble, { mine: mine, text: message.text }) : null] })] }, message.id))), _jsx(Line, { className: `px-1 text-xs text-ink-secondary ${mine ? '' : 'ml-10'}`, children: `${name} · ${words.messageTime(sentAt)}` })] }));
|
|
72
|
+
}
|
|
73
|
+
// A failed line is a button to send it again, and its note is announced.
|
|
74
|
+
const BUTTON_ROLE = 'button';
|
|
75
|
+
const ALERT_ROLE = 'alert';
|
|
76
|
+
function Unsent({ message, words, onRetry, Icon, }) {
|
|
77
|
+
const failed = message.delivery === 'failed';
|
|
78
|
+
return (_jsxs(Pressable, { ...(failed ? { role: BUTTON_ROLE } : {}), disabled: !failed, onPress: () => {
|
|
79
|
+
onRetry(message.id);
|
|
80
|
+
}, className: `max-w-[82%] items-end gap-1 self-end ${failed ? '' : 'opacity-70'}`, children: [message.file ? _jsx(PickedPreview, { picked: message.file, words: words, Icon: Icon }) : null, message.text ? _jsx(Bubble, { mine: true, text: message.text }) : null, _jsx(Line, { ...(failed ? { role: ALERT_ROLE } : {}), className: `px-1 text-xs ${failed ? 'text-error-ink' : 'text-ink-secondary'}`, children: `${words.you} · ${failed ? words.notSent : words.sending}` })] }));
|
|
81
|
+
}
|
|
82
|
+
// Skip's mark, or a care team member's photo, or their initial when they have none or it does not load.
|
|
83
|
+
function SenderAvatar({ sender, name, skipMark, }) {
|
|
84
|
+
const [failed, setFailed] = useState(false);
|
|
85
|
+
if (sender === 'skip') {
|
|
86
|
+
return (_jsx(Image, { source: skipMark, accessibilityElementsHidden: true, style: { width: AVATAR_SIZE, height: AVATAR_SIZE }, className: "rounded-control" }));
|
|
87
|
+
}
|
|
88
|
+
return (_jsx(View, { accessibilityElementsHidden: true, className: "h-8 w-8 items-center justify-center overflow-hidden rounded-pill bg-muted", children: sender.avatarUrl && !failed ? (_jsx(Image, { source: { uri: sender.avatarUrl }, onError: () => {
|
|
89
|
+
setFailed(true);
|
|
90
|
+
}, style: { width: AVATAR_SIZE, height: AVATAR_SIZE } })) : (_jsx(Line, { className: "text-sm font-semibold text-ink", children: name.trim().charAt(0).toUpperCase() })) }));
|
|
91
|
+
}
|
|
92
|
+
function FileCard({ name, detail, Icon }) {
|
|
93
|
+
const secondary = useColor('ink-secondary');
|
|
94
|
+
return (_jsxs(View, { className: "w-80 max-w-full flex-row items-center gap-3 rounded-control border border-line-subtle bg-surface p-3", children: [_jsx(Icon, { name: "file", color: secondary, size: 20 }), _jsxs(View, { className: "flex-1", children: [_jsx(Line, { className: "text-sm text-ink", numberOfLines: 1, children: name }), _jsx(Line, { className: "text-xs text-ink-secondary", children: detail })] })] }));
|
|
95
|
+
}
|
|
96
|
+
function PickedPreview({ picked, words, Icon }) {
|
|
97
|
+
if (isPhoto(picked)) {
|
|
98
|
+
return (_jsx(Image, { source: { uri: picked.uri }, accessibilityLabel: `${words.photo} ${picked.name}`, resizeMode: "cover", className: "h-48 w-48 rounded-control bg-muted" }));
|
|
99
|
+
}
|
|
100
|
+
return _jsx(FileCard, { name: picked.name, detail: `${words.pdf} · ${words.fileSize(picked.size)}`, Icon: Icon });
|
|
101
|
+
}
|
|
102
|
+
// A photo shows in the conversation and a PDF as its card. Either opens in full.
|
|
103
|
+
function Attachment({ attachment, words, Icon, }) {
|
|
104
|
+
// The Skips App stores a photo a moment after its upload answers, so a fresh one loads again until it is there.
|
|
105
|
+
const [attempt, setAttempt] = useState(0);
|
|
106
|
+
const open = () => void Linking.openURL(attachment.url);
|
|
107
|
+
if (attachment.kind === 'file') {
|
|
108
|
+
return (_jsx(Pressable, { role: "link", onPress: open, className: "active:opacity-80", children: _jsx(FileCard, { name: attachment.name, detail: words.fileKind(attachment.name), Icon: Icon }) }));
|
|
109
|
+
}
|
|
110
|
+
return (_jsx(Pressable, { role: "link", accessibilityLabel: `${words.photo} ${attachment.name}`, onPress: open, className: "active:opacity-80", children: _jsx(Image, { source: { uri: attachment.thumbUrl ?? attachment.url },
|
|
111
|
+
// Past the last attempt the key stays the same, so the photo is not asked for again.
|
|
112
|
+
onError: () => {
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
setAttempt((last) => Math.min(last + 1, PHOTO_RETRIES));
|
|
115
|
+
}, PHOTO_RETRY_MS);
|
|
116
|
+
}, resizeMode: "cover", className: "h-48 w-48 rounded-control bg-muted" }, attempt) }));
|
|
117
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Linking, Text, View } from 'react-native';
|
|
3
|
+
import { readMarkdown } from "../markdown.js";
|
|
4
|
+
function Inlines({ nodes }) {
|
|
5
|
+
return nodes.map((node, i) => _jsx(InlineNode, { node: node }, `${String(i)}-${node.type}`));
|
|
6
|
+
}
|
|
7
|
+
function InlineNode({ node }) {
|
|
8
|
+
switch (node.type) {
|
|
9
|
+
case 'text':
|
|
10
|
+
return node.text;
|
|
11
|
+
case 'break':
|
|
12
|
+
return '\n';
|
|
13
|
+
case 'code':
|
|
14
|
+
return _jsx(Text, { className: "bg-muted font-mono text-sm", children: node.text });
|
|
15
|
+
case 'strong':
|
|
16
|
+
return (_jsx(Text, { className: "font-semibold", children: _jsx(Inlines, { nodes: node.children }) }));
|
|
17
|
+
case 'em':
|
|
18
|
+
return (_jsx(Text, { className: "italic", children: _jsx(Inlines, { nodes: node.children }) }));
|
|
19
|
+
case 'del':
|
|
20
|
+
return (_jsx(Text, { className: "line-through", children: _jsx(Inlines, { nodes: node.children }) }));
|
|
21
|
+
case 'link':
|
|
22
|
+
return (_jsx(Text, { role: "link", className: "underline", onPress: () => {
|
|
23
|
+
void Linking.openURL(node.href);
|
|
24
|
+
}, children: _jsx(Inlines, { nodes: node.children }) }));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function Blocks({ blocks }) {
|
|
28
|
+
return blocks.map((block, i) => _jsx(BlockNode, { block: block }, `${String(i)}-${block.type}`));
|
|
29
|
+
}
|
|
30
|
+
function BlockNode({ block }) {
|
|
31
|
+
switch (block.type) {
|
|
32
|
+
case 'paragraph':
|
|
33
|
+
return (_jsx(Text, { className: "font-ui text-md text-ink", children: _jsx(Inlines, { nodes: block.children }) }));
|
|
34
|
+
case 'list':
|
|
35
|
+
return (_jsx(View, { role: "list", className: "gap-1", children: block.items.map((item, i) => (_jsxs(View, { role: "listitem", className: "flex-row gap-2", children: [_jsx(Text, { className: "font-ui text-md text-ink", children: block.ordered ? `${String(block.start + i)}.` : '•' }), _jsx(View, { className: "shrink gap-1", children: _jsx(Blocks, { blocks: item }) })] }, String(i)))) }));
|
|
36
|
+
case 'quote':
|
|
37
|
+
return (_jsx(View, { className: "gap-1 border-l-2 border-line-control pl-3", children: _jsx(Blocks, { blocks: block.children }) }));
|
|
38
|
+
case 'code':
|
|
39
|
+
return _jsx(Text, { className: "bg-muted p-2 font-mono text-sm text-ink", children: block.text });
|
|
40
|
+
case 'rule':
|
|
41
|
+
return _jsx(View, { className: "h-px bg-line-control" });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function Markdown({ text }) {
|
|
45
|
+
return (_jsx(View, { className: "gap-2", children: _jsx(Blocks, { blocks: readMarkdown(text) }) }));
|
|
46
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type ImageSourcePropType } from 'react-native';
|
|
2
|
+
import type { ChoiceItem } from '../message.ts';
|
|
3
|
+
/** Every word the website's welcome shows, which the website passes in. */
|
|
4
|
+
export interface WelcomeWords {
|
|
5
|
+
logIn: string;
|
|
6
|
+
continueHere: string;
|
|
7
|
+
choiceNotSent: string;
|
|
8
|
+
emailLabel: string;
|
|
9
|
+
emailSubmit: string;
|
|
10
|
+
emailError: string;
|
|
11
|
+
changeEmail: string;
|
|
12
|
+
nameLabel: string;
|
|
13
|
+
nameSubmit: string;
|
|
14
|
+
nameError: string;
|
|
15
|
+
codeLabel: string;
|
|
16
|
+
codeSubmit: string;
|
|
17
|
+
resend: string;
|
|
18
|
+
resendWait: string;
|
|
19
|
+
codeNoMatch: string;
|
|
20
|
+
codeRanOut: string;
|
|
21
|
+
typing: string;
|
|
22
|
+
}
|
|
23
|
+
export interface WelcomeChoiceProps {
|
|
24
|
+
words: WelcomeWords;
|
|
25
|
+
onLogIn: () => void;
|
|
26
|
+
onContinueHere: () => void;
|
|
27
|
+
}
|
|
28
|
+
/** The choice under Skip's welcome: log in as a member, or go on here as someone new. */
|
|
29
|
+
export declare function WelcomeChoice({ words, onLogIn, onContinueHere, }: WelcomeChoiceProps): import("react").JSX.Element;
|
|
30
|
+
export interface ChoicesProps {
|
|
31
|
+
items: ChoiceItem[];
|
|
32
|
+
question: string;
|
|
33
|
+
sending: string | null;
|
|
34
|
+
holding?: boolean;
|
|
35
|
+
failed: boolean;
|
|
36
|
+
words: WelcomeWords;
|
|
37
|
+
onChoose: (answer: ChoiceItem) => void;
|
|
38
|
+
}
|
|
39
|
+
/** The answers to Skip's question, all drawn alike. One answer only: while one is on its way, none can be pressed. */
|
|
40
|
+
export declare function Choices({ items, question, sending, holding, failed, words, onChoose, }: ChoicesProps): import("react").JSX.Element;
|
|
41
|
+
export interface EmailStepProps {
|
|
42
|
+
words: WelcomeWords;
|
|
43
|
+
onSubmit: (email: string) => void;
|
|
44
|
+
initial?: string;
|
|
45
|
+
}
|
|
46
|
+
/** The email asked for after Continue here, and again for a visitor changing it. */
|
|
47
|
+
export declare function EmailStep({ words, onSubmit, initial, }: EmailStepProps): import("react").JSX.Element;
|
|
48
|
+
export interface NameStepProps {
|
|
49
|
+
words: WelcomeWords;
|
|
50
|
+
onSubmit: (name: string) => void;
|
|
51
|
+
onChangeEmail: () => void;
|
|
52
|
+
}
|
|
53
|
+
/** The name asked of a visitor the Skips App does not know, with the way back to change a mistyped email. */
|
|
54
|
+
export declare function NameStep({ words, onSubmit, onChangeEmail, }: NameStepProps): import("react").JSX.Element;
|
|
55
|
+
/**
|
|
56
|
+
* The code emailed to the visitor, one digit to a box over one real field, so typing, deleting, a paste and the
|
|
57
|
+
* phone's own code suggestion work as in any field. The chat mounts it afresh for each code that goes out.
|
|
58
|
+
*/
|
|
59
|
+
export interface CodeStepProps {
|
|
60
|
+
words: WelcomeWords;
|
|
61
|
+
length: number;
|
|
62
|
+
noMatch: boolean;
|
|
63
|
+
ranOut: boolean;
|
|
64
|
+
resendAfterMs?: number;
|
|
65
|
+
onSubmit: (code: string) => void;
|
|
66
|
+
onResend: () => void;
|
|
67
|
+
onChangeEmail: () => void;
|
|
68
|
+
}
|
|
69
|
+
export declare function CodeStep({ words, length, noMatch, ranOut, resendAfterMs, onSubmit, onResend, onChangeEmail, }: CodeStepProps): import("react").JSX.Element;
|
|
70
|
+
export declare const DOT_REST_OPACITY = 0.35;
|
|
71
|
+
export declare const DOT_RISE = 3;
|
|
72
|
+
export declare const DOT_STILL_OPACITY = 0.6;
|
|
73
|
+
/** Skip writing a reply: three dots rising in turn in Skip's bubble beside Skip's mark, announced as a status. */
|
|
74
|
+
export interface SkipTypingProps {
|
|
75
|
+
label: string;
|
|
76
|
+
skipMark: ImageSourcePropType;
|
|
77
|
+
}
|
|
78
|
+
export declare function SkipTyping({ label, skipMark }: SkipTypingProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useId, useState } from 'react';
|
|
3
|
+
import { AccessibilityInfo, ActivityIndicator, Animated, Image, Pressable, Text, TextInput, View } from 'react-native';
|
|
4
|
+
import { DOT_CYCLE_MS } from "../skipTyping.js";
|
|
5
|
+
import { codeDigits, completesCode, EMAIL_MAX_LENGTH, givenEmail, givenName, NAME_MAX_LENGTH, RESEND_AFTER_MS, resendClock, } from "../welcome.js";
|
|
6
|
+
import { useColor } from "./parts.js";
|
|
7
|
+
const PILL_LOOK = {
|
|
8
|
+
solid: { box: 'bg-action active:bg-action-pressed', text: 'text-action-ink' },
|
|
9
|
+
stroked: { box: 'border border-line-control active:bg-muted', text: 'text-ink' },
|
|
10
|
+
flat: { box: 'active:bg-muted', text: 'text-ink' },
|
|
11
|
+
};
|
|
12
|
+
function Pill({ label, look = 'solid', disabled = false, loading = false, onPress, }) {
|
|
13
|
+
const ink = useColor(look === 'solid' ? 'action-ink' : 'ink');
|
|
14
|
+
return (_jsxs(Pressable, { role: "button", accessibilityLabel: label, disabled: disabled || loading, "aria-busy": loading, onPress: onPress, className: `h-control-compact flex-row items-center justify-center gap-2 rounded-pill px-5 ${PILL_LOOK[look].box} ${disabled ? 'opacity-40' : ''}`, children: [loading ? _jsx(ActivityIndicator, { "aria-hidden": true, size: "small", color: ink }) : null, _jsx(Text, { className: `font-ui text-sm font-semibold ${PILL_LOOK[look].text}`, children: label })] }));
|
|
15
|
+
}
|
|
16
|
+
/** The choice under Skip's welcome: log in as a member, or go on here as someone new. */
|
|
17
|
+
export function WelcomeChoice({ words, onLogIn, onContinueHere, }) {
|
|
18
|
+
return (_jsxs(View, { className: "flex-row flex-wrap items-center justify-center gap-2 self-stretch py-1", children: [_jsx(Pill, { label: words.logIn, look: "stroked", onPress: onLogIn }), _jsx(Pill, { label: words.continueHere, onPress: onContinueHere })] }));
|
|
19
|
+
}
|
|
20
|
+
/** The answers to Skip's question, all drawn alike. One answer only: while one is on its way, none can be pressed. */
|
|
21
|
+
export function Choices({ items, question, sending, holding = false, failed, words, onChoose, }) {
|
|
22
|
+
return (_jsxs(View, { className: "items-center gap-1 self-stretch py-1", children: [_jsx(View, { role: "group", accessibilityLabel: question, className: "flex-row flex-wrap items-center justify-center gap-2", children: items.map((item) => (_jsx(Pill, { label: item.title, look: "stroked", disabled: holding || sending !== null, loading: sending === item.value, onPress: () => {
|
|
23
|
+
onChoose(item);
|
|
24
|
+
} }, item.value))) }), failed ? (_jsx(Text, { role: "alert", className: "font-ui text-sm text-error-ink", children: words.choiceNotSent })) : null] }));
|
|
25
|
+
}
|
|
26
|
+
// The error under a field, as the field's description. React Native's types leave aria-describedby out, and
|
|
27
|
+
// react-native-web reads it.
|
|
28
|
+
function describedBy(errorId) {
|
|
29
|
+
return errorId === undefined ? {} : { 'aria-describedby': errorId };
|
|
30
|
+
}
|
|
31
|
+
// One thing asked of a new visitor: a field in focus and its button. What is typed is checked here and handed on.
|
|
32
|
+
function StepField({ label, error, submitLabel, input, usable, onSubmit, initial = '', aside }) {
|
|
33
|
+
const [typed, setTyped] = useState(initial);
|
|
34
|
+
const [refused, setRefused] = useState(false);
|
|
35
|
+
const errorId = useId();
|
|
36
|
+
const placeholder = useColor('ink-placeholder');
|
|
37
|
+
const submit = () => {
|
|
38
|
+
const value = usable(typed);
|
|
39
|
+
setRefused(value === null);
|
|
40
|
+
if (value !== null)
|
|
41
|
+
onSubmit(value);
|
|
42
|
+
};
|
|
43
|
+
return (_jsxs(View, { className: "w-full max-w-[360px] gap-2 self-center", children: [aside ? (_jsx(View, { className: "self-center", children: _jsx(Pill, { label: aside.label, look: "flat", onPress: aside.onPress }) })) : null, _jsx(Text, { className: "font-ui text-sm font-medium text-ink", children: label }), _jsx(TextInput, { autoFocus: true, value: typed, onChangeText: setTyped, onSubmitEditing: submit, accessibilityLabel: label, "aria-invalid": refused, ...describedBy(refused ? errorId : undefined), placeholderTextColor: placeholder, className: `h-control rounded-control border bg-surface px-3 font-ui text-md text-ink ${refused ? 'border-error-ink' : 'border-line-control'}`, ...input }), refused ? (_jsx(Text, { nativeID: errorId, role: "alert", className: "font-ui text-sm text-error-ink", children: error })) : null, _jsx(View, { className: "self-center", children: _jsx(Pill, { label: submitLabel, onPress: submit }) })] }));
|
|
44
|
+
}
|
|
45
|
+
/** The email asked for after Continue here, and again for a visitor changing it. */
|
|
46
|
+
export function EmailStep({ words, onSubmit, initial, }) {
|
|
47
|
+
return (_jsx(StepField, { label: words.emailLabel, error: words.emailError, submitLabel: words.emailSubmit, input: { autoComplete: 'email', inputMode: 'email', maxLength: EMAIL_MAX_LENGTH, autoCapitalize: 'none' }, usable: givenEmail, onSubmit: onSubmit, ...(initial === undefined ? {} : { initial }) }));
|
|
48
|
+
}
|
|
49
|
+
/** The name asked of a visitor the Skips App does not know, with the way back to change a mistyped email. */
|
|
50
|
+
export function NameStep({ words, onSubmit, onChangeEmail, }) {
|
|
51
|
+
return (_jsx(StepField, { label: words.nameLabel, error: words.nameError, submitLabel: words.nameSubmit, input: { autoComplete: 'name', maxLength: NAME_MAX_LENGTH, autoCapitalize: 'words' }, usable: givenName, onSubmit: onSubmit, aside: { label: words.changeEmail, onPress: onChangeEmail } }));
|
|
52
|
+
}
|
|
53
|
+
const TICK_MS = 250;
|
|
54
|
+
function NewCode({ waitMs, words, onResend }) {
|
|
55
|
+
const [secondsLeft, setSecondsLeft] = useState(Math.ceil(waitMs / 1000));
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const end = Date.now() + waitMs;
|
|
58
|
+
const timer = setInterval(() => {
|
|
59
|
+
const left = Math.max(0, Math.ceil((end - Date.now()) / 1000));
|
|
60
|
+
setSecondsLeft(left);
|
|
61
|
+
if (left === 0)
|
|
62
|
+
clearInterval(timer);
|
|
63
|
+
}, TICK_MS);
|
|
64
|
+
return () => {
|
|
65
|
+
clearInterval(timer);
|
|
66
|
+
};
|
|
67
|
+
}, [waitMs]);
|
|
68
|
+
return (_jsxs(_Fragment, { children: [_jsx(Pill, { label: words.resend, look: "flat", disabled: secondsLeft > 0, onPress: onResend }), secondsLeft > 0 ? (_jsx(Text, { role: "timer", accessibilityLabel: words.resendWait, className: "font-ui text-sm text-ink-secondary", children: resendClock(secondsLeft) })) : null] }));
|
|
69
|
+
}
|
|
70
|
+
export function CodeStep({ words, length, noMatch, ranOut, resendAfterMs = RESEND_AFTER_MS, onSubmit, onResend, onChangeEmail, }) {
|
|
71
|
+
const [code, setCode] = useState('');
|
|
72
|
+
const [focused, setFocused] = useState(false);
|
|
73
|
+
const errorId = useId();
|
|
74
|
+
const error = ranOut ? words.codeRanOut : noMatch ? words.codeNoMatch : null;
|
|
75
|
+
// The box the next digit goes in, or the last once the code is whole.
|
|
76
|
+
const nextBox = Math.min(code.length, length - 1);
|
|
77
|
+
return (_jsxs(View, { className: "w-full max-w-[360px] items-center gap-3 self-center", children: [_jsx(Text, { className: "font-ui text-sm font-medium text-ink", children: words.codeLabel }), _jsxs(View, { className: "relative", children: [_jsx(View, { "aria-hidden": true, className: "flex-row gap-2", children: Array.from({ length }, (_, box) => (_jsx(View, { className: `h-control w-11 items-center justify-center rounded-control border ${focused && box === nextBox ? 'border-focus' : error ? 'border-error-ink' : 'border-line-control'}`, children: _jsx(Text, { className: "font-ui text-xl font-medium text-ink", children: code[box] ?? '' }) }, box))) }), _jsx(TextInput, { autoFocus: true, value: code, onChangeText: (typed) => {
|
|
78
|
+
const digits = codeDigits(typed, length);
|
|
79
|
+
setCode(digits);
|
|
80
|
+
if (completesCode(digits, code, length))
|
|
81
|
+
onSubmit(digits);
|
|
82
|
+
}, onFocus: () => {
|
|
83
|
+
setFocused(true);
|
|
84
|
+
}, onBlur: () => {
|
|
85
|
+
setFocused(false);
|
|
86
|
+
}, accessibilityLabel: words.codeLabel, "aria-invalid": error !== null, ...describedBy(error === null ? undefined : errorId), inputMode: "numeric", autoComplete: "one-time-code", textContentType: "oneTimeCode", caretHidden: true,
|
|
87
|
+
// Unseen, not just clear: the boxes draw the digits and the focus, and a checker would read clear text as
|
|
88
|
+
// text with no contrast.
|
|
89
|
+
className: "absolute inset-0 opacity-0" })] }), error ? (_jsx(Text, { nativeID: errorId, role: "alert", className: "text-center font-ui text-sm text-error-ink", children: error })) : null, _jsxs(View, { className: "flex-row flex-wrap items-center justify-center gap-2", children: [_jsx(Pill, { label: words.changeEmail, look: "flat", onPress: onChangeEmail }), _jsx(NewCode, { waitMs: resendAfterMs, words: words, onResend: onResend }), _jsx(Pill, { label: words.codeSubmit, disabled: code.length < length, onPress: () => {
|
|
90
|
+
onSubmit(code);
|
|
91
|
+
} })] })] }));
|
|
92
|
+
}
|
|
93
|
+
const DOTS = [0, 1, 2];
|
|
94
|
+
const DOT_SIZE = 6;
|
|
95
|
+
// A dot at rest is faint, rises this far at the top of its turn, and holds at the still strength for a visitor who
|
|
96
|
+
// asks for less motion.
|
|
97
|
+
export const DOT_REST_OPACITY = 0.35;
|
|
98
|
+
export const DOT_RISE = 3;
|
|
99
|
+
export const DOT_STILL_OPACITY = 0.6;
|
|
100
|
+
// Null until the device has answered, so the dots never start before a visitor who wants them still is known.
|
|
101
|
+
function useReducedMotion() {
|
|
102
|
+
const [reduced, setReduced] = useState(null);
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
void (async () => {
|
|
105
|
+
setReduced(await AccessibilityInfo.isReduceMotionEnabled());
|
|
106
|
+
})();
|
|
107
|
+
const change = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduced);
|
|
108
|
+
return () => {
|
|
109
|
+
change.remove();
|
|
110
|
+
};
|
|
111
|
+
}, []);
|
|
112
|
+
return reduced;
|
|
113
|
+
}
|
|
114
|
+
export function SkipTyping({ label, skipMark }) {
|
|
115
|
+
const dotColor = useColor('ink-secondary');
|
|
116
|
+
const reduced = useReducedMotion();
|
|
117
|
+
const still = reduced === true;
|
|
118
|
+
// Made once and kept, so render reads no ref.
|
|
119
|
+
const [rise] = useState(() => DOTS.map(() => new Animated.Value(0)));
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (reduced !== false)
|
|
122
|
+
return undefined;
|
|
123
|
+
const step = DOT_CYCLE_MS / (2 * DOTS.length);
|
|
124
|
+
const loop = Animated.loop(Animated.stagger(step, rise.map((value) => Animated.sequence([
|
|
125
|
+
Animated.timing(value, { toValue: 1, duration: step, useNativeDriver: true }),
|
|
126
|
+
Animated.timing(value, { toValue: 0, duration: step, useNativeDriver: true }),
|
|
127
|
+
]))));
|
|
128
|
+
loop.start();
|
|
129
|
+
return () => {
|
|
130
|
+
loop.stop();
|
|
131
|
+
};
|
|
132
|
+
}, [rise, reduced]);
|
|
133
|
+
return (_jsxs(View, { className: "mt-3 flex-row items-end gap-2", children: [_jsx(Image, { source: skipMark, "aria-hidden": true, style: { width: 32, height: 32 }, className: "rounded-control" }), _jsx(View, { role: "status", accessibilityLabel: label, className: "flex-row items-center gap-1 rounded-[20px] rounded-bl-[7px] border border-line-subtle bg-surface px-4 py-4", children: rise.map((value, dot) => (_jsx(Animated.View, { style: {
|
|
134
|
+
width: DOT_SIZE,
|
|
135
|
+
height: DOT_SIZE,
|
|
136
|
+
borderRadius: DOT_SIZE / 2,
|
|
137
|
+
backgroundColor: dotColor,
|
|
138
|
+
...(still
|
|
139
|
+
? { opacity: DOT_STILL_OPACITY }
|
|
140
|
+
: {
|
|
141
|
+
opacity: value.interpolate({ inputRange: [0, 1], outputRange: [DOT_REST_OPACITY, 1] }),
|
|
142
|
+
transform: [{ translateY: value.interpolate({ inputRange: [0, 1], outputRange: [0, -DOT_RISE] }) }],
|
|
143
|
+
}),
|
|
144
|
+
} }, DOTS[dot]))) })] }));
|
|
145
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
/** The icons the chat draws, from the brand's interface icons, which each app already draws. */
|
|
3
|
+
export type ChatIconName = 'attach' | 'arrow-right' | 'file' | 'close';
|
|
4
|
+
export interface ChatIconProps {
|
|
5
|
+
name: ChatIconName;
|
|
6
|
+
color: string;
|
|
7
|
+
size: number;
|
|
8
|
+
}
|
|
9
|
+
export type ChatIcon = ComponentType<ChatIconProps>;
|
|
10
|
+
/** The two reasons an attachment is refused, in the chat's words. */
|
|
11
|
+
export interface AttachmentRefusedWords {
|
|
12
|
+
type: string;
|
|
13
|
+
size: string;
|
|
14
|
+
}
|
|
15
|
+
/** Every word the chat shows, which each app passes in. */
|
|
16
|
+
export interface ChatWords {
|
|
17
|
+
composerPlaceholder: string;
|
|
18
|
+
send: string;
|
|
19
|
+
attach: string;
|
|
20
|
+
remove: string;
|
|
21
|
+
photo: string;
|
|
22
|
+
pdf: string;
|
|
23
|
+
sending: string;
|
|
24
|
+
notSent: string;
|
|
25
|
+
retry: string;
|
|
26
|
+
you: string;
|
|
27
|
+
skip: string;
|
|
28
|
+
emergencyNote: string;
|
|
29
|
+
feedDown: string;
|
|
30
|
+
loading: string;
|
|
31
|
+
loadFailed: string;
|
|
32
|
+
attachmentRefused: AttachmentRefusedWords;
|
|
33
|
+
fileKind: (name: string) => string;
|
|
34
|
+
fileSize: (bytes: number) => string;
|
|
35
|
+
messageTime: (ms: number) => string;
|
|
36
|
+
typingLine: (names: string[]) => string;
|
|
37
|
+
}
|
|
38
|
+
export declare function useColor(token: string): string;
|
package/chat/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { Conversation, type ConversationProps, type LoadState } from './components/Conversation.tsx';
|
|
2
|
+
export type { ChatIcon, ChatIconName, ChatWords } from './components/parts.ts';
|
|
3
|
+
export { ATTACHMENT_TYPES, MAX_ATTACHMENT_BYTES, attachmentProblem, attachmentType } from './attachments.ts';
|
|
4
|
+
export { isSkip, type ChatAttachment, type ChatAuthor, type ChatMessage, type ChoiceItem, type MessageChoice, type PickedFile, } from './message.ts';
|
|
5
|
+
export { CLUSTER_GAP_MS, EMPTY_THREAD, groupThread, pendingId, withFailed, withoutMessage, withPending, withReceived, withReceivedAll, withSent, type Delivery, type RunSender, type Thread, type ThreadItem, type ThreadMessage, } from './thread.ts';
|
|
6
|
+
export { TYPING_CLEAR_MS, TYPING_IDLE_MS, createTypingSignal, createTypingWatch } from './typing.ts';
|
|
7
|
+
export { TYPING_IDLE, TYPING_TIMES, heldFrom, typingReducer, type TypingEvent, type TypingSignal, type TypingStarted, type TypingState, type TypingTimes, } from './skipTyping.ts';
|
|
8
|
+
export { fitVisibleArea, type Fit, type VisualViewportBox } from './visibleArea.ts';
|
|
9
|
+
export { EMAIL_MAX_LENGTH, NAME_MAX_LENGTH, RESEND_AFTER_MS, WELCOME_TYPING_TIMES, givenEmail, givenName, isEmail, isName, typesBefore, typingMs, type WelcomeStep, type WelcomeTypingTimes, } from './welcome.ts';
|
|
10
|
+
export { Choices, CodeStep, EmailStep, NameStep, SkipTyping, WelcomeChoice, type WelcomeWords, } from './components/Welcome.tsx';
|
package/chat/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { Conversation } from "./components/Conversation.js";
|
|
2
|
+
export { ATTACHMENT_TYPES, MAX_ATTACHMENT_BYTES, attachmentProblem, attachmentType } from "./attachments.js";
|
|
3
|
+
export { isSkip, } from "./message.js";
|
|
4
|
+
export { CLUSTER_GAP_MS, EMPTY_THREAD, groupThread, pendingId, withFailed, withoutMessage, withPending, withReceived, withReceivedAll, withSent, } from "./thread.js";
|
|
5
|
+
export { TYPING_CLEAR_MS, TYPING_IDLE_MS, createTypingSignal, createTypingWatch } from "./typing.js";
|
|
6
|
+
export { TYPING_IDLE, TYPING_TIMES, heldFrom, typingReducer, } from "./skipTyping.js";
|
|
7
|
+
export { fitVisibleArea } from "./visibleArea.js";
|
|
8
|
+
export { EMAIL_MAX_LENGTH, NAME_MAX_LENGTH, RESEND_AFTER_MS, WELCOME_TYPING_TIMES, givenEmail, givenName, isEmail, isName, typesBefore, typingMs, } from "./welcome.js";
|
|
9
|
+
export { Choices, CodeStep, EmailStep, NameStep, SkipTyping, WelcomeChoice, } from "./components/Welcome.js";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Token } from 'markdown-it';
|
|
2
|
+
export interface TextInline {
|
|
3
|
+
type: 'text';
|
|
4
|
+
text: string;
|
|
5
|
+
}
|
|
6
|
+
export interface BreakInline {
|
|
7
|
+
type: 'break';
|
|
8
|
+
}
|
|
9
|
+
export interface CodeInline {
|
|
10
|
+
type: 'code';
|
|
11
|
+
text: string;
|
|
12
|
+
}
|
|
13
|
+
export interface MarkInline {
|
|
14
|
+
type: 'strong' | 'em' | 'del';
|
|
15
|
+
children: Inline[];
|
|
16
|
+
}
|
|
17
|
+
export interface LinkInline {
|
|
18
|
+
type: 'link';
|
|
19
|
+
href: string;
|
|
20
|
+
children: Inline[];
|
|
21
|
+
}
|
|
22
|
+
export type Inline = TextInline | BreakInline | CodeInline | MarkInline | LinkInline;
|
|
23
|
+
export interface ParagraphBlock {
|
|
24
|
+
type: 'paragraph';
|
|
25
|
+
children: Inline[];
|
|
26
|
+
}
|
|
27
|
+
export interface ListBlock {
|
|
28
|
+
type: 'list';
|
|
29
|
+
ordered: boolean;
|
|
30
|
+
start: number;
|
|
31
|
+
items: Block[][];
|
|
32
|
+
}
|
|
33
|
+
export interface QuoteBlock {
|
|
34
|
+
type: 'quote';
|
|
35
|
+
children: Block[];
|
|
36
|
+
}
|
|
37
|
+
export interface CodeBlock {
|
|
38
|
+
type: 'code';
|
|
39
|
+
text: string;
|
|
40
|
+
}
|
|
41
|
+
export interface RuleBlock {
|
|
42
|
+
type: 'rule';
|
|
43
|
+
}
|
|
44
|
+
export type Block = ParagraphBlock | ListBlock | QuoteBlock | CodeBlock | RuleBlock;
|
|
45
|
+
export declare const LINK_PROTOCOLS: readonly string[];
|
|
46
|
+
export declare function inlinesOf(token: Token): Inline[];
|
|
47
|
+
export declare function readMarkdown(source: string): Block[];
|