tuigram 1.0.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/README.md +5 -0
- package/application/(common)/LinkedList.js +90 -0
- package/application/(common)/Obj/pick.js +1 -0
- package/application/(common)/Random/uuid.js +1 -0
- package/application/(common)/Rate/debounce.js +7 -0
- package/application/(common)/risk.js +8 -0
- package/application/(common)/riskAsync.js +8 -0
- package/application/1-messenger/(common)/Message.js +8 -0
- package/application/1-messenger/(methods)/getDialogs.js +24 -0
- package/application/1-messenger/(methods)/getHistory.js +25 -0
- package/application/1-messenger/(methods)/on.js +3 -0
- package/application/1-messenger/(methods)/sendMessage.js +1 -0
- package/application/1-messenger/1-tg.js +4 -0
- package/application/1-messenger/2-dispatcher.js +2 -0
- package/application/1-messenger/cache/chats.js +15 -0
- package/application/1-messenger/cache/dialogs.js +13 -0
- package/application/2-nvim/(methods)/emit.js +2 -0
- package/application/2-nvim/(methods)/input.js +1 -0
- package/application/2-nvim/(methods)/on.js +2 -0
- package/application/2-nvim/1-client.js +6 -0
- package/application/2-nvim/2-events.js +22 -0
- package/application/3-ui/1-screen/1-renderer.js +1 -0
- package/application/3-ui/1-screen/2-wrapper.js +11 -0
- package/application/3-ui/2-components/Box.js +1 -0
- package/application/3-ui/2-components/ScrollBox.js +1 -0
- package/application/3-ui/2-components/Select.js +1 -0
- package/application/3-ui/2-components/Text.js +1 -0
- package/application/3-ui/2-components/Textarea.js +1 -0
- package/application/3-ui/3-sections/1-dialogs/(private)/loadMore.js +6 -0
- package/application/3-ui/3-sections/1-dialogs/(private)/toDialog.js +1 -0
- package/application/3-ui/3-sections/1-dialogs/(public)/blur.js +3 -0
- package/application/3-ui/3-sections/1-dialogs/(public)/focus.js +3 -0
- package/application/3-ui/3-sections/1-dialogs/(public)/on.js +3 -0
- package/application/3-ui/3-sections/1-dialogs/(public)/setLabel.js +1 -0
- package/application/3-ui/3-sections/1-dialogs/1-list.js +10 -0
- package/application/3-ui/3-sections/1-dialogs/2-component.js +9 -0
- package/application/3-ui/3-sections/2-chat/(private)/clear.js +4 -0
- package/application/3-ui/3-sections/2-chat/(private)/selectMessage.js +6 -0
- package/application/3-ui/3-sections/2-chat/(public)/addMessage.js +6 -0
- package/application/3-ui/3-sections/2-chat/(public)/addPendingMessage.js +5 -0
- package/application/3-ui/3-sections/2-chat/(public)/confirmMessage.js +4 -0
- package/application/3-ui/3-sections/2-chat/(public)/down.js +4 -0
- package/application/3-ui/3-sections/2-chat/(public)/focus.js +1 -0
- package/application/3-ui/3-sections/2-chat/(public)/key.js +5 -0
- package/application/3-ui/3-sections/2-chat/(public)/open.js +8 -0
- package/application/3-ui/3-sections/2-chat/(public)/setLabel.js +1 -0
- package/application/3-ui/3-sections/2-chat/(public)/up.js +4 -0
- package/application/3-ui/3-sections/2-chat/Bubble.js +15 -0
- package/application/3-ui/3-sections/2-chat/component.js +17 -0
- package/application/3-ui/3-sections/3-messagePrompt/(private)/emit.js +2 -0
- package/application/3-ui/3-sections/3-messagePrompt/(private)/send.js +1 -0
- package/application/3-ui/3-sections/3-messagePrompt/(public)/blur.js +3 -0
- package/application/3-ui/3-sections/3-messagePrompt/(public)/focus.js +4 -0
- package/application/3-ui/3-sections/3-messagePrompt/(public)/key.js +4 -0
- package/application/3-ui/3-sections/3-messagePrompt/(public)/on.js +2 -0
- package/application/3-ui/3-sections/3-messagePrompt/(public)/setLabel.js +1 -0
- package/application/3-ui/3-sections/3-messagePrompt/1-input.js +12 -0
- package/application/3-ui/3-sections/3-messagePrompt/2-component.js +8 -0
- package/application/3-ui/3-sections/3-messagePrompt/3-events.js +11 -0
- package/application/3-ui/4-layout/(private)/select.js +5 -0
- package/application/3-ui/4-layout/(subscriptions)/mode.js +1 -0
- package/application/3-ui/4-layout/(subscriptions)/newMessage.js +3 -0
- package/application/3-ui/4-layout/(subscriptions)/openChat.js +7 -0
- package/application/3-ui/4-layout/(subscriptions)/sendMessage.js +5 -0
- package/application/3-ui/4-layout/1-layout.js +6 -0
- package/application/3-ui/4-layout/2-mapping.js +18 -0
- package/application/3-ui/4-layout/3-key.js +7 -0
- package/application/3-ui/4-layout/5-render.js +21 -0
- package/bun.lock +361 -0
- package/eslint.config.js +42 -0
- package/package.json +30 -0
- package/prettier.config.js +10 -0
- package/tsconfig.json +13 -0
- package/tuigram.js +34 -0
- package/types/global.d.ts +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# 📟 tuigram — Telegram Terminal UI Client
|
|
2
|
+
|
|
3
|
+
A lightweight, terminal-based Telegram client written in **Node.js** with a primary focus on **Vim motions**.
|
|
4
|
+
|
|
5
|
+
<img width="3840" height="2160" alt="obraz" src="https://github.com/user-attachments/assets/335b2934-ea6e-4c8b-9ae4-c8f3f89c78d0" />
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const createNode = (value, prev = null, next = null) => ({ value, prev, next });
|
|
2
|
+
|
|
3
|
+
const buildOperations = (list) => ({
|
|
4
|
+
push: (value) => {
|
|
5
|
+
const node = createNode(value, list.tail);
|
|
6
|
+
if (list.tail !== null) list.tail.next = node;
|
|
7
|
+
else list.head = node;
|
|
8
|
+
list.tail = node;
|
|
9
|
+
list.size++;
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
unshift: (value) => {
|
|
13
|
+
const node = createNode(value, null, list.head);
|
|
14
|
+
if (list.head) list.head.prev = node;
|
|
15
|
+
else list.tail = node;
|
|
16
|
+
list.head = node;
|
|
17
|
+
list.size++;
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
pop: () => {
|
|
21
|
+
if (list.tail === null) return null;
|
|
22
|
+
const value = list.tail.value;
|
|
23
|
+
list.tail = list.tail.prev;
|
|
24
|
+
if (list.tail !== null) list.tail.next = null;
|
|
25
|
+
else list.head = null;
|
|
26
|
+
list.size--;
|
|
27
|
+
return value;
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
shift: () => {
|
|
31
|
+
if (list.head === null) return null;
|
|
32
|
+
const value = list.head.value;
|
|
33
|
+
list.head = list.head.next;
|
|
34
|
+
if (list.head !== null) list.head.prev = null;
|
|
35
|
+
else list.tail = null;
|
|
36
|
+
list.size--;
|
|
37
|
+
return value;
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
find: (predicate) => {
|
|
41
|
+
for (let node = list.head; node !== null; node = node.next) if (predicate(node.value)) return node.value;
|
|
42
|
+
return null;
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
removeNode: (node) => {
|
|
46
|
+
if (node.prev !== null) node.prev.next = node.next;
|
|
47
|
+
else list.head = node.next;
|
|
48
|
+
if (node.next !== null) node.next.prev = node.prev;
|
|
49
|
+
else list.tail = node.prev;
|
|
50
|
+
list.size--;
|
|
51
|
+
return node.value;
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const buildIterator = (list) => () => {
|
|
56
|
+
let current = list.head;
|
|
57
|
+
return {
|
|
58
|
+
next: () => {
|
|
59
|
+
if (current === null) return { value: null, done: true };
|
|
60
|
+
const value = current.value;
|
|
61
|
+
current = current.next;
|
|
62
|
+
return { value, done: false };
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const from = (values = []) => {
|
|
68
|
+
const list = { head: null, tail: null, size: 0 };
|
|
69
|
+
|
|
70
|
+
const operations = buildOperations(list);
|
|
71
|
+
const iterator = buildIterator(list);
|
|
72
|
+
|
|
73
|
+
for (const value of values) operations.push(value);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
get head() {
|
|
77
|
+
return list.head;
|
|
78
|
+
},
|
|
79
|
+
get tail() {
|
|
80
|
+
return list.tail;
|
|
81
|
+
},
|
|
82
|
+
get size() {
|
|
83
|
+
return list.size;
|
|
84
|
+
},
|
|
85
|
+
...operations,
|
|
86
|
+
[Symbol.iterator]: iterator,
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
({ from });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(obj, keys) => Object.fromEntries(keys.map((key) => [key, obj[key]]));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
() => node.crypto.randomUUID();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
(() =>
|
|
2
|
+
async function* (chunkSize) {
|
|
3
|
+
console.log('get dialogs from cache');
|
|
4
|
+
|
|
5
|
+
const cachedDialogs = self.cache.dialogs.read();
|
|
6
|
+
if (cachedDialogs.length > 0) yield cachedDialogs;
|
|
7
|
+
|
|
8
|
+
console.log('get dialogs from tg');
|
|
9
|
+
const iterator = messenger.tg.iterDialogs({ chunkSize });
|
|
10
|
+
let page = [];
|
|
11
|
+
for await (const dialog of iterator) {
|
|
12
|
+
page.push({
|
|
13
|
+
chatId: dialog.lastMessage.chat.id,
|
|
14
|
+
name: dialog.peer.displayName,
|
|
15
|
+
lastMessage: dialog.lastMessage.text,
|
|
16
|
+
});
|
|
17
|
+
if (page.length === chunkSize) {
|
|
18
|
+
self.cache.dialogs.append(page);
|
|
19
|
+
yield page;
|
|
20
|
+
page = [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (page.length > 0) yield page;
|
|
24
|
+
})();
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
(() =>
|
|
2
|
+
async function* (chatId, pageSize) {
|
|
3
|
+
console.log('get history from cache');
|
|
4
|
+
const cachedMessages = self.cache.chats.read(chatId);
|
|
5
|
+
if (cachedMessages.length > 0) yield cachedMessages;
|
|
6
|
+
|
|
7
|
+
let offset = undefined;
|
|
8
|
+
|
|
9
|
+
while (true) {
|
|
10
|
+
const params = { offset, limit: pageSize };
|
|
11
|
+
|
|
12
|
+
// the first message in the array is the most recent message in the chat
|
|
13
|
+
console.log('get history from tg');
|
|
14
|
+
const history = await messenger.tg.getHistory(chatId, params);
|
|
15
|
+
|
|
16
|
+
const messages = history.map(Message.from);
|
|
17
|
+
|
|
18
|
+
self.cache.chats.append(chatId, messages);
|
|
19
|
+
|
|
20
|
+
yield messages;
|
|
21
|
+
|
|
22
|
+
if (messages.next === undefined) return;
|
|
23
|
+
offset = messages.next;
|
|
24
|
+
}
|
|
25
|
+
})();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(chatId, text) => messenger.tg.sendText(chatId, text);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
({
|
|
2
|
+
_getPath: (chatId) => node.path.join(__rootDir, `/cache/chats/${chatId}.json`),
|
|
3
|
+
read: (chatId) => {
|
|
4
|
+
const path = self._getPath(chatId);
|
|
5
|
+
const [err, json] = risk(node.fs.readFileSync, path, { encoding: 'utf-8' });
|
|
6
|
+
if (err !== null) return [];
|
|
7
|
+
return JSON.parse(json);
|
|
8
|
+
},
|
|
9
|
+
append: (chatId, messages) => {
|
|
10
|
+
const existing = self.read(chatId);
|
|
11
|
+
const updated = [...existing, ...messages];
|
|
12
|
+
const path = self._getPath(chatId);
|
|
13
|
+
node.fs.writeFileSync(path, JSON.stringify(updated));
|
|
14
|
+
},
|
|
15
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
({
|
|
2
|
+
PATH: node.path.join(__rootDir, '/cache/dialogs.json'),
|
|
3
|
+
read: () => {
|
|
4
|
+
const [err, json] = risk(node.fs.readFileSync, self.PATH, { encoding: 'utf-8' });
|
|
5
|
+
if (err !== null) return [];
|
|
6
|
+
return JSON.parse(json);
|
|
7
|
+
},
|
|
8
|
+
append: (dialogs) => {
|
|
9
|
+
const existing = self.read();
|
|
10
|
+
const updated = [...existing, ...dialogs];
|
|
11
|
+
node.fs.writeFileSync(self.PATH, JSON.stringify(updated));
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(char) => nvim.client.input(char);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
const { findNvim, attach } = npm.neovim;
|
|
2
|
+
const found = findNvim({ orderBy: 'desc', minVersion: '0.9.0' });
|
|
3
|
+
const path = found.matches[0].path;
|
|
4
|
+
const nvimProc = node.child_process.spawn(path, ['--embed']);
|
|
5
|
+
const client = attach({ proc: nvimProc, logger: console });
|
|
6
|
+
client.uiAttach(5, 5, {}).then(() => client);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const handlers = {
|
|
2
|
+
cursor_goto: async () => {
|
|
3
|
+
const [y, x] = await nvim.client.window.cursor;
|
|
4
|
+
nvim.emit('cursor', { x, y });
|
|
5
|
+
},
|
|
6
|
+
put: Rate.debounce(async () => {
|
|
7
|
+
const buffer = await nvim.client.buffer;
|
|
8
|
+
const lines = await buffer.lines;
|
|
9
|
+
const [y, x] = await nvim.client.window.cursor;
|
|
10
|
+
nvim.emit('lines', lines, { x, y });
|
|
11
|
+
}),
|
|
12
|
+
mode_change: ([[mode]]) => nvim.emit('mode', mode),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
nvim.client.on('notification', (method, args) => {
|
|
16
|
+
if (method !== 'redraw') return;
|
|
17
|
+
|
|
18
|
+
for (const [event, ...updates] of args) {
|
|
19
|
+
const handler = handlers[event];
|
|
20
|
+
if (handler !== undefined) handler(updates);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tui.createCliRenderer({ exitOnCtrlC: true });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(...options) => new tui.BoxRenderable(ui.screen.renderer, ...options);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(...options) => new tui.ScrollBoxRenderable(ui.screen.renderer, ...options);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(...options) => new tui.SelectRenderable(ui.screen.renderer, ...options);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(...options) => new tui.TextRenderable(ui.screen.renderer, ...options);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(...options) => new tui.TextareaRenderable(ui.screen.renderer, ...options);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
({ chatId, name, lastMessage }) => ({ chatId, name, description: lastMessage });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(label) => (self.component.title = label);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
() => self.selectMessage(self.selected);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
async (chatId) => {
|
|
2
|
+
self.clear();
|
|
3
|
+
self.iterator = messenger.getHistory(chatId, 20);
|
|
4
|
+
const { value } = await self.iterator.next();
|
|
5
|
+
const messages = value.toReversed();
|
|
6
|
+
for (const message of messages) self.addMessage(message);
|
|
7
|
+
self.selectMessage(self.messages.tail);
|
|
8
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(label) => (self.component.title = label);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
({ text, sender }) => {
|
|
2
|
+
const box = ui.components.Box({
|
|
3
|
+
flexDirection: 'column',
|
|
4
|
+
borderStyle: 'rounded',
|
|
5
|
+
borderColor: sender.isSelf ? '#283457' : '#1f2335',
|
|
6
|
+
paddingX: 1,
|
|
7
|
+
alignSelf: sender.isSelf ? 'flex-end' : 'flex-start',
|
|
8
|
+
focusedBorderColor: 'green',
|
|
9
|
+
focusable: true,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
box.add(ui.components.Text({ content: text, fg: '#c0caf5' }));
|
|
13
|
+
|
|
14
|
+
return box;
|
|
15
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
ui.components.ScrollBox({
|
|
2
|
+
flexGrow: 1,
|
|
3
|
+
borderStyle: 'rounded',
|
|
4
|
+
scrollY: true,
|
|
5
|
+
scrollX: false,
|
|
6
|
+
stickyScroll: true,
|
|
7
|
+
stickyStart: 'bottom',
|
|
8
|
+
focusable: true,
|
|
9
|
+
focusedBorderColor: '#42AAFF',
|
|
10
|
+
contentOptions: {
|
|
11
|
+
flexDirection: 'column',
|
|
12
|
+
gap: 1,
|
|
13
|
+
paddingX: 1,
|
|
14
|
+
paddingY: 1,
|
|
15
|
+
},
|
|
16
|
+
scrollbarOptions: { visible: false },
|
|
17
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
() => self.emit('send', self.input.plainText);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(label) => (self.component.title = label);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
ui.components.Textarea({
|
|
2
|
+
width: '100%',
|
|
3
|
+
height: 1,
|
|
4
|
+
placeholder: 'Type a message...',
|
|
5
|
+
backgroundColor: '#1a1b26',
|
|
6
|
+
focusedBackgroundColor: '#24283b',
|
|
7
|
+
textColor: '#c0caf5',
|
|
8
|
+
focusedTextColor: '#c0caf5',
|
|
9
|
+
cursorColor: '#7aa2f7',
|
|
10
|
+
wrapMode: 'word',
|
|
11
|
+
cursorStyle: { blinking: true },
|
|
12
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
nvim.on('lines', (lines, { x, y }) => {
|
|
2
|
+
if (!self.focused) return;
|
|
3
|
+
self.input.replaceText(lines.join('\n'));
|
|
4
|
+
self.input.setCursor(y - 1, x);
|
|
5
|
+
self.input.height = lines.length > 0 ? lines.length : 1;
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
nvim.on('cursor', ({ x, y }) => {
|
|
9
|
+
if (!self.focused) return;
|
|
10
|
+
self.input.setCursor(y - 1, x);
|
|
11
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nvim.on('mode', (mode) => (self.selectable = mode === 'normal'));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const mapping = {
|
|
2
|
+
1: 'dialogs',
|
|
3
|
+
2: 'chat',
|
|
4
|
+
3: 'messagePrompt',
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
for (const [index, section] of Object.entries(mapping)) {
|
|
8
|
+
ui.sections[section].setLabel(` ${index} `);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
ui.screen.renderer.keyInput.on('keypress', (key) => {
|
|
12
|
+
if (!self.selectable) return;
|
|
13
|
+
|
|
14
|
+
const section = mapping[key.name];
|
|
15
|
+
if (section !== undefined) self.select(section);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
self.reserved.push(...Object.keys(mapping));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const layout = new tui.BoxRenderable(ui.screen.renderer, {
|
|
2
|
+
id: 'wrapper',
|
|
3
|
+
width: '100%',
|
|
4
|
+
height: '100%',
|
|
5
|
+
flexDirection: 'row',
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
ui.screen.wrapper.add(layout);
|
|
9
|
+
|
|
10
|
+
layout.add(ui.sections.dialogs.component);
|
|
11
|
+
|
|
12
|
+
const rightSide = new tui.BoxRenderable(ui.screen.renderer, {
|
|
13
|
+
id: 'rightSide',
|
|
14
|
+
flexDirection: 'column',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
layout.add(rightSide);
|
|
18
|
+
|
|
19
|
+
rightSide.add(ui.sections.chat.component);
|
|
20
|
+
rightSide.add(ui.sections.messagePrompt.component);
|
|
21
|
+
ui.sections.dialogs.component.focus();
|