@joinskips/brand 8.0.0 → 9.0.0-develop.147

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/chat/{attachments.d.ts → helpers/attachments.d.ts} +2 -1
  2. package/chat/helpers/index.d.ts +7 -0
  3. package/chat/helpers/index.js +7 -0
  4. package/chat/hooks/index.d.ts +6 -0
  5. package/chat/hooks/index.js +6 -0
  6. package/chat/hooks/useCodeEntry.d.ts +6 -0
  7. package/chat/hooks/useCodeEntry.js +14 -0
  8. package/chat/hooks/useComposer.d.ts +20 -0
  9. package/chat/hooks/useComposer.js +40 -0
  10. package/chat/hooks/useResendCountdown.d.ts +2 -0
  11. package/chat/hooks/useResendCountdown.js +19 -0
  12. package/chat/hooks/useStepField.d.ts +8 -0
  13. package/chat/hooks/useStepField.js +17 -0
  14. package/chat/hooks/useTypingCue.d.ts +11 -0
  15. package/chat/hooks/useTypingCue.js +41 -0
  16. package/chat/hooks/useWelcomeTyping.d.ts +2 -0
  17. package/chat/hooks/useWelcomeTyping.js +25 -0
  18. package/chat/index.d.ts +3 -11
  19. package/chat/index.js +3 -10
  20. package/chat/ui/CheckIn.d.ts +25 -0
  21. package/chat/ui/CheckIn.js +24 -0
  22. package/chat/{components → ui}/Conversation.d.ts +3 -6
  23. package/chat/{components → ui}/Conversation.js +5 -29
  24. package/chat/{components → ui}/Markdown.js +1 -1
  25. package/chat/{components → ui}/Welcome.d.ts +21 -8
  26. package/chat/{components → ui}/Welcome.js +17 -43
  27. package/chat/ui/index.d.ts +5 -0
  28. package/chat/ui/index.js +4 -0
  29. package/chat/{components → ui}/parts.d.ts +1 -1
  30. package/icons/copy-plus.svg +1 -0
  31. package/icons/repeat.svg +1 -0
  32. package/icons/skips-icons.json +42 -0
  33. package/icons/thumbs-up.svg +1 -0
  34. package/manifest.json +15 -3
  35. package/package.json +13 -1
  36. /package/chat/{attachments.js → helpers/attachments.js} +0 -0
  37. /package/chat/{markdown.d.ts → helpers/markdown.d.ts} +0 -0
  38. /package/chat/{markdown.js → helpers/markdown.js} +0 -0
  39. /package/chat/{message.d.ts → helpers/message.d.ts} +0 -0
  40. /package/chat/{message.js → helpers/message.js} +0 -0
  41. /package/chat/{skipTyping.d.ts → helpers/skipTyping.d.ts} +0 -0
  42. /package/chat/{skipTyping.js → helpers/skipTyping.js} +0 -0
  43. /package/chat/{thread.d.ts → helpers/thread.d.ts} +0 -0
  44. /package/chat/{thread.js → helpers/thread.js} +0 -0
  45. /package/chat/{typing.d.ts → helpers/typing.d.ts} +0 -0
  46. /package/chat/{typing.js → helpers/typing.js} +0 -0
  47. /package/chat/{visibleArea.d.ts → helpers/visibleArea.d.ts} +0 -0
  48. /package/chat/{visibleArea.js → helpers/visibleArea.js} +0 -0
  49. /package/chat/{welcome.d.ts → helpers/welcome.d.ts} +0 -0
  50. /package/chat/{welcome.js → helpers/welcome.js} +0 -0
  51. /package/chat/{components → ui}/Bubble.d.ts +0 -0
  52. /package/chat/{components → ui}/Bubble.js +0 -0
  53. /package/chat/{components → ui}/Markdown.d.ts +0 -0
  54. /package/chat/{components → ui}/parts.js +0 -0
@@ -7,5 +7,6 @@ export interface FileFacts {
7
7
  type: string;
8
8
  size: number;
9
9
  }
10
+ export type AttachmentProblem = 'type' | 'size';
10
11
  /** Why a picked file cannot be sent, or null when it can. */
