├── .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 | --------------------------------------------------------------------------------