momer 1.0.0
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 +76 -0
- package/bin/momer.js +19 -0
- package/package.json +40 -0
- package/src/app.tsx +75 -0
- package/src/components/ChatView.tsx +283 -0
- package/src/components/ConversationList.tsx +211 -0
- package/src/components/ImageMessage.tsx +57 -0
- package/src/components/NewMessage.tsx +137 -0
- package/src/components/Onboarding.tsx +185 -0
- package/src/config.js +147 -0
- package/src/db.js +341 -0
- package/src/formatter.js +95 -0
- package/src/imageRenderer.js +129 -0
- package/src/index.tsx +187 -0
- package/src/sender.js +178 -0
- package/src/watcher.js +62 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# ✨ momer ✨
|
|
2
|
+
|
|
3
|
+
cute iMessage client for your terminal 💕
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
。・:*:・゚★,。・:*:・゚☆
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Clone and install
|
|
13
|
+
git clone <repo-url> momer
|
|
14
|
+
cd momer
|
|
15
|
+
./install.sh
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or manually:
|
|
19
|
+
```bash
|
|
20
|
+
npm install -g .
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
momer # Launch interactive UI
|
|
27
|
+
momer config # View settings
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Settings
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
momer config --theme cute # Pink & sparkly ✨
|
|
34
|
+
momer config --theme minimal # Clean & simple
|
|
35
|
+
|
|
36
|
+
momer config --view compact # Single-line messages
|
|
37
|
+
momer config --view spaced # Bubble-style messages
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Keyboard Shortcuts
|
|
41
|
+
|
|
42
|
+
**Conversation List:**
|
|
43
|
+
- `↑↓` Navigate
|
|
44
|
+
- `⏎` Open chat
|
|
45
|
+
- `n` New message
|
|
46
|
+
- `r` Refresh
|
|
47
|
+
- `q` Quit
|
|
48
|
+
|
|
49
|
+
**Chat View:**
|
|
50
|
+
- `↑↓` Scroll
|
|
51
|
+
- `⏎` Send message
|
|
52
|
+
- `esc` Back
|
|
53
|
+
|
|
54
|
+
## Requirements
|
|
55
|
+
|
|
56
|
+
- macOS (uses iMessage database)
|
|
57
|
+
- Node.js 18+
|
|
58
|
+
- Full Disk Access permission for your terminal
|
|
59
|
+
|
|
60
|
+
### Grant Full Disk Access
|
|
61
|
+
|
|
62
|
+
1. Open **System Settings**
|
|
63
|
+
2. Go to **Privacy & Security** > **Full Disk Access**
|
|
64
|
+
3. Add your terminal app (Terminal, iTerm2, etc.)
|
|
65
|
+
|
|
66
|
+
## Uninstall
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
./uninstall.sh
|
|
70
|
+
# or
|
|
71
|
+
npm uninstall -g momer
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
☆゚・:*:・。 momer ★゚・:*:・。
|
|
76
|
+
```
|
package/bin/momer.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'child_process';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname, join } from 'path';
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const entryPoint = join(__dirname, '..', 'src', 'index.tsx');
|
|
9
|
+
|
|
10
|
+
const result = spawnSync(
|
|
11
|
+
'npx',
|
|
12
|
+
['tsx', entryPoint, ...process.argv.slice(2)],
|
|
13
|
+
{
|
|
14
|
+
stdio: 'inherit',
|
|
15
|
+
cwd: join(__dirname, '..'),
|
|
16
|
+
}
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
process.exit(result.status || 0);
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "momer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "cute iMessage client for your terminal ✨",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.tsx",
|
|
7
|
+
"bin": {
|
|
8
|
+
"momer": "./bin/momer.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "tsx src/index.tsx",
|
|
12
|
+
"postinstall": "echo '\\n✨ momer installed! Run: momer\\n'"
|
|
13
|
+
},
|
|
14
|
+
"keywords": ["imessage", "cli", "macos", "messages", "terminal", "cute"],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": ""
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18.0.0"
|
|
23
|
+
},
|
|
24
|
+
"os": ["darwin"],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"better-sqlite3": "^11.0.0",
|
|
27
|
+
"chalk": "^5.3.0",
|
|
28
|
+
"commander": "^12.0.0",
|
|
29
|
+
"date-fns": "^3.0.0",
|
|
30
|
+
"ink": "^5.0.1",
|
|
31
|
+
"ink-text-input": "^6.0.0",
|
|
32
|
+
"react": "^18.2.0",
|
|
33
|
+
"sharp": "^0.33.0",
|
|
34
|
+
"tsx": "^4.7.0"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"bin",
|
|
38
|
+
"src"
|
|
39
|
+
]
|
|
40
|
+
}
|
package/src/app.tsx
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, useApp, useInput } from 'ink';
|
|
3
|
+
import ConversationList from './components/ConversationList.js';
|
|
4
|
+
import ChatView from './components/ChatView.js';
|
|
5
|
+
import NewMessage from './components/NewMessage.js';
|
|
6
|
+
import Onboarding from './components/Onboarding.js';
|
|
7
|
+
import { closeDatabase } from './db.js';
|
|
8
|
+
import { isFirstRun } from './config.js';
|
|
9
|
+
|
|
10
|
+
export default function App() {
|
|
11
|
+
const { exit } = useApp();
|
|
12
|
+
const [screen, setScreen] = useState(isFirstRun() ? 'onboarding' : 'list');
|
|
13
|
+
const [selectedContact, setSelectedContact] = useState(null);
|
|
14
|
+
|
|
15
|
+
// Cleanup on unmount
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
return () => {
|
|
18
|
+
closeDatabase();
|
|
19
|
+
};
|
|
20
|
+
}, []);
|
|
21
|
+
|
|
22
|
+
// Handle opening a chat
|
|
23
|
+
const openChat = (conversation) => {
|
|
24
|
+
setSelectedContact(conversation);
|
|
25
|
+
setScreen('chat');
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Handle going back to list
|
|
29
|
+
const goBack = () => {
|
|
30
|
+
setSelectedContact(null);
|
|
31
|
+
setScreen('list');
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Handle new message screen
|
|
35
|
+
const openNewMessage = () => {
|
|
36
|
+
setScreen('new');
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Global quit handler
|
|
40
|
+
useInput((input, key) => {
|
|
41
|
+
if (input === 'q' && screen === 'list') {
|
|
42
|
+
closeDatabase();
|
|
43
|
+
exit();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Box flexDirection="column" height="100%">
|
|
49
|
+
{screen === 'onboarding' && (
|
|
50
|
+
<Onboarding onComplete={() => setScreen('list')} />
|
|
51
|
+
)}
|
|
52
|
+
{screen === 'list' && (
|
|
53
|
+
<ConversationList
|
|
54
|
+
onSelect={openChat}
|
|
55
|
+
onNew={openNewMessage}
|
|
56
|
+
/>
|
|
57
|
+
)}
|
|
58
|
+
{screen === 'chat' && selectedContact && (
|
|
59
|
+
<ChatView
|
|
60
|
+
contact={selectedContact}
|
|
61
|
+
onBack={goBack}
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
64
|
+
{screen === 'new' && (
|
|
65
|
+
<NewMessage
|
|
66
|
+
onBack={goBack}
|
|
67
|
+
onSent={(contact) => {
|
|
68
|
+
setSelectedContact(contact);
|
|
69
|
+
setScreen('chat');
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
)}
|
|
73
|
+
</Box>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import { getMessages, getMessagesSince, refreshDatabase } from '../db.js';
|
|
5
|
+
import { sendMessage } from '../sender.js';
|
|
6
|
+
import { getTheme, isCuteMode, isCompactMode } from '../config.js';
|
|
7
|
+
import { isImagePath } from '../imageRenderer.js';
|
|
8
|
+
import ImageMessage from './ImageMessage.js';
|
|
9
|
+
import { format } from 'date-fns';
|
|
10
|
+
|
|
11
|
+
const VISIBLE_MESSAGES = 12;
|
|
12
|
+
const POLL_INTERVAL = 1500;
|
|
13
|
+
const MESSAGE_AREA_HEIGHT = 18;
|
|
14
|
+
|
|
15
|
+
function formatTime(date) {
|
|
16
|
+
if (!date) return '';
|
|
17
|
+
return format(date, 'h:mm a');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function ChatView({ contact, onBack }) {
|
|
21
|
+
const [messages, setMessages] = useState([]);
|
|
22
|
+
const [inputValue, setInputValue] = useState('');
|
|
23
|
+
const [sending, setSending] = useState(false);
|
|
24
|
+
const [error, setError] = useState(null);
|
|
25
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
26
|
+
const lastMessageId = useRef(0);
|
|
27
|
+
const pollRef = useRef(null);
|
|
28
|
+
|
|
29
|
+
const theme = getTheme();
|
|
30
|
+
const cute = isCuteMode();
|
|
31
|
+
const compact = isCompactMode();
|
|
32
|
+
|
|
33
|
+
const termWidth = process.stdout.columns || 60;
|
|
34
|
+
const bubbleMaxWidth = Math.floor((termWidth - 10) * 0.65);
|
|
35
|
+
const imageMaxWidth = Math.floor(bubbleMaxWidth * 0.8);
|
|
36
|
+
|
|
37
|
+
// Load all messages from DB
|
|
38
|
+
const loadMessages = () => {
|
|
39
|
+
try {
|
|
40
|
+
const msgs = getMessages(null, 100, contact.chatId);
|
|
41
|
+
// Deduplicate by message ID (attachment joins can create duplicates)
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const unique = msgs.filter(m => {
|
|
44
|
+
if (seen.has(m.id)) return false;
|
|
45
|
+
seen.add(m.id);
|
|
46
|
+
return true;
|
|
47
|
+
});
|
|
48
|
+
// Filter: keep messages with text OR image attachments
|
|
49
|
+
const filtered = unique.filter(m =>
|
|
50
|
+
(m.text && m.text.trim()) ||
|
|
51
|
+
(m.attachmentPath && isImagePath(m.attachmentPath))
|
|
52
|
+
);
|
|
53
|
+
setMessages(filtered);
|
|
54
|
+
if (filtered.length > 0) {
|
|
55
|
+
lastMessageId.current = Math.max(...filtered.map(m => m.id));
|
|
56
|
+
}
|
|
57
|
+
setScrollOffset(Math.max(0, filtered.length - VISIBLE_MESSAGES));
|
|
58
|
+
} catch (err) {
|
|
59
|
+
setError(err.message);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Check for new messages
|
|
64
|
+
const checkNewMessages = () => {
|
|
65
|
+
try {
|
|
66
|
+
refreshDatabase(); // Refresh to see Messages app changes
|
|
67
|
+
const newMsgs = getMessagesSince(lastMessageId.current, null, contact.chatId);
|
|
68
|
+
// Deduplicate by message ID (attachment joins can create duplicates)
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
const unique = newMsgs.filter(m => {
|
|
71
|
+
if (seen.has(m.id)) return false;
|
|
72
|
+
seen.add(m.id);
|
|
73
|
+
return true;
|
|
74
|
+
});
|
|
75
|
+
const filtered = unique.filter(m =>
|
|
76
|
+
(m.text && m.text.trim()) ||
|
|
77
|
+
(m.attachmentPath && isImagePath(m.attachmentPath))
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (filtered.length > 0) {
|
|
81
|
+
setMessages(prev => {
|
|
82
|
+
const existingIds = new Set(prev.map(m => m.id));
|
|
83
|
+
const uniqueNew = filtered.filter(m => !existingIds.has(m.id));
|
|
84
|
+
|
|
85
|
+
if (uniqueNew.length === 0) return prev;
|
|
86
|
+
|
|
87
|
+
const updated = [...prev, ...uniqueNew];
|
|
88
|
+
lastMessageId.current = Math.max(...updated.map(m => m.id));
|
|
89
|
+
setScrollOffset(Math.max(0, updated.length - VISIBLE_MESSAGES));
|
|
90
|
+
return updated;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// Silently ignore
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
loadMessages();
|
|
100
|
+
pollRef.current = setInterval(checkNewMessages, POLL_INTERVAL);
|
|
101
|
+
return () => {
|
|
102
|
+
if (pollRef.current) clearInterval(pollRef.current);
|
|
103
|
+
};
|
|
104
|
+
}, [contact.chatId]);
|
|
105
|
+
|
|
106
|
+
useInput((input, key) => {
|
|
107
|
+
if (key.escape) {
|
|
108
|
+
onBack();
|
|
109
|
+
} else if (key.upArrow) {
|
|
110
|
+
setScrollOffset(prev => Math.max(0, prev - 1));
|
|
111
|
+
} else if (key.downArrow) {
|
|
112
|
+
setScrollOffset(prev => Math.min(Math.max(0, messages.length - VISIBLE_MESSAGES), prev + 1));
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const handleSubmit = (value) => {
|
|
117
|
+
if (!value.trim() || sending) return;
|
|
118
|
+
|
|
119
|
+
setSending(true);
|
|
120
|
+
setError(null);
|
|
121
|
+
setInputValue('');
|
|
122
|
+
|
|
123
|
+
const recipient = contact.contactId || contact.identifier;
|
|
124
|
+
const result = sendMessage(recipient, value.trim(), {
|
|
125
|
+
isGroup: contact.isGroup,
|
|
126
|
+
service: contact.service,
|
|
127
|
+
chatIdentifier: contact.identifier
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (result.success) {
|
|
131
|
+
// Wait longer for Messages app to write to database, then refresh connection
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
refreshDatabase();
|
|
134
|
+
loadMessages();
|
|
135
|
+
setSending(false);
|
|
136
|
+
}, 1500);
|
|
137
|
+
} else {
|
|
138
|
+
setError(result.error);
|
|
139
|
+
setSending(false);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const visibleMessages = messages.slice(
|
|
144
|
+
scrollOffset,
|
|
145
|
+
scrollOffset + VISIBLE_MESSAGES
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const displayName = contact.displayName || contact.contactId || contact.identifier;
|
|
149
|
+
const isGroup = contact.isGroup;
|
|
150
|
+
|
|
151
|
+
// Check if message has an image
|
|
152
|
+
const hasImage = (msg) => msg.attachmentPath && isImagePath(msg.attachmentPath);
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<Box flexDirection="column">
|
|
156
|
+
{/* Decorative top */}
|
|
157
|
+
{cute && theme.decorTop && (
|
|
158
|
+
<Box justifyContent="center">
|
|
159
|
+
<Text color={theme.primary}>{theme.decorTop}</Text>
|
|
160
|
+
</Box>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
{/* Header */}
|
|
164
|
+
<Text color={theme.headerBorder}>╭{'─'.repeat(termWidth - 2)}╮</Text>
|
|
165
|
+
<Box width={termWidth}>
|
|
166
|
+
<Text color={theme.headerBorder}>│ </Text>
|
|
167
|
+
<Text>
|
|
168
|
+
{isGroup && <Text>{theme.group} </Text>}
|
|
169
|
+
<Text bold color={theme.primary}>{displayName}</Text>
|
|
170
|
+
{cute && <Text> 💕</Text>}
|
|
171
|
+
</Text>
|
|
172
|
+
<Text>{' '}</Text>
|
|
173
|
+
{isGroup && <Text dimColor>{contact.participantCount} people</Text>}
|
|
174
|
+
</Box>
|
|
175
|
+
<Text color={theme.headerBorder}>╰{'─'.repeat(termWidth - 2)}╯</Text>
|
|
176
|
+
|
|
177
|
+
{/* Messages */}
|
|
178
|
+
<Box
|
|
179
|
+
flexDirection="column"
|
|
180
|
+
borderStyle="round"
|
|
181
|
+
borderColor={theme.border}
|
|
182
|
+
paddingX={1}
|
|
183
|
+
flexGrow={0}
|
|
184
|
+
flexShrink={0}
|
|
185
|
+
>
|
|
186
|
+
{visibleMessages.length === 0 ? (
|
|
187
|
+
<Text dimColor>{theme.noMessages}</Text>
|
|
188
|
+
) : compact ? (
|
|
189
|
+
/* Compact view - single line per message */
|
|
190
|
+
visibleMessages.map((msg) => (
|
|
191
|
+
<Box
|
|
192
|
+
key={msg.id}
|
|
193
|
+
flexDirection="row"
|
|
194
|
+
justifyContent={msg.isFromMe ? 'flex-end' : 'flex-start'}
|
|
195
|
+
>
|
|
196
|
+
{hasImage(msg) && (
|
|
197
|
+
<Text dimColor>[img] </Text>
|
|
198
|
+
)}
|
|
199
|
+
{msg.text && msg.text.trim() && (
|
|
200
|
+
<Text>
|
|
201
|
+
{!msg.isFromMe && <Text dimColor>{msg.sender}: </Text>}
|
|
202
|
+
<Text color={msg.isFromMe ? theme.sent : 'white'}>{msg.text.slice(0, 50)}{msg.text.length > 50 ? '…' : ''}</Text>
|
|
203
|
+
<Text dimColor> {formatTime(msg.date)}</Text>
|
|
204
|
+
</Text>
|
|
205
|
+
)}
|
|
206
|
+
</Box>
|
|
207
|
+
))
|
|
208
|
+
) : (
|
|
209
|
+
/* Spaced view - bubbles with more detail */
|
|
210
|
+
visibleMessages.map((msg, index) => (
|
|
211
|
+
<Box
|
|
212
|
+
key={msg.id}
|
|
213
|
+
flexDirection="column"
|
|
214
|
+
alignItems={msg.isFromMe ? 'flex-end' : 'flex-start'}
|
|
215
|
+
marginBottom={index < visibleMessages.length - 1 ? 1 : 0}
|
|
216
|
+
>
|
|
217
|
+
{hasImage(msg) && (
|
|
218
|
+
<Box marginBottom={msg.text ? 0 : 0}>
|
|
219
|
+
<ImageMessage path={msg.attachmentPath} maxWidth={imageMaxWidth} />
|
|
220
|
+
</Box>
|
|
221
|
+
)}
|
|
222
|
+
{msg.text && msg.text.trim() && (
|
|
223
|
+
<Box
|
|
224
|
+
borderStyle="round"
|
|
225
|
+
borderColor={msg.isFromMe ? theme.sent : 'gray'}
|
|
226
|
+
paddingX={1}
|
|
227
|
+
maxWidth={bubbleMaxWidth}
|
|
228
|
+
>
|
|
229
|
+
<Text color={msg.isFromMe ? theme.sent : 'white'} wrap="wrap">
|
|
230
|
+
{msg.text}
|
|
231
|
+
</Text>
|
|
232
|
+
</Box>
|
|
233
|
+
)}
|
|
234
|
+
<Text dimColor>
|
|
235
|
+
{!msg.isFromMe && `${msg.sender} · `}
|
|
236
|
+
{formatTime(msg.date)}
|
|
237
|
+
</Text>
|
|
238
|
+
</Box>
|
|
239
|
+
))
|
|
240
|
+
)}
|
|
241
|
+
|
|
242
|
+
{/* Scroll indicator */}
|
|
243
|
+
{messages.length > VISIBLE_MESSAGES && (
|
|
244
|
+
<Box justifyContent="center">
|
|
245
|
+
<Text dimColor>
|
|
246
|
+
{scrollOffset > 0 && '↑ '}
|
|
247
|
+
{Math.min(scrollOffset + VISIBLE_MESSAGES, messages.length)}/{messages.length}
|
|
248
|
+
{scrollOffset + VISIBLE_MESSAGES < messages.length && ' ↓'}
|
|
249
|
+
</Text>
|
|
250
|
+
</Box>
|
|
251
|
+
)}
|
|
252
|
+
</Box>
|
|
253
|
+
|
|
254
|
+
{/* Error */}
|
|
255
|
+
{error && (
|
|
256
|
+
<Box paddingX={1}>
|
|
257
|
+
<Text color="red">{cute ? '💔 ' : '✗ '}{error}</Text>
|
|
258
|
+
</Box>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{/* Input */}
|
|
262
|
+
<Box borderStyle="round" borderColor={sending ? 'yellow' : theme.secondary} paddingX={1}>
|
|
263
|
+
<Text color={theme.secondary}>{sending ? '...' : '>'} </Text>
|
|
264
|
+
<TextInput
|
|
265
|
+
value={inputValue}
|
|
266
|
+
onChange={setInputValue}
|
|
267
|
+
onSubmit={handleSubmit}
|
|
268
|
+
placeholder={sending ? 'sending...' : 'Type a message...'}
|
|
269
|
+
/>
|
|
270
|
+
</Box>
|
|
271
|
+
|
|
272
|
+
{/* Status Bar */}
|
|
273
|
+
<Box borderStyle="round" borderColor="gray" paddingX={1}>
|
|
274
|
+
{cute && <Text color={theme.primary}>{theme.decorBottom} </Text>}
|
|
275
|
+
<Text dimColor>
|
|
276
|
+
<Text color={theme.secondary}>esc</Text> back
|
|
277
|
+
<Text color={theme.secondary}> ↑↓</Text> scroll
|
|
278
|
+
<Text color={theme.secondary}> ⏎</Text> send
|
|
279
|
+
</Text>
|
|
280
|
+
</Box>
|
|
281
|
+
</Box>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { getConversations, refreshDatabase } from '../db.js';
|
|
4
|
+
import { getTheme, isCuteMode, isCompactMode } from '../config.js';
|
|
5
|
+
import { formatDistanceToNow } from 'date-fns';
|
|
6
|
+
|
|
7
|
+
const VISIBLE_COUNT = 12;
|
|
8
|
+
const POLL_INTERVAL = 3000;
|
|
9
|
+
const LIST_HEIGHT = 18;
|
|
10
|
+
|
|
11
|
+
function truncate(str, len) {
|
|
12
|
+
if (!str) return '';
|
|
13
|
+
const clean = str.replace(/\n/g, ' ').trim();
|
|
14
|
+
return clean.length > len ? clean.slice(0, len - 1) + '…' : clean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatTime(date) {
|
|
18
|
+
if (!date) return '';
|
|
19
|
+
return formatDistanceToNow(date, { addSuffix: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function ConversationList({ onSelect, onNew }) {
|
|
23
|
+
const [conversations, setConversations] = useState([]);
|
|
24
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
25
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
26
|
+
const [loading, setLoading] = useState(true);
|
|
27
|
+
const [error, setError] = useState(null);
|
|
28
|
+
|
|
29
|
+
const theme = getTheme();
|
|
30
|
+
const cute = isCuteMode();
|
|
31
|
+
const compact = isCompactMode();
|
|
32
|
+
|
|
33
|
+
const loadConversations = () => {
|
|
34
|
+
try {
|
|
35
|
+
refreshDatabase(); // Refresh to see Messages app changes
|
|
36
|
+
const convos = getConversations(50);
|
|
37
|
+
setConversations(convos);
|
|
38
|
+
setLoading(false);
|
|
39
|
+
setError(null);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
setError(err.message);
|
|
42
|
+
setLoading(false);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
loadConversations();
|
|
48
|
+
const interval = setInterval(loadConversations, POLL_INTERVAL);
|
|
49
|
+
return () => clearInterval(interval);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
useInput((input, key) => {
|
|
53
|
+
if (key.upArrow) {
|
|
54
|
+
setSelectedIndex((prev) => {
|
|
55
|
+
const newIndex = Math.max(0, prev - 1);
|
|
56
|
+
if (newIndex < scrollOffset) {
|
|
57
|
+
setScrollOffset(newIndex);
|
|
58
|
+
}
|
|
59
|
+
return newIndex;
|
|
60
|
+
});
|
|
61
|
+
} else if (key.downArrow) {
|
|
62
|
+
setSelectedIndex((prev) => {
|
|
63
|
+
const newIndex = Math.min(conversations.length - 1, prev + 1);
|
|
64
|
+
if (newIndex >= scrollOffset + VISIBLE_COUNT) {
|
|
65
|
+
setScrollOffset(newIndex - VISIBLE_COUNT + 1);
|
|
66
|
+
}
|
|
67
|
+
return newIndex;
|
|
68
|
+
});
|
|
69
|
+
} else if (key.return) {
|
|
70
|
+
if (conversations[selectedIndex]) {
|
|
71
|
+
onSelect(conversations[selectedIndex]);
|
|
72
|
+
}
|
|
73
|
+
} else if (input === 'n') {
|
|
74
|
+
onNew();
|
|
75
|
+
} else if (input === 'r') {
|
|
76
|
+
setLoading(true);
|
|
77
|
+
loadConversations();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const visibleConversations = conversations.slice(
|
|
82
|
+
scrollOffset,
|
|
83
|
+
scrollOffset + VISIBLE_COUNT
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const termWidth = process.stdout.columns || 60;
|
|
87
|
+
const boxWidth = Math.min(termWidth - 4, 65);
|
|
88
|
+
|
|
89
|
+
if (error) {
|
|
90
|
+
return (
|
|
91
|
+
<Box flexDirection="column" borderStyle="round" borderColor="red" paddingX={1}>
|
|
92
|
+
<Text color="red" bold>Error</Text>
|
|
93
|
+
<Text>{error}</Text>
|
|
94
|
+
<Text dimColor>Press r to retry</Text>
|
|
95
|
+
</Box>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<Box flexDirection="column">
|
|
101
|
+
{/* Decorative top */}
|
|
102
|
+
{cute && theme.decorTop && (
|
|
103
|
+
<Box justifyContent="center">
|
|
104
|
+
<Text color={theme.primary}>{theme.decorTop}</Text>
|
|
105
|
+
</Box>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{/* Header */}
|
|
109
|
+
<Text color={theme.headerBorder}>╭{'─'.repeat(termWidth - 2)}╮</Text>
|
|
110
|
+
<Box width={termWidth}>
|
|
111
|
+
<Text color={theme.headerBorder}>│ </Text>
|
|
112
|
+
<Text bold color={theme.primary}>{theme.title}</Text>
|
|
113
|
+
<Text>{' '}</Text>
|
|
114
|
+
<Text dimColor>{conversations.length} chats</Text>
|
|
115
|
+
</Box>
|
|
116
|
+
<Text color={theme.headerBorder}>╰{'─'.repeat(termWidth - 2)}╯</Text>
|
|
117
|
+
|
|
118
|
+
{/* Conversation List */}
|
|
119
|
+
<Box
|
|
120
|
+
flexDirection="column"
|
|
121
|
+
borderStyle="round"
|
|
122
|
+
borderColor={theme.border}
|
|
123
|
+
paddingX={1}
|
|
124
|
+
flexGrow={0}
|
|
125
|
+
flexShrink={0}
|
|
126
|
+
>
|
|
127
|
+
{loading ? (
|
|
128
|
+
<Text dimColor>Loading...</Text>
|
|
129
|
+
) : conversations.length === 0 ? (
|
|
130
|
+
<Text dimColor>No conversations</Text>
|
|
131
|
+
) : compact ? (
|
|
132
|
+
/* Compact view - single line */
|
|
133
|
+
visibleConversations.map((conv, index) => {
|
|
134
|
+
const actualIndex = scrollOffset + index;
|
|
135
|
+
const isSelected = actualIndex === selectedIndex;
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<Box key={conv.chatId}>
|
|
139
|
+
<Text color={isSelected ? theme.primary : 'white'}>
|
|
140
|
+
{isSelected ? '>' : ' '}
|
|
141
|
+
</Text>
|
|
142
|
+
{conv.isGroup && <Text>{theme.group}</Text>}
|
|
143
|
+
<Text bold={isSelected} color={isSelected ? theme.primary : 'white'}>
|
|
144
|
+
{truncate(conv.displayName, 20)}
|
|
145
|
+
</Text>
|
|
146
|
+
<Text dimColor> {conv.isFromMe ? 'You: ' : ''}{truncate(conv.lastMessage || '', 25)}</Text>
|
|
147
|
+
<Box flexGrow={1} />
|
|
148
|
+
<Text dimColor>{formatTime(conv.lastDate)}</Text>
|
|
149
|
+
</Box>
|
|
150
|
+
);
|
|
151
|
+
})
|
|
152
|
+
) : (
|
|
153
|
+
/* Spaced view - two lines per conversation */
|
|
154
|
+
visibleConversations.map((conv, index) => {
|
|
155
|
+
const actualIndex = scrollOffset + index;
|
|
156
|
+
const isSelected = actualIndex === selectedIndex;
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<Box
|
|
160
|
+
key={conv.chatId}
|
|
161
|
+
flexDirection="column"
|
|
162
|
+
marginBottom={index < visibleConversations.length - 1 ? 1 : 0}
|
|
163
|
+
>
|
|
164
|
+
<Box>
|
|
165
|
+
<Text color={isSelected ? theme.primary : 'white'}>
|
|
166
|
+
{isSelected ? '> ' : ' '}
|
|
167
|
+
</Text>
|
|
168
|
+
{conv.isGroup && <Text>{theme.group} </Text>}
|
|
169
|
+
<Text bold color={isSelected ? theme.primary : 'white'}>
|
|
170
|
+
{truncate(conv.displayName, conv.isGroup ? 25 : 28)}
|
|
171
|
+
</Text>
|
|
172
|
+
<Box flexGrow={1} />
|
|
173
|
+
<Text dimColor>{formatTime(conv.lastDate)}</Text>
|
|
174
|
+
</Box>
|
|
175
|
+
<Box paddingLeft={3}>
|
|
176
|
+
<Text dimColor>
|
|
177
|
+
{conv.isFromMe ? 'You: ' : ''}
|
|
178
|
+
{truncate(conv.lastMessage || '', boxWidth - 12)}
|
|
179
|
+
</Text>
|
|
180
|
+
</Box>
|
|
181
|
+
</Box>
|
|
182
|
+
);
|
|
183
|
+
})
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
{/* Scroll indicator */}
|
|
187
|
+
{conversations.length > VISIBLE_COUNT && (
|
|
188
|
+
<Box justifyContent="center">
|
|
189
|
+
<Text dimColor>
|
|
190
|
+
{scrollOffset > 0 && '↑ '}
|
|
191
|
+
{selectedIndex + 1}/{conversations.length}
|
|
192
|
+
{scrollOffset + VISIBLE_COUNT < conversations.length && ' ↓'}
|
|
193
|
+
</Text>
|
|
194
|
+
</Box>
|
|
195
|
+
)}
|
|
196
|
+
</Box>
|
|
197
|
+
|
|
198
|
+
{/* Status Bar */}
|
|
199
|
+
<Box borderStyle="round" borderColor="gray" paddingX={1}>
|
|
200
|
+
{cute && <Text color={theme.primary}>{theme.decorBottom} </Text>}
|
|
201
|
+
<Text dimColor>
|
|
202
|
+
<Text color={theme.secondary}>↑↓</Text> navigate
|
|
203
|
+
<Text color={theme.secondary}> ⏎</Text> open
|
|
204
|
+
<Text color={theme.secondary}> n</Text> new
|
|
205
|
+
<Text color={theme.secondary}> r</Text> refresh
|
|
206
|
+
<Text color={theme.secondary}> q</Text> quit
|
|
207
|
+
</Text>
|
|
208
|
+
</Box>
|
|
209
|
+
</Box>
|
|
210
|
+
);
|
|
211
|
+
}
|