11
- export declare function attachmentProblem(file: FileFacts): 'type' | 'size' | null;
12
+ export declare function attachmentProblem(file: FileFacts): AttachmentProblem | null;
@@ -0,0 +1,7 @@
1
+ export { ATTACHMENT_TYPES, MAX_ATTACHMENT_BYTES, attachmentProblem, attachmentType, type AttachmentProblem } from './attachments.ts';
2
+ export { isSkip, type ChatAttachment, type ChatAuthor, type ChatMessage, type ChoiceItem, type MessageChoice, type PickedFile, } from './message.ts';
3
+ 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';
4
+ export { TYPING_CLEAR_MS, TYPING_IDLE_MS, createTypingSignal, createTypingWatch } from './typing.ts';
5
+ export { DOT_CYCLE_MS, TYPING_IDLE, TYPING_TIMES, heldFrom, typingReducer, type TypingEvent, type TypingSignal, type TypingStarted, type TypingState, type TypingTimes, } from './skipTyping.ts';
6
+ export { fitVisibleArea, type Fit, type VisualViewportBox } from './visibleArea.ts';
7
+ 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';
@@ -0,0 +1,7 @@
1
+ export { ATTACHMENT_TYPES, MAX_ATTACHMENT_BYTES, attachmentProblem, attachmentType } from "./attachments.js";
2
+ export { isSkip, } from "./message.js";
3
+ export { CLUSTER_GAP_MS, EMPTY_THREAD, groupThread, pendingId, withFailed, withoutMessage, withPending, withReceived, withReceivedAll, withSent, } from "./thread.js";
4
+ export { TYPING_CLEAR_MS, TYPING_IDLE_MS, createTypingSignal, createTypingWatch } from "./typing.js";
5
+ export { DOT_CYCLE_MS, TYPING_IDLE, TYPING_TIMES, heldFrom, typingReducer, } from "./skipTyping.js";
6
+ export { fitVisibleArea } from "./visibleArea.js";
7
+ export { EMAIL_MAX_LENGTH, NAME_MAX_LENGTH, RESEND_AFTER_MS, WELCOME_TYPING_TIMES, givenEmail, givenName, isEmail, isName, typesBefore, typingMs, } from "./welcome.js";
@@ -0,0 +1,6 @@
1
+ export { useCodeEntry, type CodeEntry } from './useCodeEntry.ts';
2
+ export { useComposer, type Composer, type ComposerOptions } from './useComposer.ts';
3
+ export { useResendCountdown } from './useResendCountdown.ts';
4
+ export { useStepField, type StepField } from './useStepField.ts';
5
+ export { useTypingCue, type SkipsTyping, type TypingCue } from './useTypingCue.ts';
6
+ export { useWelcomeTyping } from './useWelcomeTyping.ts';
@@ -0,0 +1,6 @@
1
+ export { useCodeEntry } from "./useCodeEntry.js";
2
+ export { useComposer } from "./useComposer.js";
3
+ export { useResendCountdown } from "./useResendCountdown.js";
4
+ export { useStepField } from "./useStepField.js";
5
+ export { useTypingCue } from "./useTypingCue.js";
6
+ export { useWelcomeTyping } from "./useWelcomeTyping.js";
@@ -0,0 +1,6 @@
1
+ /** The emailed code as it is typed: digits only, handed on by itself once it is whole. */
2
+ export interface CodeEntry {
3
+ code: string;
4
+ onChangeText: (typed: string) => void;
5
+ }
6
+ export declare function useCodeEntry(length: number, onSubmit: (code: string) => void): CodeEntry;
@@ -0,0 +1,14 @@
1
+ import { useState } from 'react';
2
+ import { codeDigits, completesCode } from "../helpers/welcome.js";
3
+ export function useCodeEntry(length, onSubmit) {
4
+ const [code, setCode] = useState('');
5
+ return {
6
+ code,
7
+ onChangeText: (typed) => {
8
+ const digits = codeDigits(typed, length);
9
+ setCode(digits);
10
+ if (completesCode(digits, code, length))
11
+ onSubmit(digits);
12
+ },
13
+ };
14
+ }
@@ -0,0 +1,20 @@
1
+ import { type AttachmentProblem } from '../helpers/attachments.ts';
2
+ import type { PickedFile } from '../helpers/message.ts';
3
+ export interface ComposerOptions {
4
+ onSend: (text: string, file: PickedFile | null) => void;
5
+ onTyped: (text: string) => void;
6
+ pickFile?: () => Promise<PickedFile | null>;
7
+ initialText?: string;
8
+ }
9
+ /** The message box's state and what each control does, which Conversation draws. */
10
+ export interface Composer {
11
+ text: string;
12
+ picked: PickedFile | null;
13
+ refused: AttachmentProblem | null;
14
+ canSend: boolean;
15
+ onChangeText: (text: string) => void;
16
+ attach: (() => void) | null;
17
+ remove: () => void;
18
+ submit: () => void;
19
+ }
20
+ export declare function useComposer({ onSend, onTyped, pickFile, initialText }: ComposerOptions): Composer;
@@ -0,0 +1,40 @@
1
+ import { useState } from 'react';
2
+ import { attachmentProblem } from "../helpers/attachments.js";
3
+ export function useComposer({ onSend, onTyped, pickFile, initialText = '' }) {
4
+ const [text, setText] = useState(initialText);
5
+ const [picked, setPicked] = useState(null);
6
+ const [refused, setRefused] = useState(null);
7
+ const attach = async (pick) => {
8
+ const file = await pick();
9
+ if (!file)
10
+ return;
11
+ const problem = attachmentProblem(file);
12
+ setRefused(problem);
13
+ if (!problem)
14
+ setPicked(file);
15
+ };
16
+ return {
17
+ text,
18
+ picked,
19
+ refused,
20
+ canSend: Boolean(text.trim() || picked),
21
+ onChangeText: (value) => {
22
+ setText(value);
23
+ onTyped(value);
24
+ },
25
+ attach: pickFile
26
+ ? () => {
27
+ void attach(pickFile);
28
+ }
29
+ : null,
30
+ remove: () => {
31
+ setPicked(null);
32
+ },
33
+ submit: () => {
34
+ onSend(text, picked);
35
+ setText('');
36
+ setPicked(null);
37
+ setRefused(null);
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,2 @@
1
+ /** Whole seconds left before a new code can be sent, counting down to 0 from when the wait starts. */
2
+ export declare function useResendCountdown(waitMs: number): number;
@@ -0,0 +1,19 @@
1
+ import { useEffect, useState } from 'react';
2
+ const TICK_MS = 250;
3
+ /** Whole seconds left before a new code can be sent, counting down to 0 from when the wait starts. */
4
+ export function useResendCountdown(waitMs) {
5
+ const [secondsLeft, setSecondsLeft] = useState(Math.ceil(waitMs / 1000));
6
+ useEffect(() => {
7
+ const end = Date.now() + waitMs;
8
+ const timer = setInterval(() => {
9
+ const left = Math.max(0, Math.ceil((end - Date.now()) / 1000));
10
+ setSecondsLeft(left);
11
+ if (left === 0)
12
+ clearInterval(timer);
13
+ }, TICK_MS);
14
+ return () => {
15
+ clearInterval(timer);
16
+ };
17
+ }, [waitMs]);
18
+ return secondsLeft;
19
+ }
@@ -0,0 +1,8 @@
1
+ /** A welcome step's field: what was typed, whether it was refused, and its change and submit. */
2
+ export interface StepField {
3
+ typed: string;
4
+ refused: boolean;
5
+ onChangeText: (typed: string) => void;
6
+ submit: () => void;
7
+ }
8
+ export declare function useStepField(usable: (typed: string) => string | null, onSubmit: (value: string) => void, initial?: string): StepField;
@@ -0,0 +1,17 @@
1
+ import { useState } from 'react';
2
+ // usable turns what was typed into what is handed on, or null when it cannot be used, as givenEmail and givenName do.
3
+ export function useStepField(usable, onSubmit, initial = '') {
4
+ const [typed, setTyped] = useState(initial);
5
+ const [refused, setRefused] = useState(false);
6
+ return {
7
+ typed,
8
+ refused,
9
+ onChangeText: setTyped,
10
+ submit: () => {
11
+ const value = usable(typed);
12
+ setRefused(value === null);
13
+ if (value !== null)
14
+ onSubmit(value);
15
+ },
16
+ };
17
+ }
@@ -0,0 +1,11 @@
1
+ import { type TypingTimes } from '../helpers/skipTyping.ts';
2
+ /** The Skips App's last word on Skip typing. */
3
+ export interface SkipsTyping {
4
+ typing: boolean;
5
+ count: number;
6
+ }
7
+ export interface TypingCue {
8
+ dots: boolean;
9
+ held: ReadonlySet<number>;
10
+ }
11
+ export declare function useTypingCue(skipsLineIds: readonly number[], skipsTyping: SkipsTyping, times?: TypingTimes): TypingCue;
@@ -0,0 +1,41 @@
1
+ import { useEffect, useReducer, useState } from 'react';
2
+ import { heldFrom, TYPING_IDLE, TYPING_TIMES, typingReducer } from "../helpers/skipTyping.js";
3
+ const NONE = new Set();
4
+ // The Skips App's word on Skip typing and Skip's lines landing drive the cue, and two timers keep the dots up for
5
+ // their minimum and stop them at the limit. skipsLineIds are Skip's lines in the conversation, in order.
6
+ export function useTypingCue(skipsLineIds, skipsTyping, times = TYPING_TIMES) {
7
+ const [state, dispatch] = useReducer(typingReducer, TYPING_IDLE);
8
+ const [wordsSeen, setWordsSeen] = useState(skipsTyping.count);
9
+ const [linesSeen, setLinesSeen] = useState(skipsLineIds.length);
10
+ if (skipsTyping.count !== wordsSeen) {
11
+ setWordsSeen(skipsTyping.count);
12
+ // The lines before this render: a reply that lands with typing_on, as one sent in a keystroke does, waits for the dots.
13
+ dispatch(skipsTyping.typing ? { type: 'typing', skipsLines: linesSeen } : { type: 'stopped' });
14
+ }
15
+ if (skipsLineIds.length !== linesSeen) {
16
+ setLinesSeen(skipsLineIds.length);
17
+ dispatch({ type: 'stopped' });
18
+ }
19
+ useEffect(() => {
20
+ if (!state.dots || state.shown)
21
+ return undefined;
22
+ const shown = setTimeout(() => {
23
+ dispatch({ type: 'shown' });
24
+ }, times.leastMs);
25
+ return () => {
26
+ clearTimeout(shown);
27
+ };
28
+ }, [state.dots, state.shown, state.turn, times.leastMs]);
29
+ useEffect(() => {
30
+ if (!state.dots)
31
+ return undefined;
32
+ const capped = setTimeout(() => {
33
+ dispatch({ type: 'capped' });
34
+ }, times.limitMs);
35
+ return () => {
36
+ clearTimeout(capped);
37
+ };
38
+ }, [state.dots, state.beats, times.limitMs]);
39
+ const from = heldFrom(state);
40
+ return { dots: state.dots, held: from === null ? NONE : new Set(skipsLineIds.slice(from)) };
41
+ }
@@ -0,0 +1,2 @@
1
+ import { type WelcomeStep, type WelcomeTypingTimes } from '../helpers/welcome.ts';
2
+ export declare function useWelcomeTyping(step: WelcomeStep | null, times?: WelcomeTypingTimes): WelcomeStep | null;
@@ -0,0 +1,25 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { typesBefore, typingMs, WELCOME_TYPING_TIMES, } from "../helpers/welcome.js";
3
+ // The welcome step whose new line Skip is still typing, kept out of sight with its field or buttons until the
4
+ // dots go, or null.
5
+ export function useWelcomeTyping(step, times = WELCOME_TYPING_TIMES) {
6
+ const [seen, setSeen] = useState(null);
7
+ const [typing, setTyping] = useState(null);
8
+ const { leastMs, mostMs } = times;
9
+ if (step !== seen) {
10
+ setSeen(step);
11
+ // With no time to type in, the line shows as the step is reached.
12
+ setTyping(step !== null && mostMs > 0 && typesBefore(seen, step) ? step : null);
13
+ }
14
+ useEffect(() => {
15
+ if (typing === null)
16
+ return undefined;
17
+ const typed = setTimeout(() => {
18
+ setTyping(null);
19
+ }, typingMs({ leastMs, mostMs }, Math.random()));
20
+ return () => {
21
+ clearTimeout(typed);
22
+ };
23
+ }, [typing, leastMs, mostMs]);
24
+ return typing;
25
+ }
package/chat/index.d.ts CHANGED
@@ -1,11 +1,3 @@
1
- export { Conversation, type ConversationProps, type LoadState } from './components/Conversation.tsx';
2
- export { Bubble, type BubbleProps } from './components/Bubble.tsx';
3
- export type { ChatIcon, ChatIconName, ChatWords } from './components/parts.ts';
4
- export { ATTACHMENT_TYPES, MAX_ATTACHMENT_BYTES, attachmentProblem, attachmentType } from './attachments.ts';
5
- export { isSkip, type ChatAttachment, type ChatAuthor, type ChatMessage, type ChoiceItem, type MessageChoice, type PickedFile, } from './message.ts';
6
- 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';
7
- export { TYPING_CLEAR_MS, TYPING_IDLE_MS, createTypingSignal, createTypingWatch } from './typing.ts';
8
- export { TYPING_IDLE, TYPING_TIMES, heldFrom, typingReducer, type TypingEvent, type TypingSignal, type TypingStarted, type TypingState, type TypingTimes, } from './skipTyping.ts';
9
- export { fitVisibleArea, type Fit, type VisualViewportBox } from './visibleArea.ts';
10
- 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';
11
- export { Choices, CodeStep, EmailStep, NameStep, SkipTyping, WelcomeChoice, type WelcomeWords, } from './components/Welcome.tsx';
1
+ export * from './ui/index.ts';
2
+ export * from './hooks/index.ts';
3
+ export * from './helpers/index.ts';
package/chat/index.js CHANGED
@@ -1,10 +1,3 @@
1
- export { Conversation } from "./components/Conversation.js";
2
- export { Bubble } from "./components/Bubble.js";
3
- export { ATTACHMENT_TYPES, MAX_ATTACHMENT_BYTES, attachmentProblem, attachmentType } from "./attachments.js";
4
- export { isSkip, } from "./message.js";
5
- export { CLUSTER_GAP_MS, EMPTY_THREAD, groupThread, pendingId, withFailed, withoutMessage, withPending, withReceived, withReceivedAll, withSent, } from "./thread.js";
6
- export { TYPING_CLEAR_MS, TYPING_IDLE_MS, createTypingSignal, createTypingWatch } from "./typing.js";
7
- export { TYPING_IDLE, TYPING_TIMES, heldFrom, typingReducer, } from "./skipTyping.js";
8
- export { fitVisibleArea } from "./visibleArea.js";
9
- export { EMAIL_MAX_LENGTH, NAME_MAX_LENGTH, RESEND_AFTER_MS, WELCOME_TYPING_TIMES, givenEmail, givenName, isEmail, isName, typesBefore, typingMs, } from "./welcome.js";
10
- export { Choices, CodeStep, EmailStep, NameStep, SkipTyping, WelcomeChoice, } from "./components/Welcome.js";
1
+ export * from "./ui/index.js";
2
+ export * from "./hooks/index.js";
3
+ export * from "./helpers/index.js";
@@ -0,0 +1,25 @@
1
+ import { type ChatIcon } from './parts.ts';
2
+ /** What a person says about one of their current medications. */
3
+ export type MedicationAnswer = 'taking' | 'refill' | 'reorder' | 'stopped';
4
+ /** Every word the medication check-in shows, which the app passes in. */
5
+ export interface CheckInWords {
6
+ taking: string;
7
+ refill: string;
8
+ reorder: string;
9
+ stopped: string;
10
+ whyStopped: string;
11
+ }
12
+ export declare const STOPPED_REASON_MAX_LENGTH = 500;
13
+ export interface MedicationCheckProps {
14
+ name: string;
15
+ directions: string;
16
+ refillable: boolean;
17
+ answer: MedicationAnswer | null;
18
+ reason: string;
19
+ words: CheckInWords;
20
+ Icon: ChatIcon;
21
+ onAnswer: (answer: MedicationAnswer) => void;
22
+ onReason: (reason: string) => void;
23
+ }
24
+ /** One current medication in the check-in: still taking, a refill or re-order, or stopped and why. */
25
+ export declare function MedicationCheck({ name, directions, refillable, answer, reason, words, Icon, onAnswer, onReason, }: MedicationCheckProps): import("react").JSX.Element;
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Pressable, Text, TextInput, View } from 'react-native';
3
+ import { useColor } from "./parts.js";
4
+ export const STOPPED_REASON_MAX_LENGTH = 500;
5
+ const ICONS = {
6
+ taking: 'thumbs-up',
7
+ refill: 'repeat',
8
+ reorder: 'copy-plus',
9
+ stopped: 'close',
10
+ };
11
+ const ICON_SIZE = 24;
12
+ /** One current medication in the check-in: still taking, a refill or re-order, or stopped and why. */
13
+ export function MedicationCheck({ name, directions, refillable, answer, reason, words, Icon, onAnswer, onReason, }) {
14
+ const ink = useColor('ink');
15
+ const accentInk = useColor('accent-ink');
16
+ const placeholder = useColor('ink-placeholder');
17
+ const answers = ['taking', refillable ? 'refill' : 'reorder', 'stopped'];
18
+ return (_jsxs(View, { className: "w-80 max-w-full gap-3.5 rounded-[20px] border border-line-subtle bg-surface p-4", children: [_jsxs(View, { className: "gap-0.5", children: [_jsx(Text, { className: "font-ui text-md font-semibold text-ink", children: name }), _jsx(Text, { className: "font-ui text-sm text-ink-secondary", children: directions })] }), _jsx(View, { role: "radiogroup", accessibilityLabel: name, className: "flex-row", children: answers.map((each) => {
19
+ const picked = answer === each;
20
+ return (_jsxs(Pressable, { role: "radio", "aria-checked": picked, accessibilityLabel: words[each], onPress: () => {
21
+ onAnswer(each);
22
+ }, className: "flex-1 items-center gap-1.5", children: [_jsx(View, { className: `h-[52px] w-[52px] items-center justify-center rounded-pill border ${picked ? 'border-accent bg-accent' : 'border-line-control bg-surface'}`, children: _jsx(Icon, { name: ICONS[each], color: picked ? accentInk : ink, size: ICON_SIZE }) }), _jsx(Text, { className: "text-center font-ui text-xs font-semibold text-ink", children: words[each] })] }, each));
23
+ }) }), answer === 'stopped' ? (_jsx(TextInput, { value: reason, onChangeText: onReason, accessibilityLabel: words.whyStopped, placeholder: words.whyStopped, placeholderTextColor: placeholder, multiline: true, maxLength: STOPPED_REASON_MAX_LENGTH, className: "min-h-20 rounded-control border border-line-control bg-surface px-3 py-2 font-ui text-md text-ink" })) : null] }));
24
+ }
@@ -1,6 +1,6 @@
1
1
  import { type ImageSourcePropType } from 'react-native';
2
- import type { PickedFile } from '../message.ts';
3
- import { type Thread } from '../thread.ts';
2
+ import type { Composer } from '../hooks/useComposer.ts';
3
+ import { type Thread } from '../helpers/thread.ts';
4
4
  import { type ChatIcon, type ChatWords } from './parts.ts';
5
5
  export type LoadState = 'loading' | 'ready' | 'failed';
6
6
  export interface ConversationProps {
@@ -9,13 +9,10 @@ export interface ConversationProps {
9
9
  feedDown: boolean;
10
10
  typists: string[];
11
11
  skipUserId: number | null;
12
- onSend: (text: string, file: PickedFile | null) => void;
12
+ composer: Composer;
13
13
  onRetry: (id: number) => void;
14
- onTyped: (text: string) => void;
15
14
  onLoadOlder: () => void;
16
15
  onReload: () => void;
17
- pickFile?: () => Promise<PickedFile | null>;
18
- initialText?: string;
19
16
  keyboardOffset?: number;
20
17
  bottomInset?: number;
21
18
  words: ChatWords;
@@ -1,8 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
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";
4
+ import { groupThread } from "../helpers/thread.js";
6
5
  import { Bubble } from "./Bubble.js";
7
6
  import { useColor } from "./parts.js";
8
7
  // An image from require needs its size in style: the web draws it at its own size otherwise.
@@ -20,42 +19,19 @@ export function Conversation(props) {
20
19
  const secondary = useColor('ink-secondary');
21
20
  // The list is inverted so it rests at the newest message and stays put while the member reads older ones.
22
21
  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 })] }));
