ink-framework 0.0.0-stage → 0.1.0-alpha.1
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/LICENSE +21 -0
- package/ink-native.json +33 -0
- package/package.json +37 -4
- package/runtime.d.ts +81 -0
- package/src/action.ts +49 -0
- package/src/appearance.ts +24 -0
- package/src/assets.d.ts +45 -0
- package/src/assets.ts +7 -0
- package/src/canvas.ts +40 -0
- package/src/controller.ts +43 -0
- package/src/conversation.ts +205 -0
- package/src/external.ts +13 -0
- package/src/file-router.tsx +74 -0
- package/src/icons/conversation/LICENSE.md +21 -0
- package/src/icons/conversation/tick-double.svg +1 -0
- package/src/icons/conversation/tick.svg +1 -0
- package/src/icons/conversation/view.svg +4 -0
- package/src/icons.ts +8509 -0
- package/src/image.ts +45 -0
- package/src/index.ts +137 -0
- package/src/input.ts +24 -0
- package/src/link-preview.ts +29 -0
- package/src/links.ts +13 -0
- package/src/list-patch.ts +14 -0
- package/src/list.ts +134 -0
- package/src/native-list.ts +248 -0
- package/src/native.ts +96 -0
- package/src/navigation.ts +237 -0
- package/src/patterns.ts +73 -0
- package/src/playback-binding.ts +1 -0
- package/src/playing.ts +79 -0
- package/src/press.ts +16 -0
- package/src/react.ts +6 -0
- package/src/renderer.ts +341 -0
- package/src/reorder.ts +49 -0
- package/src/resource.ts +134 -0
- package/src/route-path.ts +38 -0
- package/src/scroll.ts +3 -0
- package/src/snapshot.ts +18 -0
- package/tsconfig.app.json +13 -0
- package/tsconfig.json +21 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vandam Dinh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/ink-native.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"modules": {
|
|
4
|
+
"src/image.ts": [
|
|
5
|
+
"image",
|
|
6
|
+
"network"
|
|
7
|
+
],
|
|
8
|
+
"src/input.ts": [
|
|
9
|
+
"text-input"
|
|
10
|
+
],
|
|
11
|
+
"src/playing.ts": [
|
|
12
|
+
"image",
|
|
13
|
+
"network",
|
|
14
|
+
"ui-playing"
|
|
15
|
+
],
|
|
16
|
+
"src/external.ts": [
|
|
17
|
+
"external"
|
|
18
|
+
],
|
|
19
|
+
"src/native-list.ts": [
|
|
20
|
+
"ui-lists"
|
|
21
|
+
],
|
|
22
|
+
"src/list.ts": [
|
|
23
|
+
"ui-lists"
|
|
24
|
+
],
|
|
25
|
+
"src/conversation.ts": [
|
|
26
|
+
"ui-messages",
|
|
27
|
+
"ui-lists"
|
|
28
|
+
],
|
|
29
|
+
"src/canvas.ts": [
|
|
30
|
+
"ui-canvas"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ink-framework",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"types": "./src/index.ts",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts",
|
|
7
|
+
"./renderer": "./src/renderer.ts",
|
|
8
|
+
"./internal/list": "./src/native-list.ts",
|
|
9
|
+
"./internal/playback": "./src/playback-binding.ts",
|
|
10
|
+
"./native": "./src/native.ts",
|
|
11
|
+
"./native/controller": "./src/controller.ts",
|
|
12
|
+
"./assets": "./src/assets.ts",
|
|
13
|
+
"./icons": "./src/icons.ts",
|
|
14
|
+
"./file-router": "./src/file-router.tsx",
|
|
15
|
+
"./runtime-types": "./runtime.d.ts",
|
|
16
|
+
"./tsconfig": "./tsconfig.app.json"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"linkify-it": "^6.1.0",
|
|
20
|
+
"react-reconciler": "0.33.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"react": "19.2.8"
|
|
24
|
+
},
|
|
25
|
+
"sideEffects": [
|
|
26
|
+
"./src/native.ts"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/No-Scrolling/ink.git",
|
|
32
|
+
"directory": "packages/ink"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"src",
|
|
36
|
+
"*.json",
|
|
37
|
+
"*.ts"
|
|
38
|
+
]
|
|
39
|
+
}
|
package/runtime.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export {};
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
interface ImportMeta {
|
|
5
|
+
readonly env: { readonly [key: `INK_PUBLIC_${string}`]: string | undefined };
|
|
6
|
+
}
|
|
7
|
+
function setTimeout<T extends unknown[]>(callback: (...args: T) => void, delay?: number, ...args: T): number;
|
|
8
|
+
function setInterval<T extends unknown[]>(callback: (...args: T) => void, delay?: number, ...args: T): number;
|
|
9
|
+
function clearTimeout(id?: number): void;
|
|
10
|
+
function clearInterval(id?: number): void;
|
|
11
|
+
function queueMicrotask(callback: () => void): void;
|
|
12
|
+
const performance: { now(): number; readonly timeOrigin: number };
|
|
13
|
+
const console: { log(...values: unknown[]): void; info(...values: unknown[]): void; warn(...values: unknown[]): void; error(...values: unknown[]): void; debug(...values: unknown[]): void };
|
|
14
|
+
|
|
15
|
+
class TextEncoder {
|
|
16
|
+
readonly encoding: "utf-8";
|
|
17
|
+
encode(input?: string): Uint8Array<ArrayBuffer>;
|
|
18
|
+
encodeInto(input: string, destination: Uint8Array): { read: number; written: number };
|
|
19
|
+
}
|
|
20
|
+
class TextDecoder {
|
|
21
|
+
constructor(label?: string, options?: { fatal?: boolean; ignoreBOM?: boolean });
|
|
22
|
+
readonly encoding: "utf-8";
|
|
23
|
+
readonly fatal: boolean;
|
|
24
|
+
readonly ignoreBOM: boolean;
|
|
25
|
+
decode(input?: ArrayBuffer | ArrayBufferView, options?: { stream?: boolean }): string;
|
|
26
|
+
}
|
|
27
|
+
class DOMException extends Error {
|
|
28
|
+
constructor(message?: string, name?: string);
|
|
29
|
+
readonly code: number;
|
|
30
|
+
}
|
|
31
|
+
interface EventInit { bubbles?: boolean; cancelable?: boolean; composed?: boolean }
|
|
32
|
+
class Event {
|
|
33
|
+
constructor(type: string, options?: EventInit);
|
|
34
|
+
readonly type: string;
|
|
35
|
+
readonly bubbles: boolean;
|
|
36
|
+
readonly cancelable: boolean;
|
|
37
|
+
readonly composed: boolean;
|
|
38
|
+
readonly target: EventTarget | null;
|
|
39
|
+
readonly currentTarget: EventTarget | null;
|
|
40
|
+
readonly eventPhase: number;
|
|
41
|
+
readonly defaultPrevented: boolean;
|
|
42
|
+
readonly timeStamp: number;
|
|
43
|
+
readonly isTrusted: false;
|
|
44
|
+
cancelBubble: boolean;
|
|
45
|
+
returnValue: boolean;
|
|
46
|
+
stopPropagation(): void;
|
|
47
|
+
stopImmediatePropagation(): void;
|
|
48
|
+
preventDefault(): void;
|
|
49
|
+
composedPath(): EventTarget[];
|
|
50
|
+
static readonly NONE: 0; static readonly CAPTURING_PHASE: 1; static readonly AT_TARGET: 2; static readonly BUBBLING_PHASE: 3;
|
|
51
|
+
readonly NONE: 0; readonly CAPTURING_PHASE: 1; readonly AT_TARGET: 2; readonly BUBBLING_PHASE: 3;
|
|
52
|
+
}
|
|
53
|
+
class CustomEvent<T = unknown> extends Event {
|
|
54
|
+
constructor(type: string, options?: EventInit & { detail?: T });
|
|
55
|
+
readonly detail: T;
|
|
56
|
+
}
|
|
57
|
+
type EventListener = (event: Event) => void;
|
|
58
|
+
interface EventListenerObject { handleEvent(event: Event): void }
|
|
59
|
+
interface EventListenerOptions { capture?: boolean }
|
|
60
|
+
interface AddEventListenerOptions extends EventListenerOptions { once?: boolean; passive?: boolean; signal?: AbortSignal }
|
|
61
|
+
class EventTarget {
|
|
62
|
+
addEventListener(type: string, listener: EventListener | EventListenerObject | null, options?: boolean | AddEventListenerOptions): void;
|
|
63
|
+
removeEventListener(type: string, listener: EventListener | EventListenerObject | null, options?: boolean | EventListenerOptions): void;
|
|
64
|
+
dispatchEvent(event: Event): boolean;
|
|
65
|
+
}
|
|
66
|
+
class AbortSignal extends EventTarget {
|
|
67
|
+
private constructor();
|
|
68
|
+
readonly aborted: boolean;
|
|
69
|
+
readonly reason: unknown;
|
|
70
|
+
onabort: ((event: Event) => void) | null;
|
|
71
|
+
throwIfAborted(): void;
|
|
72
|
+
static abort(reason?: unknown): AbortSignal;
|
|
73
|
+
static timeout(milliseconds: number): AbortSignal;
|
|
74
|
+
static any(signals: Iterable<AbortSignal>): AbortSignal;
|
|
75
|
+
}
|
|
76
|
+
class AbortController {
|
|
77
|
+
readonly signal: AbortSignal;
|
|
78
|
+
abort(reason?: unknown): void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
}
|
package/src/action.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "./react";
|
|
2
|
+
|
|
3
|
+
type ActionState<T> =
|
|
4
|
+
| { status: "idle" }
|
|
5
|
+
| { status: "pending" }
|
|
6
|
+
| { status: "success"; data: T }
|
|
7
|
+
| { status: "error"; error: Error };
|
|
8
|
+
|
|
9
|
+
export type Action<T, Args extends unknown[]> = ActionState<T> & {
|
|
10
|
+
run: (...args: Args) => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function useAction<T, Args extends unknown[]>(
|
|
14
|
+
operation: (...args: Args) => T | PromiseLike<T>,
|
|
15
|
+
): Action<T, Args> {
|
|
16
|
+
const [state, setState] = useState<ActionState<T>>({ status: "idle" });
|
|
17
|
+
const mounted = useRef(false);
|
|
18
|
+
const pending = useRef<object | null>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
mounted.current = true;
|
|
22
|
+
setState(current => current.status === "pending" ? { status: "idle" } : current);
|
|
23
|
+
return () => {
|
|
24
|
+
mounted.current = false;
|
|
25
|
+
pending.current = null;
|
|
26
|
+
};
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
const run = useCallback((...args: Args) => {
|
|
30
|
+
if (!mounted.current || pending.current) return;
|
|
31
|
+
const request = {};
|
|
32
|
+
pending.current = request;
|
|
33
|
+
setState({ status: "pending" });
|
|
34
|
+
const finish = (result: ActionState<T>) => {
|
|
35
|
+
if (pending.current !== request) return;
|
|
36
|
+
pending.current = null;
|
|
37
|
+
setState(result);
|
|
38
|
+
};
|
|
39
|
+
void (async () => {
|
|
40
|
+
try {
|
|
41
|
+
finish({ status: "success", data: await operation(...args) });
|
|
42
|
+
} catch (error) {
|
|
43
|
+
finish({ status: "error", error: error instanceof Error ? error : new Error(String(error)) });
|
|
44
|
+
}
|
|
45
|
+
})();
|
|
46
|
+
}, [operation]);
|
|
47
|
+
|
|
48
|
+
return { ...state, run };
|
|
49
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "./react";
|
|
2
|
+
|
|
3
|
+
declare const __inkPost: (message: string) => void;
|
|
4
|
+
|
|
5
|
+
export type ColourScheme = "dark" | "light";
|
|
6
|
+
let colourScheme: ColourScheme = "dark";
|
|
7
|
+
const listeners = new Set<() => void>();
|
|
8
|
+
|
|
9
|
+
export function setColourScheme(value: ColourScheme): void {
|
|
10
|
+
if (value !== "dark" && value !== "light") throw new TypeError("Invalid colour scheme");
|
|
11
|
+
if (value === colourScheme) return;
|
|
12
|
+
__inkPost(JSON.stringify({ type: "appearance", colourScheme: value }));
|
|
13
|
+
colourScheme = value;
|
|
14
|
+
for (const listener of listeners) listener();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useColourScheme(): ColourScheme {
|
|
18
|
+
return useSyncExternalStore(subscribe, () => colourScheme);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function subscribe(listener: () => void): () => void {
|
|
22
|
+
listeners.add(listener);
|
|
23
|
+
return () => listeners.delete(listener);
|
|
24
|
+
}
|
package/src/assets.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
declare module "*.db" {
|
|
2
|
+
const source: string;
|
|
3
|
+
export default source;
|
|
4
|
+
}
|
|
5
|
+
declare module "*.png" {
|
|
6
|
+
const source: string;
|
|
7
|
+
export default source;
|
|
8
|
+
}
|
|
9
|
+
declare module "*.jpg" {
|
|
10
|
+
const source: string;
|
|
11
|
+
export default source;
|
|
12
|
+
}
|
|
13
|
+
declare module "*.jpeg" {
|
|
14
|
+
const source: string;
|
|
15
|
+
export default source;
|
|
16
|
+
}
|
|
17
|
+
declare module "*.webp" {
|
|
18
|
+
const source: string;
|
|
19
|
+
export default source;
|
|
20
|
+
}
|
|
21
|
+
declare module "*.mp3" {
|
|
22
|
+
const source: string;
|
|
23
|
+
export default source;
|
|
24
|
+
}
|
|
25
|
+
declare module "*.mp4" {
|
|
26
|
+
const source: string;
|
|
27
|
+
export default source;
|
|
28
|
+
}
|
|
29
|
+
declare module "*.mov" {
|
|
30
|
+
const source: string;
|
|
31
|
+
export default source;
|
|
32
|
+
}
|
|
33
|
+
declare module "*.webm" {
|
|
34
|
+
const source: string;
|
|
35
|
+
export default source;
|
|
36
|
+
}
|
|
37
|
+
declare module "*.svg" {
|
|
38
|
+
const icon: import("./assets").IconAsset;
|
|
39
|
+
export default icon;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
declare module "*.gif" {
|
|
43
|
+
const source: string;
|
|
44
|
+
export default source;
|
|
45
|
+
}
|
package/src/assets.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Reference imported from ink/icons or an SVG file. */
|
|
2
|
+
export type IconAsset = string & { readonly __inkIcon: unique symbol };
|
|
3
|
+
|
|
4
|
+
/** Unknown names in external data return undefined. */
|
|
5
|
+
export function findIcon(collection: Readonly<Record<string, IconAsset>>, name: string): IconAsset | undefined {
|
|
6
|
+
return Object.hasOwn(collection, name) ? collection[name] : undefined;
|
|
7
|
+
}
|
package/src/canvas.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { IconAsset } from "./assets";
|
|
3
|
+
import { createElement } from "./react";
|
|
4
|
+
|
|
5
|
+
type Bounds = { x: number; y: number; width: number; height: number };
|
|
6
|
+
|
|
7
|
+
export function Canvas(props: { width: number; height: number; children?: ReactNode }) {
|
|
8
|
+
return createElement("Canvas", props);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Rectangle(props: Bounds & {
|
|
12
|
+
fill?: string;
|
|
13
|
+
stroke?: string;
|
|
14
|
+
strokeWidth?: number;
|
|
15
|
+
onPress?: () => void;
|
|
16
|
+
onLongPress?: () => void;
|
|
17
|
+
onDragEnter?: () => void;
|
|
18
|
+
}) {
|
|
19
|
+
return createElement("CanvasRectangle", props);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function CanvasText({ children, ...props }: Bounds & {
|
|
23
|
+
children: string | number;
|
|
24
|
+
size?: number;
|
|
25
|
+
colour?: string;
|
|
26
|
+
align?: "start" | "center" | "end";
|
|
27
|
+
tabularNumbers?: boolean;
|
|
28
|
+
}) {
|
|
29
|
+
return createElement("CanvasText", { ...props, text: String(children) });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function CanvasIcon({ size = 28, ...props }: {
|
|
33
|
+
x: number;
|
|
34
|
+
y: number;
|
|
35
|
+
name: IconAsset;
|
|
36
|
+
size?: number;
|
|
37
|
+
colour?: string;
|
|
38
|
+
}) {
|
|
39
|
+
return createElement("CanvasIcon", { ...props, width: size, height: size });
|
|
40
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { callNative, NativeError, onNativeMessage } from "./native";
|
|
2
|
+
|
|
3
|
+
const observers = new Map<number, (value: unknown) => void>();
|
|
4
|
+
declare const __inkNextId: () => number;
|
|
5
|
+
|
|
6
|
+
onNativeMessage("controller", message => {
|
|
7
|
+
if (typeof message.id !== "number") throw new NativeError("protocol", "Invalid controller event ID");
|
|
8
|
+
observers.get(message.id)?.(message.value);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export function attachNativeController(
|
|
12
|
+
module: string,
|
|
13
|
+
recipe: unknown,
|
|
14
|
+
observe: (value: unknown) => void,
|
|
15
|
+
) {
|
|
16
|
+
if (observers.size >= 256) throw new NativeError("busy", "Too many native controllers");
|
|
17
|
+
const id = __inkNextId();
|
|
18
|
+
let disposed = false;
|
|
19
|
+
observers.set(id, observe);
|
|
20
|
+
const ready = callNative(module, "activate", recipe, { controller: id }).catch(error => {
|
|
21
|
+
disposed = true;
|
|
22
|
+
observers.delete(id);
|
|
23
|
+
throw error;
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
id,
|
|
27
|
+
ready,
|
|
28
|
+
call(operation: string, payload: unknown = {}) {
|
|
29
|
+
if (disposed) return Promise.reject(new NativeError("unavailable", "Native controller is disposed"));
|
|
30
|
+
return ready.then(() => {
|
|
31
|
+
if (disposed) throw new NativeError("unavailable", "Native controller is disposed");
|
|
32
|
+
return callNative(module, operation, payload, { controller: id });
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
async dispose() {
|
|
36
|
+
if (disposed) return;
|
|
37
|
+
disposed = true;
|
|
38
|
+
observers.delete(id);
|
|
39
|
+
try { await ready; } catch { return; }
|
|
40
|
+
await callNative(module, "deactivate", {}, { controller: id });
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { createElement, useEffect, useRef, useState, type ComponentProps } from "./react";
|
|
2
|
+
import { addFilled, closeFilled, sendFilled } from "./icons";
|
|
3
|
+
import { Button, Image, List, Screen, Stack, Text, TextInput } from "./index";
|
|
4
|
+
import { back, presentPage } from "./navigation";
|
|
5
|
+
import { textLinks } from "./links";
|
|
6
|
+
import type { LinkPreviewData } from "./link-preview";
|
|
7
|
+
import tick from "./icons/conversation/tick.svg";
|
|
8
|
+
import tickDouble from "./icons/conversation/tick-double.svg";
|
|
9
|
+
import view from "./icons/conversation/view.svg";
|
|
10
|
+
import { callNative } from "./native";
|
|
11
|
+
import { openLink } from "./external";
|
|
12
|
+
import { nativeListRow, nativeListTemplate } from "./native-list";
|
|
13
|
+
|
|
14
|
+
export type ReplyPreview = { author: string; text: string; onPress?: () => void };
|
|
15
|
+
export type MessageProps = {
|
|
16
|
+
text?: string;
|
|
17
|
+
onLinkPress?: (url: string) => void;
|
|
18
|
+
linkPreview?: LinkPreviewData;
|
|
19
|
+
filePreview?: { title: string; label: string; artwork?: string };
|
|
20
|
+
onFilePress?: () => void;
|
|
21
|
+
timestamp: number;
|
|
22
|
+
outgoing?: boolean;
|
|
23
|
+
author?: string;
|
|
24
|
+
image?: { src: string; width: number; height: number; onPress?: () => void };
|
|
25
|
+
reply?: ReplyPreview;
|
|
26
|
+
reactions?: string;
|
|
27
|
+
status?: "sending" | "sent" | "delivered" | "read" | "failed";
|
|
28
|
+
onRetry?: () => void;
|
|
29
|
+
onLongPress?: () => void;
|
|
30
|
+
onDoubleTap?: () => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const statusIcons = { sending: tick, sent: tick, delivered: tickDouble, read: view };
|
|
34
|
+
|
|
35
|
+
function messageTime(timestamp: number) {
|
|
36
|
+
const date = new Date(timestamp);
|
|
37
|
+
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
38
|
+
const day = `${months[date.getMonth()]} ${date.getDate()}`;
|
|
39
|
+
const time = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
|
40
|
+
return date.toDateString() === new Date().toDateString() ? time : `${day} ${time}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function Reply({ author, text, onPress }: ReplyPreview) {
|
|
44
|
+
return createElement("Pressable", { onPress },
|
|
45
|
+
createElement("MessageQuote", null,
|
|
46
|
+
createElement(Stack, { gap: 3 },
|
|
47
|
+
createElement(Text, { size: 14 }, `Replying to ${author}`),
|
|
48
|
+
createElement(Text, { size: 14, maxLines: 1 }, text))));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const messageTemplate = /* @__PURE__ */ nativeListTemplate((node, field) => node("MessageContent", Object.fromEntries([
|
|
52
|
+
"outgoing", "label", "reactions", "statusIcon", "statusMuted", "reply", "image", "preview", "parts",
|
|
53
|
+
"onPress", "onLongPress", "onRetry", "onImagePress", "onReplyPress", "onLinkPress", "onPreviewPress",
|
|
54
|
+
].map(name => [name, field(name)]))));
|
|
55
|
+
|
|
56
|
+
function messageFields({ text, onLinkPress = openLink, linkPreview, filePreview, onFilePress, timestamp, outgoing = false, author, image, reply,
|
|
57
|
+
reactions, status, onRetry, onLongPress }: MessageProps, onPress: () => void) {
|
|
58
|
+
const displayText = text && linkPreview && !filePreview
|
|
59
|
+
? textLinks(text).filter(link => link.url === linkPreview.url).reverse()
|
|
60
|
+
.reduce((value, link) => value.slice(0, link.start) + value.slice(link.end), text).trim()
|
|
61
|
+
: text;
|
|
62
|
+
const parts: { text: string; url?: string }[] = [];
|
|
63
|
+
let offset = 0;
|
|
64
|
+
if (displayText) {
|
|
65
|
+
for (const link of textLinks(displayText)) {
|
|
66
|
+
parts.push({ text: displayText.slice(offset, link.start) });
|
|
67
|
+
parts.push({ text: displayText.slice(link.start, link.end), url: link.url });
|
|
68
|
+
offset = link.end;
|
|
69
|
+
}
|
|
70
|
+
parts.push({ text: displayText.slice(offset) });
|
|
71
|
+
}
|
|
72
|
+
const failed = outgoing && status === "failed";
|
|
73
|
+
return { outgoing, label: failed ? (onRetry ? "Tap to retry" : "Could not send")
|
|
74
|
+
: [!outgoing && author, messageTime(timestamp)].filter(Boolean).join(", "),
|
|
75
|
+
reactions, statusIcon: outgoing && status && status !== "failed" ? statusIcons[status] : undefined,
|
|
76
|
+
statusMuted: status === "sending", parts,
|
|
77
|
+
reply: reply && { author: reply.author, text: reply.text },
|
|
78
|
+
image: image && { src: image.src, width: image.width, height: image.height },
|
|
79
|
+
preview: filePreview ? { title: filePreview.title, domain: filePreview.label, thumbnail: filePreview.artwork } : linkPreview && { ...linkPreview, domain: linkPreview.url.replace(/^https?:\/\/(?:www\.)?/, "").split(/[/?#]/, 1)[0] },
|
|
80
|
+
onPress, onLongPress, onRetry: failed ? onRetry : undefined, onImagePress: image?.onPress,
|
|
81
|
+
onReplyPress: reply?.onPress, onLinkPress, onPreviewPress: filePreview ? onFilePress : linkPreview && (() => onLinkPress(linkPreview.url)),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function doubleTap(previous: { current: number }, callback?: () => void) {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
if (previous.current && now - previous.current < 300) {
|
|
88
|
+
previous.current = 0;
|
|
89
|
+
if (callback) { void callNative("interaction", "haptic", null); callback(); }
|
|
90
|
+
} else previous.current = now;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function Message(props: MessageProps) {
|
|
94
|
+
const lastTap = useRef(0);
|
|
95
|
+
return createElement("MessageContent", messageFields(props, () => doubleTap(lastTap, props.onDoubleTap)));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type MessageAction = { label: string; onPress: () => void };
|
|
99
|
+
|
|
100
|
+
export type ConversationMessage = Pick<MessageProps, "text" | "timestamp" | "outgoing" | "author" | "reactions" | "status" | "linkPreview" | "filePreview"> & {
|
|
101
|
+
id: string;
|
|
102
|
+
image?: { src: string; width: number; height: number };
|
|
103
|
+
reply?: { author: string; text: string };
|
|
104
|
+
};
|
|
105
|
+
const messageKey = (message: ConversationMessage) => message.id;
|
|
106
|
+
|
|
107
|
+
export type ConversationScreenProps<T extends ConversationMessage = ConversationMessage> = {
|
|
108
|
+
title: string;
|
|
109
|
+
rightAction?: ComponentProps<typeof Screen>["rightAction"];
|
|
110
|
+
group?: boolean;
|
|
111
|
+
messages: readonly T[];
|
|
112
|
+
draft: string;
|
|
113
|
+
onDraftChange: (draft: string) => void;
|
|
114
|
+
onSend: (message: { text: string; replyTo?: T }) => void;
|
|
115
|
+
onAttach?: () => void;
|
|
116
|
+
onRetry?: (message: T) => void;
|
|
117
|
+
onImagePress?: (message: T) => void;
|
|
118
|
+
onFilePress?: (message: T) => void;
|
|
119
|
+
onLinkPress?: (url: string) => void;
|
|
120
|
+
onDoubleTap?: (message: T) => void;
|
|
121
|
+
actions?: (message: T) => readonly MessageAction[];
|
|
122
|
+
onLoadOlder?: () => Promise<void>;
|
|
123
|
+
hasOlder?: boolean;
|
|
124
|
+
loading?: boolean;
|
|
125
|
+
sending?: boolean;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export function ConversationScreen<T extends ConversationMessage>({ title, rightAction, group = false, messages, draft, onDraftChange, onSend, onAttach, onRetry, onImagePress, onFilePress, onLinkPress, onDoubleTap, actions, onLoadOlder, hasOlder = false, loading = false, sending = false }: ConversationScreenProps<T>) {
|
|
129
|
+
const [reply, setReply] = useState<T>();
|
|
130
|
+
const [scrollToEnd, setScrollToEnd] = useState(0);
|
|
131
|
+
const taps = useRef(new Map<string, { current: number }>());
|
|
132
|
+
const previousMessages = useRef<Set<string> | null>(null);
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
const previous = previousMessages.current;
|
|
135
|
+
const addedOutgoing = previous && messages.some(message => message.outgoing && message.status === "sending"
|
|
136
|
+
&& !previous.has(message.id));
|
|
137
|
+
previousMessages.current = new Set(messages.map(message => message.id));
|
|
138
|
+
for (const key of taps.current.keys()) if (!previousMessages.current.has(key)) taps.current.delete(key);
|
|
139
|
+
if (addedOutgoing) setScrollToEnd(request => request + 1);
|
|
140
|
+
}, [messages]);
|
|
141
|
+
const text = draft.trim();
|
|
142
|
+
const send = () => {
|
|
143
|
+
if (!text || sending) return;
|
|
144
|
+
onSend({ text, replyTo: reply });
|
|
145
|
+
setReply(undefined);
|
|
146
|
+
setScrollToEnd(request => request + 1);
|
|
147
|
+
};
|
|
148
|
+
function replyPreview(message: T): ReplyPreview {
|
|
149
|
+
return { author: message.outgoing ? "You" : message.author ?? title, text: message.text || message.filePreview?.title || "Photo" };
|
|
150
|
+
}
|
|
151
|
+
function messageProps(message: T, onLongPress?: () => void): MessageProps {
|
|
152
|
+
const image = message.image;
|
|
153
|
+
return {
|
|
154
|
+
text: message.text, timestamp: message.timestamp, outgoing: message.outgoing,
|
|
155
|
+
onLinkPress, linkPreview: message.linkPreview,
|
|
156
|
+
filePreview: message.filePreview, onFilePress: onFilePress && (() => onFilePress(message)),
|
|
157
|
+
author: group ? message.author : undefined, reply: message.reply,
|
|
158
|
+
reactions: message.reactions, status: message.status,
|
|
159
|
+
image: image && { ...image, onPress: () => {
|
|
160
|
+
if (onImagePress) onImagePress(message);
|
|
161
|
+
else presentPage(createElement(Screen, { title: "Photo", centered: true },
|
|
162
|
+
createElement(Image, { ...image, fit: "contain", bleed: true, zoomable: true })));
|
|
163
|
+
} },
|
|
164
|
+
onRetry: onRetry && (() => onRetry(message)),
|
|
165
|
+
onDoubleTap: onDoubleTap && (() => onDoubleTap(message)),
|
|
166
|
+
onLongPress,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function renderMessage(message: T, onLongPress?: () => void) { return createElement(Message, messageProps(message, onLongPress)); }
|
|
170
|
+
function openMessage(message: T) {
|
|
171
|
+
presentPage(createElement(Screen, { title: "Message actions" },
|
|
172
|
+
renderMessage(message),
|
|
173
|
+
createElement(Button, { onPress: () => {
|
|
174
|
+
setReply(message);
|
|
175
|
+
setScrollToEnd(request => request + 1);
|
|
176
|
+
back();
|
|
177
|
+
} }, "Reply"),
|
|
178
|
+
actions?.(message).map((action, actionIndex) => createElement(Button, {
|
|
179
|
+
key: actionIndex,
|
|
180
|
+
onPress: () => { back(); action.onPress(); },
|
|
181
|
+
}, action.label))));
|
|
182
|
+
}
|
|
183
|
+
const messageItem = nativeListRow((message: T) => renderMessage(message, () => openMessage(message)), message => {
|
|
184
|
+
const props = messageProps(message, () => openMessage(message));
|
|
185
|
+
return [messageFields(props, () => {
|
|
186
|
+
let previous = taps.current.get(message.id);
|
|
187
|
+
if (!previous) { previous = { current: 0 }; taps.current.set(message.id, previous); }
|
|
188
|
+
doubleTap(previous, props.onDoubleTap);
|
|
189
|
+
})];
|
|
190
|
+
}, messageTemplate, { identity: "ink/conversation", usesIndex: false, values: () => [group, !!onRetry, !!onFilePress, new Date().toDateString()] });
|
|
191
|
+
const iconButton = (name: string, onPress?: () => void) => createElement("Pressable", { onPress },
|
|
192
|
+
createElement("Icon", { name, size: 28, tone: onPress ? "primary" : "muted" }));
|
|
193
|
+
return createElement("Screen", { title, rightIcon: rightAction?.icon, onRightPress: rightAction?.onPress, pinnedFooter: true, initialEnd: true, scrollToEnd },
|
|
194
|
+
loading ? createElement(Text, { size: 18, align: "center" }, "Loading…")
|
|
195
|
+
: createElement(List<T>, { items: messages, keyExtractor: messageKey, renderItem: messageItem, gap: 28, followEnd: true, initialPosition: "end", onLoadOlder, hasOlder }),
|
|
196
|
+
!loading && messages.length === 0 && createElement(Text, { size: 18, align: "center" }, "No messages yet"),
|
|
197
|
+
createElement(Stack, { gap: 8, align: "stretch" },
|
|
198
|
+
reply && createElement("ConversationComposer", null,
|
|
199
|
+
createElement(Reply, replyPreview(reply)), iconButton(closeFilled, () => setReply(undefined))),
|
|
200
|
+
createElement("ConversationComposer", null,
|
|
201
|
+
onAttach ? iconButton(addFilled, onAttach) : createElement(Stack),
|
|
202
|
+
createElement(TextInput, { value: draft, onChange: onDraftChange, placeholder: "Message…", action: "return" }),
|
|
203
|
+
iconButton(sendFilled, text && !sending ? send : undefined)),
|
|
204
|
+
));
|
|
205
|
+
}
|
package/src/external.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { callNative } from "./native";
|
|
2
|
+
|
|
3
|
+
export async function openURL(url: string): Promise<void> {
|
|
4
|
+
await callNative("external", "open-url", { url }, { timeoutMs: 900_000 });
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function openLink(url: string): void {
|
|
8
|
+
void openURL(url).catch(error => console.error("Could not open link", error));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function share(options: { text: string }): Promise<void> {
|
|
12
|
+
await callNative("external", "share", options, { timeoutMs: 900_000 });
|
|
13
|
+
}
|