├── .gitignore
├── .vim
└── coc-settings.json
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── assets
├── icon-v1.png
└── icon-v1.svg
├── deno.jsonc
├── deno.lock
└── src
├── api.ts
├── components
├── message.ts
├── statusbar.ts
└── timeline.ts
├── index.ts
├── noteEditor.ts
├── tui
├── index.ts
├── keyboard.ts
├── string.test.ts
└── string.ts
└── util
├── anim.ts
└── sleep.ts
/.gitignore:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AsPulse/lesskey/76c49a1e81003d21d279387bd0c153f3043f3101/.gitignore
--------------------------------------------------------------------------------
/.vim/coc-settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "deno.enable": true,
3 | "deno.lint": true,
4 | "deno.unstable": true,
5 | "tsserver.enable": false,
6 | "deno.config": "./deno.jsonc"
7 | }
8 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "deno.enable": true,
3 | "deno.unstable": true,
4 | "deno.config": "./deno.jsonc",
5 | "editor.formatOnSave": true,
6 | "json.schemas": [{
7 | "fileMatch": ["./deno.jsonc"],
8 | "url": "https://deno.land/x/deno/cli/schemas/config-file.v1.json"
9 | }],
10 | "deno.inlayHints.enumMemberValues.enabled": false,
11 | "deno.inlayHints.functionLikeReturnTypes.enabled": false,
12 | "deno.inlayHints.parameterNames.suppressWhenArgumentMatchesName": false,
13 | "deno.inlayHints.parameterTypes.enabled": false,
14 | "deno.inlayHints.propertyDeclarationTypes.enabled": false,
15 | "deno.inlayHints.parameterNames.enabled": "all",
16 | "deno.inlayHints.variableTypes.enabled": false,
17 | "deno.inlayHints.variableTypes.suppressWhenTypeMatchesName": false,
18 | "[typescript]": {
19 | "editor.defaultFormatter": "denoland.vscode-deno"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 AsPulse
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.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
LessKey
6 |
7 |
8 |
9 | LessKey is CLI Client of Misskey written in Deno!
10 |
11 |
12 |
13 |
14 | ### Prerequisites
15 | A text editor is required to post/edit notes.
16 | By default, vim will be launched, but can be specified as you like.
17 |
18 | (Example of Use Neovim)
19 | ```
20 | lesskey --token --editor nvim
21 | ```
22 |
23 | By default, lesskey connects to Misskey.io, but if you want to connect to a different instance use the `--origin` flag.
24 | ```
25 | lesskey --origin misskey.cf --token
26 | ```
27 |
28 |
29 |
30 |
31 | ## Getting Started
32 |
33 | Usage (released binary):
34 |
35 | ```
36 | lesskey --token
37 | ```
38 |
39 |
40 |
41 | Usage (from source code):
42 |
43 | ```
44 | deno task dev --token
45 | ```
46 |
--------------------------------------------------------------------------------
/assets/icon-v1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AsPulse/lesskey/76c49a1e81003d21d279387bd0c153f3043f3101/assets/icon-v1.png
--------------------------------------------------------------------------------
/assets/icon-v1.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/deno.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "tasks": {
3 | "dev": "deno run --allow-net --allow-read --allow-write --allow-run --unstable --watch src/index.ts",
4 | "main": "deno run --allow-net --allow-read --allow-write --allow-run --unstable src/index.ts"
5 | },
6 | "imports": {
7 | "std/": "https://deno.land/std@0.177.0/",
8 | "zod": "https://deno.land/x/zod@v3.20.5/mod.ts"
9 | },
10 | "fmt": {
11 | "options": {
12 | "singleQuote": true
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/deno.lock:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2",
3 | "remote": {
4 | "https://deno.land/std@0.177.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462",
5 | "https://deno.land/std@0.177.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3",
6 | "https://deno.land/std@0.177.0/flags/mod.ts": "d1cdefa18472ef69858a17df5cf7c98445ed27ac10e1460183081303b0ebc270",
7 | "https://deno.land/std@0.177.0/fmt/colors.ts": "938c5d44d889fb82eff6c358bea8baa7e85950a16c9f6dae3ec3a7a729164471",
8 | "https://deno.land/std@0.177.0/node/_core.ts": "9a58c0ef98ee77e9b8fcc405511d1b37a003a705eb6a9b6e95f75434d8009adc",
9 | "https://deno.land/std@0.177.0/node/_events.d.ts": "1347437fd6b084d7c9a4e16b9fe7435f00b030970086482edeeb3b179d0775af",
10 | "https://deno.land/std@0.177.0/node/_events.mjs": "d4ba4e629abe3db9f1b14659fd5c282b7da8b2b95eaf13238eee4ebb142a2448",
11 | "https://deno.land/std@0.177.0/node/_utils.ts": "7fd55872a0cf9275e3c080a60e2fa6d45b8de9e956ebcde9053e72a344185884",
12 | "https://deno.land/std@0.177.0/node/events.ts": "d2de352d509de11a375e2cb397d6b98f5fed4e562fc1d41be33214903a38e6b0",
13 | "https://deno.land/std@0.177.0/node/internal/crypto/_keys.ts": "8f3c3b5a141aa0331a53c205e9338655f1b3b307a08085fd6ff6dda6f7c4190b",
14 | "https://deno.land/std@0.177.0/node/internal/crypto/constants.ts": "544d605703053218499b08214f2e25cf4310651d535b7ab995891c4b7a217693",
15 | "https://deno.land/std@0.177.0/node/internal/error_codes.ts": "8495e33f448a484518d76fa3d41d34fc20fe03c14b30130ad8e936b0035d4b8b",
16 | "https://deno.land/std@0.177.0/node/internal/errors.ts": "1c699b8a3cb93174f697a348c004b1c6d576b66688eac8a48ebb78e65c720aae",
17 | "https://deno.land/std@0.177.0/node/internal/hide_stack_frames.ts": "9dd1bad0a6e62a1042ce3a51eb1b1ecee2f246907bff44835f86e8f021de679a",
18 | "https://deno.land/std@0.177.0/node/internal/normalize_encoding.mjs": "fd1d9df61c44d7196432f6e8244621468715131d18cc79cd299fc78ac549f707",
19 | "https://deno.land/std@0.177.0/node/internal/util/inspect.mjs": "11d7c9cab514b8e485acc3978c74b837263ff9c08ae4537fa18ad56bae633259",
20 | "https://deno.land/std@0.177.0/node/internal/util/types.ts": "0e587b44ec5e017cf228589fc5ce9983b75beece6c39409c34170cfad49d6417",
21 | "https://deno.land/std@0.177.0/node/internal/validators.mjs": "e02f2b02dd072a5d623970292588d541204dc82207b4c58985d933a5f4b382e6",
22 | "https://deno.land/std@0.177.0/node/internal_binding/_libuv_winerror.ts": "30c9569603d4b97a1f1a034d88a3f74800d5ea1f12fcc3d225c9899d4e1a518b",
23 | "https://deno.land/std@0.177.0/node/internal_binding/_winerror.ts": "3e8cfdfe22e89f13d2b28529bab35155e6b1730c0221ec5a6fc7077dc037be13",
24 | "https://deno.land/std@0.177.0/node/internal_binding/constants.ts": "21ff9d1ee71d0a2086541083a7711842fc6ae25e264dbf45c73815aadce06f4c",
25 | "https://deno.land/std@0.177.0/node/internal_binding/types.ts": "2187595a58d2cf0134f4db6cc2a12bf777f452f52b15b6c3aed73fa072aa5fc3",
26 | "https://deno.land/std@0.177.0/node/internal_binding/util.ts": "808ff3b92740284184ab824adfc420e75398c88c8bccf5111f0c24ac18c48f10",
27 | "https://deno.land/std@0.177.0/node/internal_binding/uv.ts": "eb0048e30af4db407fb3f95563e30d70efd6187051c033713b0a5b768593a3a3",
28 | "https://deno.land/std@0.177.0/streams/_common.ts": "f45cba84f0d813de3326466095539602364a9ba521f804cc758f7a475cda692d",
29 | "https://deno.land/std@0.177.0/streams/copy.ts": "de0de21701d8cceba84ca01d9731c77f4b3597bb9de6a1b08f32250353feeae8",
30 | "https://deno.land/std@0.177.0/streams/write_all.ts": "3b2e1ce44913f966348ce353d02fa5369e94115181037cd8b602510853ec3033",
31 | "https://deno.land/std@0.177.0/testing/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea",
32 | "https://deno.land/std@0.177.0/testing/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7",
33 | "https://deno.land/std@0.177.0/testing/asserts.ts": "984ab0bfb3faeed92ffaa3a6b06536c66811185328c5dd146257c702c41b01ab",
34 | "https://deno.land/std@0.177.0/types.d.ts": "220ed56662a0bd393ba5d124aa6ae2ad36a00d2fcbc0e8666a65f4606aaa9784",
35 | "https://deno.land/x/zod@v3.20.5/ZodError.ts": "10bb0d014b0ece532c3bc395c50ae25996315a5897c0216517d9174c2fb570b5",
36 | "https://deno.land/x/zod@v3.20.5/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef",
37 | "https://deno.land/x/zod@v3.20.5/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe",
38 | "https://deno.land/x/zod@v3.20.5/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c",
39 | "https://deno.land/x/zod@v3.20.5/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7",
40 | "https://deno.land/x/zod@v3.20.5/helpers/parseUtil.ts": "51a76c126ee212be86013d53a9d07f87e9ae04bb1496f2558e61b62cb74a6aa8",
41 | "https://deno.land/x/zod@v3.20.5/helpers/partialUtil.ts": "8dc921a02b47384cf52217c7e539268daf619f89319b75bdf13ea178815725df",
42 | "https://deno.land/x/zod@v3.20.5/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e",
43 | "https://deno.land/x/zod@v3.20.5/helpers/util.ts": "0e7366354b1a5070408c1c48d01c7e33d374ca70806f5003b12ff527795c578c",
44 | "https://deno.land/x/zod@v3.20.5/index.ts": "035a7422d9f2be54daa0fe464254b69225b443000673e4794095d672471e8792",
45 | "https://deno.land/x/zod@v3.20.5/locales/en.ts": "ac7210faad6e67ec4f6dbe7062886f04db79ce91ae5ee6c9f64cbdf6221bc80e",
46 | "https://deno.land/x/zod@v3.20.5/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4",
47 | "https://deno.land/x/zod@v3.20.5/types.ts": "6ddc4608e70d75f2e06f9cc14aa406df4d80f420c0eef64f2f02d429853c0c38"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/api.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 |
3 | const userSchema = z.object({
4 | username: z.string(),
5 | name: z.string().nullable(),
6 | host: z.string().nullable(),
7 | emojis: z.record(z.string()).optional(),
8 | isBot: z.boolean(),
9 | isCat: z.boolean(),
10 | });
11 |
12 | const _errorSchema = z.object({
13 | error: z.object({
14 | code: z.string(),
15 | }),
16 | });
17 |
18 | const driveFileSchema = z.object({
19 | createdAt: z.string(),
20 | id: z.string(),
21 | name: z.string(),
22 | thumbnailUrl: z.string().nullable(),
23 | url: z.string(),
24 | type: z.string(),
25 | size: z.number(),
26 | blurhash: z.string().nullable(),
27 | comment: z.string().nullable(),
28 | properties: z.record(z.string(), z.unknown()),
29 | });
30 |
31 | const messageBaseSchema = z.object({
32 | text: z.string().nullable(),
33 | user: userSchema,
34 | createdAt: z.string(),
35 | renoteId: z.string().nullable(),
36 | renoteCount: z.number(),
37 | fileIds: z.array(z.string()),
38 | files: z.array(driveFileSchema),
39 | replayId: z.string().optional(),
40 | repliesCount: z.number(),
41 | reactions: z.record(z.string(), z.number()),
42 | reactionEmojis: z.record(z.string()),
43 | emojis: z.record(z.string()).optional(),
44 | tags: z.array(z.string()).optional(),
45 | url: z.string().optional(),
46 | uri: z.string().optional(),
47 | visibility: z.union([
48 | z.literal('public'),
49 | z.literal('home'),
50 | z.literal('followers'),
51 | z.literal('specified'),
52 | ]),
53 | localOnly: z.boolean().optional(),
54 | });
55 |
56 | const messageSchema = messageBaseSchema.extend({
57 | renote: messageBaseSchema.optional(),
58 | reply: messageBaseSchema.optional(),
59 | });
60 |
61 | const fetchTimelineSchema = messageSchema.array();
62 |
63 | const channelMessageSchema = z.object({
64 | type: z.literal('channel'),
65 | body: z.object({
66 | id: z.string(),
67 | type: z.literal('note'),
68 | body: messageSchema,
69 | }),
70 | });
71 |
72 | export type User = z.infer;
73 | export type ChannelMessageEvent = z.infer;
74 | export type NewNoteEvent = z.infer;
75 |
76 | export class MisskeyAPI {
77 | ws: Promise;
78 | listens: {
79 | type: 'channel';
80 | id: string;
81 | onMessage: (info: ChannelMessageEvent) => void;
82 | }[] = [];
83 |
84 | constructor(public origin: string, public token: string, onError: (e: Error) => void) {
85 | this.ws = new Promise((resolve) => {
86 | try {
87 | const ws = new WebSocket(`wss://${origin}/streaming?i=${token}`);
88 | ws.onopen = () => {
89 | resolve(ws);
90 | ws.onmessage = (m: MessageEvent) => {
91 | try {
92 | const json = JSON.parse(m.data);
93 |
94 | const channelMessage = channelMessageSchema.safeParse(json);
95 | if (channelMessage.success) {
96 | this.listens.find((v) => v.id === channelMessage.data.body.id)
97 | ?.onMessage(channelMessage.data);
98 | }
99 | } catch {
100 | //TODO: WS Error Parse JSON
101 | }
102 | };
103 | };
104 | ws.onclose = () => {
105 | //TODO: handle correctly;
106 | Deno.exit(1);
107 | };
108 | } catch (e) {
109 | onError(e);
110 | }
111 | });
112 | }
113 |
114 | private async request(
115 | endpoint: `/${string}`,
116 | payload: Record,
117 | ): Promise> {
118 | const req = await fetch(
119 | `https://${this.origin}/api${endpoint}`,
120 | {
121 | method: 'POST',
122 | body: JSON.stringify({ i: this.token, ...payload }),
123 | headers: {
124 | ['Content-Type']: 'application/json',
125 | },
126 | },
127 | );
128 | return req.json();
129 | }
130 |
131 | async getMe(): Promise<
132 | { success: true } & z.infer | { success: false }
133 | > {
134 | const api = await this.request('/i', {});
135 |
136 | const ok = userSchema.safeParse(api);
137 | if (ok.success) {
138 | return { success: true, ...ok.data };
139 | }
140 |
141 | return { success: false };
142 | }
143 |
144 | async fetchTimeline(
145 | type: string,
146 | limit: number,
147 | ): Promise> {
148 | const api = await this.request(`/notes/${type}`, { limit });
149 | const data = fetchTimelineSchema.parse(api);
150 | return data;
151 | }
152 |
153 | async startListenChannel(
154 | channel: string,
155 | id: string,
156 | onMessage: (ev: ChannelMessageEvent) => void,
157 | ) {
158 | this.listens.push({ type: 'channel', id, onMessage });
159 | (await this.ws).send(JSON.stringify({
160 | type: 'connect',
161 | body: {
162 | channel,
163 | id,
164 | },
165 | }));
166 | }
167 |
168 | async postNote(content: string) {
169 | return await this.request('/notes/create', {
170 | visibility: 'public',
171 | text: content,
172 | localOnly: false,
173 | poll: null,
174 | });
175 | }
176 |
177 | async stopListenChannel(id: string) {
178 | (await this.ws).send(JSON.stringify({
179 | type: 'disconnect',
180 | body: {
181 | id,
182 | },
183 | }));
184 | }
185 | }
186 |
--------------------------------------------------------------------------------
/src/components/message.ts:
--------------------------------------------------------------------------------
1 | import { TUIArea, TUIComponent, TUIParent } from '../tui/index.ts';
2 | import { uiString } from '../tui/string.ts';
3 |
4 | export class Message implements TUIComponent {
5 | constructor(public parent: TUIParent) {}
6 |
7 | private text: string[] = [];
8 |
9 | async setText(newValue: string[]) {
10 | this.text = newValue;
11 | await this.parent.render();
12 | }
13 |
14 | render(area: TUIArea) {
15 | const str = this.text.flatMap((v) =>
16 | uiString([{ text: v }], area.w, false)
17 | );
18 |
19 | return Promise.resolve([
20 | {
21 | x: Math.floor(
22 | (area.w -
23 | str.map((v) => v.length).reduce((a, b) => a > b ? a : b, 0)) / 2,
24 | ),
25 | y: Math.floor((area.h - str.length) / 2),
26 | z: 1,
27 | content: str,
28 | },
29 | ]);
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/statusbar.ts:
--------------------------------------------------------------------------------
1 | import { TUIArea, TUIComponent, TUIParent } from '../tui/index.ts';
2 | import { uiString } from '../tui/string.ts';
3 |
4 | export class StatusBar implements TUIComponent {
5 | constructor(public parent: TUIParent) {}
6 |
7 | id: null | string = null;
8 | text = '';
9 |
10 | async setId(id: string) {
11 | this.id = id;
12 | await this.parent.render();
13 | }
14 |
15 | async setText(content: string) {
16 | this.text = content;
17 | await this.parent.render();
18 | }
19 |
20 | render(area: TUIArea) {
21 | const howToQuit = uiString(
22 | [{ text: '(Ctrl+C to quit)', foregroundColor: [100, 100, 100] }],
23 | area.w,
24 | true,
25 | );
26 |
27 | const username = uiString(
28 | [{
29 | text: this.id === null
30 | ? ' LessKey '
31 | : ` LessKey [@${this.id}] `,
32 | backgroundColor: [255, 56, 139],
33 | foregroundColor: [255, 255, 255],
34 | bold: true,
35 | }],
36 | area.w,
37 | true,
38 | );
39 |
40 | return Promise.resolve([
41 | {
42 | x: area.x,
43 | y: area.h - area.y - 1,
44 | z: 11,
45 | content: [username],
46 | },
47 | {
48 | x: area.w - area.x - howToQuit.length - 1,
49 | y: area.h - area.y - 1,
50 | z: 12,
51 | content: [howToQuit],
52 | },
53 | {
54 | x: area.x + username.length + 1,
55 | y: area.h - area.y - 1,
56 | z: 11,
57 | content: [uiString([{ text: this.text }], area.w, true)],
58 | },
59 | {
60 | x: area.x,
61 | y: area.h - area.y - 1,
62 | z: 10,
63 | content: [uiString([{ text: ' '.repeat(area.w) }], area.w, true)],
64 | },
65 | ]);
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/components/timeline.ts:
--------------------------------------------------------------------------------
1 | import { MisskeyAPI, NewNoteEvent } from '../api.ts';
2 | import { TUIArea, TUIComponent, TUIParent, TUIResult } from '../tui/index.ts';
3 | import { keyboard } from '../tui/keyboard.ts';
4 | import { formatTimespan, uiString } from '../tui/string.ts';
5 | import { Animation, easeOutExpo } from '../util/anim.ts';
6 | import { sleep } from '../util/sleep.ts';
7 | import { StatusBar } from './statusbar.ts';
8 |
9 | type StatusType = { now: string; left: string | null; right: string | null };
10 |
11 | const timelines = [
12 | { view: 'Home', id: 'homeTimeline', apiId: 'timeline' },
13 | { view: 'Local', id: 'localTimeline', apiId: 'local-timeline' },
14 | { view: 'Social', id: 'hybridTimeline', apiId: 'hybrid-timeline' },
15 | { view: 'Global', id: 'globalTimeline', apiId: 'global-timeline' },
16 | ] as const;
17 |
18 | export type TimelineId = number & (keyof typeof timelines);
19 | export type MisskeyNote = {
20 | message: NewNoteEvent;
21 | selected: boolean;
22 | opacity: number;
23 | };
24 |
25 | const Note = (note: MisskeyNote, width: number) => {
26 | const isRenote = note.message.renote !== undefined;
27 | const noteCap = isRenote ? 1 : 0;
28 | const message = note.message.renote ?? note.message;
29 |
30 | const content = (message.text ?? '').split(/\n/).flatMap((text) =>
31 | uiString([{ text }], width, false)
32 | );
33 | const time = uiString(
34 | [
35 | {
36 | text: formatTimespan(new Date(message.createdAt)),
37 | foregroundColor: [150, 150, 150],
38 | },
39 | ],
40 | 12,
41 | true,
42 | );
43 |
44 | const postedTime = uiString(
45 | [
46 | {
47 | text: formatTimespan(new Date(note.message.createdAt)),
48 | foregroundColor: [30, 120, 100],
49 | },
50 | ],
51 | 12,
52 | true,
53 | );
54 |
55 | return {
56 | components: [
57 | ...(!isRenote ? [] : [
58 | {
59 | x: 0,
60 | y: 0,
61 | content: [
62 | uiString(
63 | [{
64 | text: `♻️ Renoted by ${
65 | note.message.user.name ?? note.message.user.username
66 | }`,
67 | foregroundColor: [40, 150, 120],
68 | bold: true,
69 | }],
70 | width,
71 | true,
72 | ),
73 | ],
74 | },
75 | {
76 | x: width - postedTime.length,
77 | y: 0,
78 | content: [postedTime],
79 | },
80 | ]),
81 | {
82 | x: 0,
83 | y: 0 + noteCap,
84 | content: [
85 | uiString(
86 | [
87 | {
88 | text: `${message.user.name ?? message.user.username} `,
89 | foregroundColor: [120, 206, 235],
90 | },
91 | {
92 | text: `@${message.user.username}`,
93 | foregroundColor: [130, 130, 130],
94 | },
95 | ],
96 | width,
97 | true,
98 | ),
99 | ],
100 | },
101 | {
102 | x: width - time.length,
103 | y: 0 + noteCap,
104 | content: [time],
105 | },
106 | {
107 | x: 0,
108 | y: 1 + noteCap,
109 | content,
110 | },
111 | ],
112 | height: content.length + 1 + noteCap,
113 | };
114 | };
115 |
116 | export class Timeline implements TUIComponent {
117 | id: null | string = null;
118 | scrollOffset = 0;
119 | scrollAnimation;
120 |
121 | lastWidth: null | number = null;
122 | status: StatusType = { now: 'Loading', left: null, right: null };
123 | notes: MisskeyNote[] = [];
124 |
125 | get selectedNotes() {
126 | return this.notes.findIndex((n) => n.selected);
127 | }
128 |
129 | constructor(
130 | public parent: TUIParent,
131 | public api: MisskeyAPI,
132 | public statusBar: StatusBar,
133 | ) {
134 | this.setActiveTimeline(1);
135 |
136 | this.scrollAnimation = new Animation(async (y) => {
137 | await sleep(5);
138 | if (Math.round(y) === this.scrollOffset) return;
139 | this.scrollOffset = Math.round(y);
140 | await this.parent.render();
141 | }, this.statusBar);
142 |
143 | keyboard.onPress((buf) => {
144 | // Key H
145 | if (buf[0] === 104 && buf[1] === 0) {
146 | if (this.activeTimeline > 0) {
147 | this.setActiveTimeline(this.activeTimeline - 1);
148 | }
149 | return;
150 | }
151 |
152 | if (buf[0] === 108 && buf[1] === 0) {
153 | if (this.activeTimeline < timelines.length - 1) {
154 | this.setActiveTimeline(this.activeTimeline + 1);
155 | }
156 | return;
157 | }
158 | });
159 | }
160 |
161 | activeTimeline: TimelineId = 1;
162 |
163 | private async setActiveTimeline(index: TimelineId) {
164 | this.activeTimeline = index;
165 | if (this.id !== null) {
166 | await this.api.stopListenChannel(this.id);
167 | }
168 | const left = index === 0 ? null : timelines[index - 1].view;
169 | const right = index === timelines.length - 1
170 | ? null
171 | : timelines[index + 1].view;
172 |
173 | this.status = { left, right, now: timelines[index].view };
174 | this.notes = (await this.api.fetchTimeline(timelines[index].apiId, 20))
175 | .map((v) => ({ message: v, selected: false, opacity: 1 }));
176 | this.id = `--lesskey-TL-${Date.now()}`;
177 |
178 | await this.parent.render();
179 |
180 | this.api.startListenChannel(
181 | timelines[index].id,
182 | this.id,
183 | (e) => this.addNote(e.body.body),
184 | );
185 | }
186 |
187 | private async addNote(e: NewNoteEvent) {
188 | const note = { message: e, selected: false, opacity: 0 };
189 | this.notes.unshift(note);
190 | const selected = this.selectedNotes;
191 |
192 | // Keep a cache of the 100 most recent notes and the 10 surrounding notes that are in focus.
193 | this.notes = this.notes.filter((_, i) => {
194 | if (i < 100) return true;
195 | if (selected === -1) return false;
196 | if (Math.abs(i - selected) < 10) return true;
197 | return false;
198 | });
199 |
200 | if (this.lastWidth !== null) {
201 | const height = Note(note, this.lastWidth - 6).height + 3;
202 | this.scrollOffset += height;
203 | const duration = Math.round(
204 | Math.max(300, 440 * Math.log(this.scrollOffset) - 390),
205 | );
206 | this.scrollAnimation.moveTo(
207 | [this.scrollOffset, 0],
208 | duration,
209 | easeOutExpo,
210 | );
211 | } else {
212 | this.scrollOffset = 0;
213 | }
214 |
215 | await this.parent.render();
216 | }
217 |
218 | render(area: TUIArea) {
219 | const backgroundColor: [number, number, number] = [23, 124, 198];
220 | const foregroundColor: [number, number, number] = [201, 232, 255];
221 | const now = uiString(
222 | [{
223 | text: `${this.status.now} TIMELINE`,
224 | bold: true,
225 | backgroundColor,
226 | foregroundColor: [245, 245, 245],
227 | }],
228 | area.w,
229 | true,
230 | );
231 | const left = uiString(
232 | [{ text: ` <[h] ${this.status.left}`, backgroundColor, foregroundColor }],
233 | area.w,
234 | true,
235 | );
236 | const right = uiString(
237 | [{
238 | text: `${this.status.right} [l]> `,
239 | backgroundColor,
240 | foregroundColor,
241 | }],
242 | area.w,
243 | true,
244 | );
245 |
246 | const width = area.w - area.x;
247 | const height = area.h - area.y - 2 + this.scrollOffset;
248 | this.lastWidth = width;
249 |
250 | const components: TUIResult[] = [];
251 | let stuck = 1;
252 | let index = 0;
253 |
254 | for (const note of this.notes) {
255 | if (index === 0) {
256 | components.push({
257 | x: area.x,
258 | y: area.y + stuck,
259 | z: 1,
260 | content: [
261 | uiString(
262 | [{
263 | text: `┌${'─'.repeat(width - 2)}┐`,
264 | foregroundColor: [80, 80, 80],
265 | }],
266 | width,
267 | true,
268 | ),
269 | ],
270 | });
271 | } else {
272 | components.push({
273 | x: area.x,
274 | y: area.y + stuck,
275 | z: 1,
276 | content: [
277 | uiString(
278 | [{
279 | text: `├${'─'.repeat(width - 2)}┤`,
280 | foregroundColor: [80, 80, 80],
281 | }],
282 | width,
283 | true,
284 | ),
285 | ],
286 | });
287 | }
288 |
289 | stuck++;
290 | const renderedNote = Note(note, width - 6);
291 | components.push(
292 | ...renderedNote.components.map((c) => ({
293 | x: area.x + 3 + c.x,
294 | y: area.y + stuck + c.y + 1,
295 | z: 1,
296 | content: c.content,
297 | })),
298 | );
299 |
300 | for (let i = 0; i < renderedNote.height + 2; i++) {
301 | components.push({
302 | x: area.x,
303 | y: area.y + stuck + i,
304 | z: 1,
305 | content: [
306 | uiString([{ text: '│', foregroundColor: [80, 80, 80] }], 1, true),
307 | ],
308 | });
309 |
310 | components.push({
311 | x: area.x + area.w - 1,
312 | y: area.y + stuck + i,
313 | z: 1,
314 | content: [
315 | uiString([{ text: '│', foregroundColor: [80, 80, 80] }], 1, true),
316 | ],
317 | });
318 | }
319 |
320 | stuck += renderedNote.height;
321 | stuck += 2;
322 |
323 | if (index === this.notes.length - 1) {
324 | components.push({
325 | x: area.x,
326 | y: area.y + stuck,
327 | z: 1,
328 | content: [
329 | uiString(
330 | [{
331 | text: `└${'─'.repeat(width - 2)}┘`,
332 | foregroundColor: [80, 80, 80],
333 | }],
334 | width,
335 | true,
336 | ),
337 | ],
338 | });
339 | }
340 |
341 | if (stuck > height) break;
342 | index++;
343 | }
344 |
345 | stuck++;
346 |
347 | return Promise.resolve([
348 | ...components.map((v) => ({ ...v, y: v.y - this.scrollOffset })).filter(
349 | (v) => v.y >= area.y,
350 | ),
351 | {
352 | x: area.x,
353 | y: area.y,
354 | z: 2,
355 | content: [
356 | uiString(
357 | [{ text: ' '.repeat(area.w), backgroundColor }],
358 | area.w,
359 | true,
360 | ),
361 | ],
362 | },
363 | {
364 | x: Math.floor(area.x + (area.w - now.length) / 2),
365 | y: area.y,
366 | z: 4,
367 | content: [now],
368 | },
369 | ...this.status.left === null ? [] : [{
370 | x: area.x,
371 | y: area.y,
372 | z: 3,
373 | content: [left],
374 | }],
375 | ...this.status.right === null ? [] : [{
376 | x: area.w - area.x - right.length,
377 | y: area.y,
378 | z: 3,
379 | content: [right],
380 | }],
381 | ]);
382 | }
383 | }
384 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { Message } from './components/message.ts';
2 | import { StatusBar } from './components/statusbar.ts';
3 | import { TUICanvas } from './tui/index.ts';
4 | import { keyboard } from './tui/keyboard.ts';
5 | import { parse } from 'std/flags/mod.ts';
6 | import { sleep } from './util/sleep.ts';
7 | import { MisskeyAPI } from './api.ts';
8 | import { Timeline } from './components/timeline.ts';
9 | import { NoteEditor } from './noteEditor.ts';
10 |
11 | const canvas = new TUICanvas();
12 |
13 | const statusBar = new StatusBar(canvas);
14 | const connectStatus = new Message(canvas);
15 | let timeline: null | Timeline = null;
16 |
17 | canvas.components = [
18 | statusBar,
19 | connectStatus,
20 | ];
21 |
22 | keyboard.begin();
23 | await canvas.render();
24 |
25 | const parsedArgs = parse(Deno.args);
26 |
27 | let focusOn: 'timeline' | 'newpost' = 'timeline';
28 |
29 | main: {
30 | if (!('token' in parsedArgs)) {
31 | await connectStatus.setText([
32 | 'API Token was not provided!',
33 | 'Usage: lesskey --token ',
34 | ]);
35 | break main;
36 | }
37 |
38 | const origin = 'origin' in parsedArgs ? parsedArgs.origin : 'misskey.io'
39 |
40 | await connectStatus.setText([`Connecting to ${origin}`, 'Please wait...']);
41 | await sleep(300);
42 | const api = new MisskeyAPI(origin, parsedArgs.token, () => {
43 | connectStatus.setText(['Error: The token or origin is wrong.']);
44 | });
45 |
46 | await api.ws;
47 |
48 | const me = await api.getMe();
49 |
50 | if (!me.success) {
51 | await connectStatus.setText([
52 | 'An unknown error occurred during the connection.',
53 | ]);
54 | break main;
55 | }
56 |
57 | await connectStatus.setText([`Logged in as "${me.name}(@${me.username})"!`]);
58 | await sleep(1250);
59 |
60 | await statusBar.setId(`${me.username}@${origin}`);
61 | openTimeline(api);
62 | }
63 |
64 | async function openTimeline(api: MisskeyAPI) {
65 | keyboard.begin();
66 | focusOn = 'timeline';
67 | await statusBar.setText('N…New Note')
68 | timeline ??= new Timeline(canvas, api, statusBar);
69 |
70 | canvas.components = [
71 | statusBar,
72 | timeline,
73 | ];
74 |
75 | canvas.pauseRender = false;
76 | await canvas.render();
77 |
78 | keyboard.onPress(buf => {
79 | // N
80 | if(buf[0] === 78 && buf[1] === 0) {
81 | postNewNote(api);
82 | keyboard.pauseFlag = true;
83 | }
84 | });
85 | }
86 |
87 | async function postNewNote(api: MisskeyAPI) {
88 | if(focusOn === 'newpost') return;
89 | focusOn = 'newpost';
90 |
91 | await statusBar.setText('Opening TextEditor...');
92 | canvas.pauseRender = true;
93 | await sleep(300);
94 |
95 | const note = await NoteEditor('editor' in parsedArgs ? parsedArgs.editor : 'vim');
96 |
97 |
98 | if(!note.cancelled) {
99 | await api.postNote(note.content);
100 | }
101 |
102 | openTimeline(api);
103 | }
104 |
--------------------------------------------------------------------------------
/src/noteEditor.ts:
--------------------------------------------------------------------------------
1 | export const newNoteDefault = `
2 | ; ↑↑↑
3 | ; この上にあなたの投稿したい内容を書いてください。
4 | ; Please write your Note above this.
5 | ;
6 | ; セミコロンで始まる行はコメントになります。
7 | ; Lines starting with a semicolon are comments.
8 | ;
9 | `;
10 |
11 | export async function NoteEditor(editor: string, content = newNoteDefault): Promise<{ cancelled: false, content: string } | { cancelled: true }> {
12 | const file = await Deno.makeTempFile({
13 | prefix: 'Lesskey_NoteEditor_',
14 | suffix: ''
15 | });
16 |
17 | await Deno.writeTextFile(file, content);
18 |
19 | Deno.stdin.setRaw(false);
20 | await new Deno.Command(editor, { args: [file] }).spawn().status;
21 | Deno.stdin.setRaw(true);
22 |
23 | const text = await Deno.readTextFile(file);
24 | await Deno.remove(file);
25 |
26 | if(content === text) return { cancelled: true };
27 |
28 | return { cancelled: false, content: text.split(/\n/).filter(v => !v.startsWith(';')).join('\n').trim() };
29 | }
30 |
--------------------------------------------------------------------------------
/src/tui/index.ts:
--------------------------------------------------------------------------------
1 | import { writeAll } from 'std/streams/write_all.ts';
2 | import { is2Byte } from './string.ts';
3 | export interface TUIPoint {
4 | x: number;
5 | y: number;
6 | w: number;
7 | }
8 |
9 | export type TUIArea = TUIPoint & {
10 | h: number;
11 | z: number;
12 | };
13 |
14 | export type TUIResult = {
15 | x: number;
16 | y: number;
17 | z: number;
18 | content: string[][];
19 | };
20 |
21 | export type TUIRenderer = (parentArea: TUIArea) => Promise;
22 | export type TUIParent = { render: () => Promise };
23 |
24 | export abstract class TUIComponent {
25 | abstract parent: TUIParent;
26 | abstract render: TUIRenderer;
27 | }
28 |
29 | export class TUICanvas {
30 | components: TUIComponent[] = [];
31 | encoder = new TextEncoder();
32 |
33 | rendering = false;
34 | needToReRender = false;
35 |
36 | pauseRender = false;
37 |
38 | size: null | { columns: number; rows: number } = null;
39 |
40 | constructor() {
41 | setInterval(() => {
42 | if (this.size === null) return;
43 | const newSize = Deno.consoleSize();
44 | if (
45 | newSize.columns === this.size.columns && newSize.rows == this.size.rows
46 | ) return;
47 | this.render();
48 | }, 100);
49 | }
50 |
51 | async render() {
52 | if(this.pauseRender) return;
53 |
54 | if (this.rendering) {
55 | this.needToReRender = true;
56 | return;
57 | }
58 |
59 | this.rendering = true;
60 | let text = '';
61 |
62 | this.size = Deno.consoleSize();
63 | const { columns: width, rows: height } = this.size;
64 |
65 | const area: TUIArea = {
66 | x: 0,
67 | y: 0,
68 | w: width,
69 | h: height,
70 | z: -1,
71 | };
72 |
73 | const results =
74 | (await Promise.all(this.components.map((v) => v.render(area)))).flat();
75 |
76 | for (let y = 0; y < area.h; y++) {
77 | for (let x = 0; x < area.w; x++) {
78 | const onComponents = results
79 | .filter(({ x: cx, y: cy, content }) => {
80 | if (
81 | cy <= y && y < cy + content.length && cx <= x &&
82 | content[y - cy] === undefined
83 | ) {
84 | throw { content, y, x, cx, cy };
85 | }
86 | return cy <= y && y < cy + content.length && cx <= x &&
87 | x < cx + content[y - cy].length;
88 | });
89 |
90 | if (onComponents.length < 1) {
91 | text += ' ';
92 | } else {
93 | const topComponent = onComponents.reduce((a, b) => a.z > b.z ? a : b);
94 | const content =
95 | topComponent.content[y - topComponent.y][x - topComponent.x];
96 | if (x === area.w - 1 && is2Byte(content)) {
97 | text += ' ';
98 | continue;
99 | }
100 | text += content;
101 | }
102 | }
103 | if (y + 1 < area.h) text += '\n'; //`\n\x1b[${y + 2};1H`;
104 | }
105 |
106 | const encoded = this.encoder.encode(
107 | '\x1b[1;1H' + text + `\x1b[${area.h}:${area.w}H\x1b[0K`,
108 | );
109 |
110 | await writeAll(Deno.stdout, encoded);
111 |
112 | this.rendering = false;
113 | if (this.needToReRender) {
114 | this.needToReRender = false;
115 | await this.render();
116 | }
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/tui/keyboard.ts:
--------------------------------------------------------------------------------
1 | import { EventEmitter } from 'std/node/events.ts';
2 |
3 | export class TUIKeyboardListener {
4 | constructor() {}
5 |
6 | buf = new Uint8Array(16);
7 | private events = new EventEmitter();
8 | listening = false;
9 | pauseFlag = false;
10 |
11 | async begin() {
12 | if(this.listening) return;
13 | this.listening = true;
14 | Deno.stdin.setRaw(true);
15 |
16 | while (true) {
17 | this.buf.fill(0);
18 | const nread = await Deno.stdin.read(this.buf);
19 |
20 | if (nread === null) break;
21 |
22 | //Ctrl-C to break
23 | if (this.buf && this.buf[0] === 0x03) {
24 | this.exit();
25 | break;
26 | }
27 |
28 | if (this.buf) this.events.emit('press', this.buf);
29 | if (this.pauseFlag) {
30 | this.pauseFlag = false;
31 | break;
32 | }
33 | }
34 |
35 | Deno.stdin.setRaw(false);
36 | this.listening = false;
37 | }
38 |
39 | exit(){
40 | Deno.stdin.setRaw(false);
41 | Deno.exit(0);
42 | }
43 |
44 | onPress(emitter: (buf: Uint8Array) => void) {
45 | this.events.on('press', emitter);
46 | }
47 | }
48 |
49 | export const keyboard = new TUIKeyboardListener();
50 |
--------------------------------------------------------------------------------
/src/tui/string.test.ts:
--------------------------------------------------------------------------------
1 | import { assertEquals } from 'std/testing/asserts.ts';
2 | import { formatTimespan, is2Byte } from './string.ts';
3 |
4 | Deno.test('is2Byte() given numbers', () => {
5 | const actual = is2Byte('2');
6 | const expected = false;
7 | assertEquals(actual, expected);
8 |
9 | const actual2 = is2Byte('6');
10 | const expected2 = false;
11 | assertEquals(actual2, expected2);
12 | });
13 |
14 | Deno.test('is2Byte() given alphabets', () => {
15 | const actual = is2Byte('b');
16 | const expected = false;
17 | assertEquals(actual, expected);
18 |
19 | const actual2 = is2Byte('j');
20 | const expected2 = false;
21 | assertEquals(actual2, expected2);
22 | });
23 |
24 | Deno.test('is2Byte() given \'...\'', () => {
25 | const actual = is2Byte('...');
26 | const expected = false;
27 | assertEquals(actual, expected);
28 | });
29 |
30 | Deno.test('is2Byte() given some ruled lines and so on', () => {
31 | {
32 | const actual = is2Byte('┌');
33 | const expected = false;
34 | assertEquals(actual, expected);
35 | }
36 | {
37 | const actual = is2Byte('┐');
38 | const expected = false;
39 | assertEquals(actual, expected);
40 | }
41 | {
42 | const actual = is2Byte('♻️');
43 | const expected = false;
44 | assertEquals(actual, expected);
45 | }
46 | });
47 |
48 | Deno.test('formatTimeSpan() given future time', () => {
49 | assertEquals(
50 | formatTimespan(
51 | new Date('2023-02-09T01:01:30.167Z'),
52 | new Date(1675904481000),
53 | ),
54 | 'Future',
55 | );
56 | });
57 |
58 | Deno.test('formatTimeSpan() given just-now time', () => {
59 | assertEquals(
60 | formatTimespan(
61 | new Date('2023-02-09T01:02:30.400Z'),
62 | new Date(1675904554000),
63 | ),
64 | 'Just now',
65 | );
66 | assertEquals(
67 | formatTimespan(
68 | new Date('2023-02-09T01:05:04.571Z'),
69 | new Date(1675904713000),
70 | ),
71 | 'Just now',
72 | );
73 | });
74 |
75 | Deno.test('formatTimeSpan() given seconds-order time', () => {
76 | assertEquals(
77 | formatTimespan(
78 | new Date('2023-02-09T01:08:25.456Z'),
79 | new Date(1675904917000),
80 | ),
81 | '12s ago',
82 | );
83 | });
84 |
--------------------------------------------------------------------------------
/src/tui/string.ts:
--------------------------------------------------------------------------------
1 | export const is2Byte = (str: string) =>
2 | '┌┐├┤╭╮╰╯└┘│─…♻️'.includes(str)
3 | ? false // deno-lint-ignore no-control-regex
4 | : str.match(/^[^\x01-\x7E\xA1-\xDF]+$/) !== null;
5 |
6 | export function uiString(
7 | data: {
8 | text: string;
9 | foregroundColor?: [number, number, number];
10 | backgroundColor?: [number, number, number];
11 | bold?: boolean;
12 | }[],
13 | width: number,
14 | oneLine: T,
15 | ): T extends true ? string[] : string[][] {
16 | const result: string[][] = [];
17 | let cache: string[] = [];
18 |
19 | data.forEach((v) => {
20 | const before = [
21 | '\x1b[0m',
22 | v.foregroundColor === undefined
23 | ? ''
24 | : `\x1b[38;2;${v.foregroundColor.join(';')}m`,
25 | v.backgroundColor === undefined
26 | ? ''
27 | : `\x1b[48;2;${v.backgroundColor.join(';')}m`,
28 | v.bold === true ? '\x1b[1m' : '',
29 | ].join('');
30 |
31 | [...v.text].forEach((s) => {
32 | const bytes = is2Byte(s) ? 2 : 1;
33 | const content = `${before}${s}\x1b[0m`;
34 | if (cache.length + bytes > width) {
35 | while (cache.length <= width) {
36 | cache.push(' ');
37 | }
38 | result.push(cache);
39 | cache = [];
40 | }
41 | cache.push(...(bytes === 2 ? [content, ''] : [content]));
42 | });
43 | });
44 |
45 | result.push(cache);
46 |
47 | return (oneLine ? result[0] : result) as T extends true ? string[]
48 | : string[][];
49 | }
50 |
51 | export function formatTimespan(target: Date, now: Date = new Date()) {
52 | const span = now.getTime() - target.getTime();
53 | if (span < 0) return 'Future';
54 | if (span <= 10 * 1000) return 'Just now';
55 | if (span <= 60 * 1000) return `${Math.round(span / 1000)}s ago`;
56 | if (span <= 60 * 60 * 1000) return `${Math.round(span / 1000 / 60)}m ago`;
57 | if (span <= 24 * 60 * 60 * 1000) {
58 | return `${Math.round(span / 1000 / 60 / 60)}h ago`;
59 | }
60 |
61 | return `${Math.round(span / 1000 / 60 / 60 / 24)}d ago`;
62 | }
63 |
--------------------------------------------------------------------------------
/src/util/anim.ts:
--------------------------------------------------------------------------------
1 | import { StatusBar } from '../components/statusbar.ts';
2 |
3 | export type EasingFunction = (x: number) => number;
4 |
5 | export const interpolation = (
6 | x: number,
7 | xChange: [number, number],
8 | yChange: [number, number],
9 | easingFunction: EasingFunction,
10 | ) => {
11 | return yChange[0] +
12 | easingFunction((x - xChange[0]) / (xChange[1] - xChange[0])) *
13 | (yChange[1] - yChange[0]);
14 | };
15 |
16 | export const clamp: EasingFunction = (x) => Math.max(Math.min(x, 1), 0);
17 | export const easeOutExpo: EasingFunction = (x) =>
18 | clamp(x === 1 ? 1 : 1 - Math.pow(2, -10 * x));
19 |
20 | export const animation = async (
21 | change: [number, number],
22 | ms: number,
23 | action: (y: number) => Promise,
24 | easingFunction: EasingFunction,
25 | ) => {
26 | await action(change[0]);
27 | const startTime = Date.now();
28 | while (Date.now() - startTime <= ms) {
29 | await action(
30 | interpolation(
31 | Date.now(),
32 | [startTime, startTime + ms],
33 | change,
34 | easingFunction,
35 | ),
36 | );
37 | }
38 | await action(change[1]);
39 | };
40 |
41 | export class Animation {
42 | change: [[number, number], [number, number]] | null = null;
43 |
44 | constructor(
45 | public action: (y: number) => Promise,
46 | public statusBar: StatusBar,
47 | ) {}
48 |
49 | async moveTo(
50 | change: [number, number],
51 | ms: number,
52 | easingFunction: EasingFunction,
53 | ) {
54 | const isChanging = this.change !== null;
55 | this.change = [change, [Date.now(), Date.now() + ms]];
56 | if (isChanging) return;
57 | this.action(this.change[0][0]);
58 |
59 | while (Date.now() <= this.change[1][1]) {
60 | await this.action(
61 | interpolation(
62 | Date.now(),
63 | this.change[1],
64 | this.change[0],
65 | easingFunction,
66 | ),
67 | );
68 | }
69 |
70 | this.action(this.change[0][1]);
71 |
72 | this.change = null;
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/util/sleep.ts:
--------------------------------------------------------------------------------
1 | export const sleep = (msec: number) =>
2 | new Promise((resolve) => setTimeout(resolve, msec));
3 |
--------------------------------------------------------------------------------