22
+ 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(ComposerBox, { ...props })] }));
24
23
  }
25
24
  function itemKey(item) {
26
25
  if (item.kind === 'run')
27
26
  return `r${String(item.messages[0]?.id)}`;
28
27
  return `${item.kind === 'notice' ? 'n' : 'u'}${String(item.message.id)}`;
29
28
  }
30
- function Composer({ onSend, onTyped, pickFile, initialText, bottomInset, words, Icon }) {
29
+ function ComposerBox({ composer, bottomInset, words, Icon }) {
31
30
  const secondary = useColor('ink-secondary');
32
31
  const placeholder = useColor('ink-placeholder');
33
32
  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 })] }));
33
+ const { text, picked, refused, canSend } = composer;
34
+ 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: words.attachmentRefused[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: composer.remove, 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: [composer.attach ? (_jsx(Pressable, { role: "button", accessibilityLabel: words.attach, onPress: composer.attach, 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: composer.onChangeText, 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: composer.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
35
  }
60
36
  function ThreadRow({ item, words, onRetry, ...props }) {
61
37
  if (item.kind === 'notice') {
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Linking, Platform, Text, View } from 'react-native';
3
- import { readMarkdown } from "../markdown.js";
3
+ import { readMarkdown } from "../helpers/markdown.js";
4
4
  // On the web a link opens in a new tab, as the website's own links do, telling the other site nothing.
5
5
  export const NEW_TAB = { target: '_blank', rel: 'noopener noreferrer nofollow' };
6
6
  function Inlines({ nodes }) {
@@ -1,5 +1,7 @@
1
1
  import { type ImageSourcePropType } from 'react-native';
2
- import type { ChoiceItem } from '../message.ts';
2
+ import type { ChoiceItem } from '../helpers/message.ts';
3
+ import type { CodeEntry } from '../hooks/useCodeEntry.ts';
4
+ import type { StepField as StepFieldState } from '../hooks/useStepField.ts';
3
5
  /** Every word the website's welcome shows, which the website passes in. */
4
6
  export interface WelcomeWords {
5
7
  logIn: string;
@@ -20,6 +22,16 @@ export interface WelcomeWords {
20
22
  codeRanOut: string;
21
23
  typing: string;
22
24
  }
25
+ type PillLook = 'solid' | 'stroked' | 'flat';
26
+ export interface PillProps {
27
+ label: string;
28
+ look?: PillLook;
29
+ disabled?: boolean;
30
+ loading?: boolean;
31
+ onPress: () => void;
32
+ }
33
+ /** The chat's button, in the brand's pill. */
34
+ export declare function Pill({ label, look, disabled, loading, onPress, }: PillProps): import("react").JSX.Element;
23
35
  export interface WelcomeChoiceProps {
24
36
  words: WelcomeWords;
25
37
  onLogIn: () => void;
@@ -40,18 +52,17 @@ export interface ChoicesProps {
40
52
  export declare function Choices({ items, question, sending, holding, failed, words, onChoose, }: ChoicesProps): import("react").JSX.Element;
41
53
  export interface EmailStepProps {
42
54
  words: WelcomeWords;
43
- onSubmit: (email: string) => void;
44
- initial?: string;
55
+ field: StepFieldState;
45
56
  }
46
57
  /** 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;
58
+ export declare function EmailStep({ words, field }: EmailStepProps): import("react").JSX.Element;
48
59
  export interface NameStepProps {
49
60
  words: WelcomeWords;
50
- onSubmit: (name: string) => void;
61
+ field: StepFieldState;
51
62
  onChangeEmail: () => void;
52
63
  }
53
64
  /** 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;
65
+ export declare function NameStep({ words, field, onChangeEmail }: NameStepProps): import("react").JSX.Element;
55
66
  /**
56
67
  * The code emailed to the visitor, one digit to a box over one real field, so typing, deleting, a paste and the
57
68
  * phone's own code suggestion work as in any field. The chat mounts it afresh for each code that goes out.
@@ -61,12 +72,13 @@ export interface CodeStepProps {
61
72
  length: number;
62
73
  noMatch: boolean;
63
74
  ranOut: boolean;
64
- resendAfterMs?: number;
75
+ entry: CodeEntry;
76
+ secondsLeft: number;
65
77
  onSubmit: (code: string) => void;
66
78
  onResend: () => void;
67
79
  onChangeEmail: () => void;
68
80
  }
69
- export declare function CodeStep({ words, length, noMatch, ranOut, resendAfterMs, onSubmit, onResend, onChangeEmail, }: CodeStepProps): import("react").JSX.Element;
81
+ export declare function CodeStep({ words, length, noMatch, ranOut, entry, secondsLeft, onSubmit, onResend, onChangeEmail, }: CodeStepProps): import("react").JSX.Element;
70
82
  export declare const DOT_REST_OPACITY = 0.35;
71
83
  export declare const DOT_RISE = 3;
72
84
  export declare const DOT_STILL_OPACITY = 0.6;
@@ -76,3 +88,4 @@ export interface SkipTypingProps {
76
88
  skipMark: ImageSourcePropType;
77
89
  }
78
90
  export declare function SkipTyping({ label, skipMark }: SkipTypingProps): import("react").JSX.Element;
91
+ export {};
@@ -1,15 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useId, useState } from 'react';
3
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";
4
+ import { DOT_CYCLE_MS } from "../helpers/skipTyping.js";
5
+ import { EMAIL_MAX_LENGTH, NAME_MAX_LENGTH, resendClock } from "../helpers/welcome.js";
6
6
  import { useColor } from "./parts.js";
7
7
  const PILL_LOOK = {
8
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' },
9
+ stroked: { box: 'border border-line-control bg-surface active:bg-muted', text: 'text-ink' },
10
10
  flat: { box: 'active:bg-muted', text: 'text-ink' },
11
11
  };
12
- function Pill({ label, look = 'solid', disabled = false, loading = false, onPress, }) {
12
+ /** The chat's button, in the brand's pill. */
13
+ export function Pill({ label, look = 'solid', disabled = false, loading = false, onPress, }) {
13
14
  const ink = useColor(look === 'solid' ? 'action-ink' : 'ink');
14
15
  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
  }
@@ -28,58 +29,31 @@ export function Choices({ items, question, sending, holding = false, failed, wor
28
29
  function describedBy(errorId) {
29
30
  return errorId === undefined ? {} : { 'aria-describedby': errorId };
30
31
  }
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);
32
+ // One thing asked of a new visitor: a field in focus and its button.
33
+ function StepField({ label, error, submitLabel, input, field, aside }) {
35
34
  const errorId = useId();
36
35
  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: [_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, aside ? (_jsx(View, { className: "self-center", children: _jsx(Pill, { label: aside.label, look: "flat", onPress: aside.onPress }) })) : null, _jsx(View, { className: "self-center", children: _jsx(Pill, { label: submitLabel, onPress: submit }) })] }));
36
+ return (_jsxs(View, { className: "w-full max-w-[360px] gap-2 self-center", children: [_jsx(Text, { className: "font-ui text-sm font-medium text-ink", children: label }), _jsx(TextInput, { autoFocus: true, value: field.typed, onChangeText: field.onChangeText, onSubmitEditing: field.submit, accessibilityLabel: label, "aria-invalid": field.refused, ...describedBy(field.refused ? errorId : undefined), placeholderTextColor: placeholder, className: `h-control rounded-control border bg-surface px-3 font-ui text-md text-ink ${field.refused ? 'border-error-ink' : 'border-line-control'}`, ...input }), field.refused ? (_jsx(Text, { nativeID: errorId, role: "alert", className: "font-ui text-sm text-error-ink", children: error })) : null, aside ? (_jsx(View, { className: "self-center", children: _jsx(Pill, { label: aside.label, look: "flat", onPress: aside.onPress }) })) : null, _jsx(View, { className: "self-center", children: _jsx(Pill, { label: submitLabel, onPress: field.submit }) })] }));
44
37
  }
45
38
  /** 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 }) }));
39
+ export function EmailStep({ words, field }) {
40
+ return (_jsx(StepField, { label: words.emailLabel, error: words.emailError, submitLabel: words.emailSubmit, input: { autoComplete: 'email', inputMode: 'email', maxLength: EMAIL_MAX_LENGTH, autoCapitalize: 'none' }, field: field }));
48
41
  }
49
42
  /** 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 } }));
43
+ export function NameStep({ words, field, onChangeEmail }) {
44
+ return (_jsx(StepField, { label: words.nameLabel, error: words.nameError, submitLabel: words.nameSubmit, input: { autoComplete: 'name', maxLength: NAME_MAX_LENGTH, autoCapitalize: 'words' }, field: field, aside: { label: words.changeEmail, onPress: onChangeEmail } }));
52
45
  }
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]);
46
+ function NewCode({ secondsLeft, words, onResend }) {
68
47
  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
48
  }
70
- export function CodeStep({ words, length, noMatch, ranOut, resendAfterMs = RESEND_AFTER_MS, onSubmit, onResend, onChangeEmail, }) {
71
- const [code, setCode] = useState('');
49
+ export function CodeStep({ words, length, noMatch, ranOut, entry, secondsLeft, onSubmit, onResend, onChangeEmail, }) {
50
+ const { code } = entry;
72
51
  const [focused, setFocused] = useState(false);
73
52
  const errorId = useId();
74
53
  const error = ranOut ? words.codeRanOut : noMatch ? words.codeNoMatch : null;
75
54
  // The box the next digit goes in, or the last once the code is whole.
76
55
  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: () => {
56
+ 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: entry.onChangeText, onFocus: () => {
83
57
  setFocused(true);
84
58
  }, onBlur: () => {
85
59
  setFocused(false);
@@ -87,7 +61,7 @@ export function CodeStep({ words, length, noMatch, ranOut, resendAfterMs = RESEN
87
61
  // The boxes draw the digits and the focus. Clear on iOS, which hands no taps to a view under 0.01 alpha.
88
62
  // Unseen elsewhere: Android would draw a long press's selection over the boxes, and a web checker would read
89
63
  // clear text as text with no contrast.
90
- className: "absolute inset-0 ios:text-transparent android:opacity-0 web: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: () => {
64
+ className: "absolute inset-0 ios:text-transparent android:opacity-0 web: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, { secondsLeft: secondsLeft, words: words, onResend: onResend }), _jsx(Pill, { label: words.codeSubmit, disabled: code.length < length, onPress: () => {
91
65
  onSubmit(code);
92
66
  } })] })] }));
93
67
  }
@@ -0,0 +1,5 @@
1
+ export { Conversation, type ConversationProps, type LoadState } from './Conversation.tsx';
2
+ export { Bubble, type BubbleProps } from './Bubble.tsx';
3
+ export type { ChatIcon, ChatIconName, ChatWords } from './parts.ts';
4
+ export { Choices, CodeStep, EmailStep, NameStep, Pill, SkipTyping, WelcomeChoice, type PillProps, type WelcomeWords, } from './Welcome.tsx';
5
+ export { MedicationCheck, STOPPED_REASON_MAX_LENGTH, type CheckInWords, type MedicationAnswer, type MedicationCheckProps, } from './CheckIn.tsx';
@@ -0,0 +1,4 @@
1
+ export { Conversation } from "./Conversation.js";
2
+ export { Bubble } from "./Bubble.js";
3
+ export { Choices, CodeStep, EmailStep, NameStep, Pill, SkipTyping, WelcomeChoice, } from "./Welcome.js";
4
+ export { MedicationCheck, STOPPED_REASON_MAX_LENGTH, } from "./CheckIn.js";
@@ -1,6 +1,6 @@
1
1
  import type { ComponentType } from 'react';
2
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';
3
+ export type ChatIconName = 'attach' | 'arrow-right' | 'file' | 'close' | 'thumbs-up' | 'repeat' | 'copy-plus';
4
4
  export interface ChatIconProps {
5
5
  name: ChatIconName;
6
6
  color: string;
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 12V18M12 15H18"/><path d="M10 8h10a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2z"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/></svg>
@@ -192,6 +192,20 @@
192
192
  }
193
193
  ]
194
194
  },
195
+ "copy-plus": {
196
+ "viewBox": "0 0 24 24",
197
+ "paths": [
198
+ {
199
+ "d": "M15 12V18M12 15H18"
200
+ },
201
+ {
202
+ "d": "M10 8h10a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2z"
203
+ },
204
+ {
205
+ "d": "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
206
+ }
207
+ ]
208
+ },
195
209
  "cross": {
196
210
  "viewBox": "0 0 24 24",
197
211
  "paths": [
@@ -414,6 +428,23 @@
414
428
  }
415
429
  ]
416
430
  },
431
+ "repeat": {
432
+ "viewBox": "0 0 24 24",
433
+ "paths": [
434
+ {
435
+ "d": "m17 2 4 4-4 4"
436
+ },
437
+ {
438
+ "d": "M3 11v-1a4 4 0 0 1 4-4h14"
439
+ },
440
+ {
441
+ "d": "m7 22-4-4 4-4"
442
+ },
443
+ {
444
+ "d": "M21 13v1a4 4 0 0 1-4 4H3"
445
+ }
446
+ ]
447
+ },
417
448
  "search": {
418
449
  "viewBox": "0 0 24 24",
419
450
  "paths": [
@@ -514,6 +545,17 @@
514
545
  }
515
546
  ]
516
547
  },
548
+ "thumbs-up": {
549
+ "viewBox": "0 0 24 24",
550
+ "paths": [
551
+ {
552
+ "d": "M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"
553
+ },
554
+ {
555
+ "d": "M7 10v12"
556
+ }
557
+ ]
558
+ },
517
559
  "trending": {
518
560
  "viewBox": "0 0 24 24",
519
561
  "paths": [
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/><path d="M7 10v12"/></svg>
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": 8,
2
+ "version": 9,
3
3
  "files": {
4
4
  "app-icons/adaptive-foreground-1024.png": {
5
5
  "sha256": "1cfdc826654866b7bd1e4f549417f9f1918a66d5d8a0133dafbfd3d75d5ecc5f",
@@ -229,6 +229,10 @@
229
229
  "sha256": "c32290c528db58e780e716b3808e26a577b360690d4ebd8a4dfc3974da3f6c18",
230
230
  "bytes": 197
231
231
  },
232
+ "icons/copy-plus.svg": {
233
+ "sha256": "c8e3f2a8d9211a5c7607a40df2281d0e677d6dc3a9278182a0b42783f29b3b50",
234
+ "bytes": 354
235
+ },
232
236
  "icons/copy.svg": {
233
237
  "sha256": "457583ce507f99b808a4abcad3b2c219666cbe9cfd17b73e2a42b20c7d7d8903",
234
238
  "bytes": 203
@@ -325,6 +329,10 @@
325
329
  "sha256": "1c6ac080e8d7b8ef0b15ef946183584ad298927cb83edb856c9bbf0079421420",
326
330
  "bytes": 193
327
331
  },
332
+ "icons/repeat.svg": {
333
+ "sha256": "f6619910da0ad09a068c998699d01fe1126757cf3bd1b2702057ef2dcc19ce20",
334
+ "bytes": 288
335
+ },
328
336
  "icons/search.svg": {
329
337
  "sha256": "cc63462e59472d8af56ae72eda34609992031be03952951767a06a351287fa0e",
330
338
  "bytes": 247
@@ -346,8 +354,8 @@
346
354
  "bytes": 246
347
355
  },
348
356
  "icons/skips-icons.json": {
349
- "sha256": "ef0d361e9d2a1b515aaf7bfc1ac7ef1500b8988764e4a3bc063c520907f7424a",
350
- "bytes": 12241
357
+ "sha256": "2422b653aad88608e41dcb6f2c4c3d8fefb7274f65e6715e67ba072b1f44d39b",
358
+ "bytes": 13217
351
359
  },
352
360
  "icons/sparkle.svg": {
353
361
  "sha256": "726a63259dd94e02a6dc019b6ca6ab63f200843d2dc2561e2bea829bd1d7df45",
@@ -365,6 +373,10 @@
365
373
  "sha256": "cf8a8f6ae843bf2cf3b3f7b274c7847fe01df083ae20c70a40b8000c11cb6624",
366
374
  "bytes": 326
367
375
  },
376
+ "icons/thumbs-up.svg": {
377
+ "sha256": "5219afc2aa3d3bb5436e8c77cdea80c411de15c8331b2edca9e572efe3f3babb",
378
+ "bytes": 349
379
+ },
368
380
  "icons/trending.svg": {
369
381
  "sha256": "e7bccc40f51f4566fac8dfd11f1b6468d1a3cce39a1206b59cee4df44eb0e6c6",
370
382
  "bytes": 209
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joinskips/brand",
3
- "version": "8.0.0",
3
+ "version": "9.0.0-develop.147",
4
4
  "description": "Skips design tokens, Onest, logos, favicons, interface icons, member pictures and app icons.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://brand.joinskips.com",
@@ -13,6 +13,18 @@
13
13
  "types": "./chat/index.d.ts",
14
14
  "default": "./chat/index.js"
15
15
  },
16
+ "./chat/ui": {
17
+ "types": "./chat/ui/index.d.ts",
18
+ "default": "./chat/ui/index.js"
19
+ },
20
+ "./chat/hooks": {
21
+ "types": "./chat/hooks/index.d.ts",
22
+ "default": "./chat/hooks/index.js"
23
+ },
24
+ "./chat/helpers": {
25
+ "types": "./chat/helpers/index.d.ts",
26
+ "default": "./chat/helpers/index.js"
27
+ },
16
28
  "./ui": {
17
29
  "types": "./ui/index.d.ts",
18
30
  "default": "./ui/index.js"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes