59chat 0.3.1__tar.gz

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.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: 59chat
3
+ Version: 0.3.1
4
+ Summary: 59-second zero-trace terminal chat (Ultra-Light Edition)
5
+ Home-page: https://github.com/yourusername/59chat
6
+ Author: YourName
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: textual>=0.45.0
10
+ Requires-Dist: httpx>=0.25.0
11
+ Requires-Dist: pyperclip>=1.8.2
12
+ Dynamic: author
13
+ Dynamic: description
14
+ Dynamic: description-content-type
15
+ Dynamic: home-page
16
+ Dynamic: requires-dist
17
+ Dynamic: requires-python
18
+ Dynamic: summary
19
+
20
+ # Ephemeral Terminal Chat
21
+
22
+ Zero-trace terminal chat application with 59-second disappearing messages.
23
+
24
+ ## Features
25
+
26
+ - Messages disappear after 59 seconds
27
+ - Zero trace - no history kept
28
+ - Fast terminal UI with Textual framework
29
+ - Mono font recommended (set in your terminal, e.g., JetBrains Mono)
30
+ - Supabase backend for real-time sync
31
+ - Share rooms with 6-character room codes
32
+ - Random nickname generation
33
+
34
+ ## Setup
35
+
36
+ ### 1. Supabase Setup
37
+
38
+ 1. Go to [supabase.com](https://supabase.com) and create a free account
39
+ 2. Create a new project
40
+ 3. Go to SQL Editor and run the `supabase_setup.sql` file
41
+ 4. Get your project URL and anon key from Settings > API
42
+
43
+ ### 2. Environment Variables
44
+
45
+ Copy `.env.example` to `.env` and add your Supabase credentials:
46
+
47
+ ```bash
48
+ cp .env.example .env
49
+ ```
50
+
51
+ Edit `.env`:
52
+ ```
53
+ SUPABASE_URL=your_supabase_project_url
54
+ SUPABASE_KEY=your_supabase_anon_key
55
+ ```
56
+
57
+ ### 3. Install Dependencies
58
+
59
+ ```bash
60
+ pip install -r requirements.txt
61
+ ```
62
+
63
+ ### 4. Run
64
+
65
+ ```bash
66
+ python main.py
67
+ ```
68
+
69
+ ## Usage
70
+
71
+ - **Type a message** and press Enter to send
72
+ - **Ctrl+R** - Create new room
73
+ - **Ctrl+C** - Quit
74
+ - Share the **room code** with others to chat
75
+ - Messages auto-delete after 59 seconds
76
+
77
+ ## How It Works
78
+
79
+ - Each room has a unique 6-character code
80
+ - Messages are stored in Supabase with timestamp
81
+ - Auto-cleanup deletes messages older than 59 seconds
82
+ - Terminal polls for new messages every second
83
+ - Zero-trace: no message history retained
84
+
85
+ ## Terminal Share
86
+
87
+ Share your terminal with:
88
+ - tmate: `tmate`
89
+ - warp: Share session feature
90
+ - ssh: Allow remote connections
91
+ - Or just share the room code via any channel
@@ -0,0 +1,14 @@
1
+ MANIFEST.in
2
+ README.md
3
+ main.py
4
+ requirements.txt
5
+ setup.py
6
+ 59chat.egg-info/PKG-INFO
7
+ 59chat.egg-info/SOURCES.txt
8
+ 59chat.egg-info/dependency_links.txt
9
+ 59chat.egg-info/entry_points.txt
10
+ 59chat.egg-info/requires.txt
11
+ 59chat.egg-info/top_level.txt
12
+ chat59/__init__.py
13
+ chat59/__main__.py
14
+ chat59/main.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ 59chat = chat59.main:main_func
@@ -0,0 +1,3 @@
1
+ textual>=0.45.0
2
+ httpx>=0.25.0
3
+ pyperclip>=1.8.2
@@ -0,0 +1 @@
1
+ chat59
@@ -0,0 +1,5 @@
1
+ include README.md
2
+ include requirements.txt
3
+ exclude .env
4
+ exclude .env.example
5
+ recursive-exclude tests *
59chat-0.3.1/PKG-INFO ADDED
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: 59chat
3
+ Version: 0.3.1
4
+ Summary: 59-second zero-trace terminal chat (Ultra-Light Edition)
5
+ Home-page: https://github.com/yourusername/59chat
6
+ Author: YourName
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: textual>=0.45.0
10
+ Requires-Dist: httpx>=0.25.0
11
+ Requires-Dist: pyperclip>=1.8.2
12
+ Dynamic: author
13
+ Dynamic: description
14
+ Dynamic: description-content-type
15
+ Dynamic: home-page
16
+ Dynamic: requires-dist
17
+ Dynamic: requires-python
18
+ Dynamic: summary
19
+
20
+ # Ephemeral Terminal Chat
21
+
22
+ Zero-trace terminal chat application with 59-second disappearing messages.
23
+
24
+ ## Features
25
+
26
+ - Messages disappear after 59 seconds
27
+ - Zero trace - no history kept
28
+ - Fast terminal UI with Textual framework
29
+ - Mono font recommended (set in your terminal, e.g., JetBrains Mono)
30
+ - Supabase backend for real-time sync
31
+ - Share rooms with 6-character room codes
32
+ - Random nickname generation
33
+
34
+ ## Setup
35
+
36
+ ### 1. Supabase Setup
37
+
38
+ 1. Go to [supabase.com](https://supabase.com) and create a free account
39
+ 2. Create a new project
40
+ 3. Go to SQL Editor and run the `supabase_setup.sql` file
41
+ 4. Get your project URL and anon key from Settings > API
42
+
43
+ ### 2. Environment Variables
44
+
45
+ Copy `.env.example` to `.env` and add your Supabase credentials:
46
+
47
+ ```bash
48
+ cp .env.example .env
49
+ ```
50
+
51
+ Edit `.env`:
52
+ ```
53
+ SUPABASE_URL=your_supabase_project_url
54
+ SUPABASE_KEY=your_supabase_anon_key
55
+ ```
56
+
57
+ ### 3. Install Dependencies
58
+
59
+ ```bash
60
+ pip install -r requirements.txt
61
+ ```
62
+
63
+ ### 4. Run
64
+
65
+ ```bash
66
+ python main.py
67
+ ```
68
+
69
+ ## Usage
70
+
71
+ - **Type a message** and press Enter to send
72
+ - **Ctrl+R** - Create new room
73
+ - **Ctrl+C** - Quit
74
+ - Share the **room code** with others to chat
75
+ - Messages auto-delete after 59 seconds
76
+
77
+ ## How It Works
78
+
79
+ - Each room has a unique 6-character code
80
+ - Messages are stored in Supabase with timestamp
81
+ - Auto-cleanup deletes messages older than 59 seconds
82
+ - Terminal polls for new messages every second
83
+ - Zero-trace: no message history retained
84
+
85
+ ## Terminal Share
86
+
87
+ Share your terminal with:
88
+ - tmate: `tmate`
89
+ - warp: Share session feature
90
+ - ssh: Allow remote connections
91
+ - Or just share the room code via any channel
59chat-0.3.1/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # Ephemeral Terminal Chat
2
+
3
+ Zero-trace terminal chat application with 59-second disappearing messages.
4
+
5
+ ## Features
6
+
7
+ - Messages disappear after 59 seconds
8
+ - Zero trace - no history kept
9
+ - Fast terminal UI with Textual framework
10
+ - Mono font recommended (set in your terminal, e.g., JetBrains Mono)
11
+ - Supabase backend for real-time sync
12
+ - Share rooms with 6-character room codes
13
+ - Random nickname generation
14
+
15
+ ## Setup
16
+
17
+ ### 1. Supabase Setup
18
+
19
+ 1. Go to [supabase.com](https://supabase.com) and create a free account
20
+ 2. Create a new project
21
+ 3. Go to SQL Editor and run the `supabase_setup.sql` file
22
+ 4. Get your project URL and anon key from Settings > API
23
+
24
+ ### 2. Environment Variables
25
+
26
+ Copy `.env.example` to `.env` and add your Supabase credentials:
27
+
28
+ ```bash
29
+ cp .env.example .env
30
+ ```
31
+
32
+ Edit `.env`:
33
+ ```
34
+ SUPABASE_URL=your_supabase_project_url
35
+ SUPABASE_KEY=your_supabase_anon_key
36
+ ```
37
+
38
+ ### 3. Install Dependencies
39
+
40
+ ```bash
41
+ pip install -r requirements.txt
42
+ ```
43
+
44
+ ### 4. Run
45
+
46
+ ```bash
47
+ python main.py
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ - **Type a message** and press Enter to send
53
+ - **Ctrl+R** - Create new room
54
+ - **Ctrl+C** - Quit
55
+ - Share the **room code** with others to chat
56
+ - Messages auto-delete after 59 seconds
57
+
58
+ ## How It Works
59
+
60
+ - Each room has a unique 6-character code
61
+ - Messages are stored in Supabase with timestamp
62
+ - Auto-cleanup deletes messages older than 59 seconds
63
+ - Terminal polls for new messages every second
64
+ - Zero-trace: no message history retained
65
+
66
+ ## Terminal Share
67
+
68
+ Share your terminal with:
69
+ - tmate: `tmate`
70
+ - warp: Share session feature
71
+ - ssh: Allow remote connections
72
+ - Or just share the room code via any channel
File without changes
@@ -0,0 +1,4 @@
1
+ from .main import main_func
2
+
3
+ if __name__ == "__main__":
4
+ main_func()
@@ -0,0 +1,295 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+ import argparse
5
+ import json
6
+ import random
7
+ import string
8
+ from datetime import datetime, timezone, timedelta
9
+ from typing import Optional, List, Dict
10
+
11
+ import httpx
12
+ import pyperclip
13
+ from textual.app import App, ComposeResult
14
+ from textual.widgets import Input, Static, Footer
15
+ from textual.containers import Vertical, Horizontal
16
+ from textual.reactive import reactive
17
+ from textual.binding import Binding
18
+ from rich.markdown import Markdown
19
+
20
+ # --- CONFIGURATION ---
21
+ VERSION = "0.3.1"
22
+ APP_NAME = "59chat"
23
+ INTERNAL_PKG = "chat59"
24
+ SUPABASE_URL = "https://xdqxebyyjxklzisddmwl.supabase.co"
25
+ SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhkcXhlYnl5anhrbHppc2RkbXdsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTk4Mzc0NDAsImV4cCI6MjA3NTQxMzQ0MH0.37JtLjN6mGfdac-t-cqNADa8OQlYIgSkZEFSngwxlM0"
26
+
27
+ CSS = (
28
+ "Screen { background: #000000; color: #FFFFFF; }\n"
29
+ "#header { dock: top; height: 4; background: #000000; padding: 1 2; }\n"
30
+ "#header-info { width: 1fr; content-align: left middle; }\n"
31
+ "#header-status { width: auto; content-align: right middle; color: #22C55E; text-style: bold; }\n"
32
+ "#chat-scroll { height: 1fr; padding: 1 2; }\n"
33
+ "#bottom-bar { dock: bottom; height: auto; background: #000000; }\n"
34
+ "#input-row { height: 1; background: #0A0A0A; padding: 0 2; }\n"
35
+ "#input-prefix { width: auto; color: #FFFFFF; text-style: bold; }\n"
36
+ "Input { background: transparent; border: none; width: 1fr; height: 1; color: #FFFFFF; padding: 0; }\n"
37
+ "Input:focus { border: none; }\n"
38
+ ".message-item { width: 100%; height: auto; margin-bottom: 2; }\n"
39
+ ".message-item.own { align-horizontal: right; }\n"
40
+ ".message-item.other { align-horizontal: left; }\n"
41
+ ".msg-container { width: auto; min-width: 40%; max-width: 80%; height: auto; }\n"
42
+ ".msg-header { height: 1; color: #FFFFFF; }\n"
43
+ ".msg-content { height: auto; padding: 0 1; }\n"
44
+ ".msg-line { height: 1; color: #525252; }\n"
45
+ )
46
+
47
+ def parse_iso_dt(s: str) -> datetime:
48
+ """Robust ISO datetime parsing for various microsecond lengths."""
49
+ try:
50
+ # Remove trailing offsets or 'Z' and simplify
51
+ clean_s = s.replace('Z', '+00:00').split('+')[0]
52
+ if '.' in clean_s:
53
+ base, ms = clean_s.split('.')
54
+ clean_s = f"{base}.{ms[:6]}" # Truncate to max 6 digits for microseconds
55
+ return datetime.fromisoformat(f"{clean_s}+00:00")
56
+ except:
57
+ return datetime.now(timezone.utc)
58
+
59
+ class SupabaseClient:
60
+ def __init__(self):
61
+ self.base_url = f"{SUPABASE_URL}/rest/v1"
62
+ self.headers = {
63
+ "apikey": SUPABASE_KEY,
64
+ "Authorization": f"Bearer {SUPABASE_KEY}",
65
+ "Content-Type": "application/json",
66
+ "Prefer": "return=representation"
67
+ }
68
+
69
+ async def get_messages(self, room_id: str):
70
+ async with httpx.AsyncClient() as client:
71
+ url = f"{self.base_url}/messages?room_id=eq.{room_id}&order=created_at.asc"
72
+ res = await client.get(url, headers=self.headers)
73
+ return res.json()
74
+
75
+ async def send_message(self, room_id: str, nickname: str, content: str):
76
+ async with httpx.AsyncClient() as client:
77
+ data = {"room_id": room_id, "nickname": nickname, "content": content}
78
+ await client.post(f"{self.base_url}/messages", headers=self.headers, json=data)
79
+
80
+ async def mark_read(self, msg_ids: List[int]):
81
+ if not msg_ids: return
82
+ now = datetime.now(timezone.utc).isoformat()
83
+ async with httpx.AsyncClient() as client:
84
+ ids_str = f"({','.join(map(str, msg_ids))})"
85
+ url = f"{self.base_url}/messages?id=in.{ids_str}"
86
+ await client.patch(url, headers=self.headers, json={"read_at": now})
87
+
88
+ async def get_active_users(self, room_id: str):
89
+ async with httpx.AsyncClient() as client:
90
+ url = f"{self.base_url}/messages?room_id=eq.{room_id}&select=nickname"
91
+ res = await client.get(url, headers=self.headers)
92
+ data = res.json()
93
+ return len({m['nickname'] for m in data}) if isinstance(data, list) else 1
94
+
95
+ class MessageItem(Vertical):
96
+ def __init__(self, m, is_own):
97
+ super().__init__(classes=f"message-item {'own' if is_own else 'other'}")
98
+ self.m = m
99
+ self.is_own = is_own
100
+ self.rendered_markdown = False
101
+
102
+ def compose(self) -> ComposeResult:
103
+ with Vertical(classes="msg-container"):
104
+ yield Static(id="msg-header", classes="msg-header", markup=True)
105
+ yield Static(id="msg-content", classes="msg-content")
106
+ yield Static(id="msg-line", classes="msg-line", markup=True)
107
+
108
+ def on_mount(self):
109
+ self.set_interval(0.5, self.refresh_msg)
110
+ self.refresh_msg()
111
+
112
+ def refresh_msg(self):
113
+ now = datetime.now(timezone.utc)
114
+ read_at = self.m.get('read_at')
115
+
116
+ status = " [dim]○[/]"
117
+ if read_at:
118
+ read_dt = parse_iso_dt(read_at)
119
+ rem = 59 - (now - read_dt).total_seconds()
120
+ if rem <= 0:
121
+ self.remove()
122
+ return
123
+ status = f" [bold #22C55E]●[/] [bold #EF4444]{int(rem)}s[/]"
124
+
125
+ user_style = "bold #FFFFFF" if self.is_own else "bold #525252"
126
+ prefix = "› "
127
+ time_part = self.m['created_at'].split('T')[1][:5]
128
+
129
+ header_text = f"[{user_style}]{prefix}{self.m['nickname']}[/] [dim]{time_part}[/]{status}"
130
+ self.query_one("#msg-header").update(header_text)
131
+
132
+ if not self.rendered_markdown:
133
+ self.query_one("#msg-content").update(Markdown(self.m['content']))
134
+ self.rendered_markdown = True
135
+
136
+ box_width = self.query_one(".msg-container").content_size.width
137
+ if box_width > 0:
138
+ self.query_one("#msg-line").update(f"[dim]{'─' * box_width}[/]")
139
+
140
+ class FiftyNineChat(App):
141
+ CSS = CSS
142
+ status_text = reactive("")
143
+
144
+ BINDINGS = [
145
+ Binding("ctrl+c", "quit", "QUIT"),
146
+ Binding("ctrl+r", "new_room", "NEW ROOM"),
147
+ Binding("ctrl+l", "copy_invite", "INVITE"),
148
+ ]
149
+
150
+ def __init__(self, room_id: Optional[str] = None, is_new: bool = False):
151
+ super().__init__()
152
+ self.api = SupabaseClient()
153
+ self.room_id = room_id or ""
154
+ self.nickname = ""
155
+ self.running = True
156
+ self.active_users = 1
157
+ self.is_new_session = is_new
158
+ self.displayed_ids = set()
159
+
160
+ def compose(self) -> ComposeResult:
161
+ with Horizontal(id="header"):
162
+ yield Static(id="header-info")
163
+ yield Static(id="header-status")
164
+ from textual.containers import VerticalScroll
165
+ yield VerticalScroll(id="chat-scroll")
166
+ with Vertical(id="bottom-bar"):
167
+ with Horizontal(id="input-row"):
168
+ yield Static("› ", id="input-prefix")
169
+ yield Input(placeholder="Type message...", id="message-input")
170
+ yield Footer()
171
+
172
+ def on_mount(self) -> None:
173
+ try:
174
+ self.nickname = self._generate_nickname()
175
+ if not self.room_id:
176
+ self.room_id = self._generate_room_id()
177
+ self.is_new_session = True
178
+
179
+ self._update_header()
180
+
181
+ welcome_msg = {
182
+ 'id': 'welcome-msg',
183
+ 'nickname': 'SYSTEM',
184
+ 'content': "**WELCOME TO 59CHAT**\nMessages vanish in 59s. Zero Build Tools Required.",
185
+ 'created_at': datetime.now(timezone.utc).isoformat(),
186
+ 'read_at': datetime.now(timezone.utc).isoformat()
187
+ }
188
+ self._add_message_to_scroll(welcome_msg)
189
+
190
+ asyncio.create_task(self._watch_messages())
191
+ asyncio.create_task(self._mark_as_read())
192
+ asyncio.create_task(self._presence_heartbeat())
193
+
194
+ if self.is_new_session:
195
+ asyncio.create_task(self.action_copy_invite())
196
+
197
+ self.query_one("#message-input").focus()
198
+ except Exception as e:
199
+ self.notify(f"Init failed: {e}", severity="error")
200
+
201
+ def watch_status_text(self, text: str):
202
+ try:
203
+ self.query_one("#header-status").update(text)
204
+ except: pass
205
+
206
+ async def on_input_submitted(self, event: Input.Submitted):
207
+ content = event.value.strip()
208
+ if not content: return
209
+ try:
210
+ await self.api.send_message(self.room_id, self.nickname, content)
211
+ event.input.value = ""
212
+ except: pass
213
+
214
+ def _generate_nickname(self) -> str:
215
+ adj = ["Cold", "Swift", "Pure", "Thin", "Hard", "Dark"]
216
+ noun = ["Grid", "Line", "Type", "Form", "Node", "Void"]
217
+ return f"{random.choice(adj)}{random.choice(noun)}"
218
+
219
+ def _generate_room_id(self) -> str:
220
+ return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
221
+
222
+ def _update_header(self):
223
+ info = f"ROOM [bold #FFFFFF]{self.room_id}[/] │ USER [bold #FFFFFF]{self.nickname}[/] │ ONLINE [bold #22C55E]{self.active_users}[/]"
224
+ self.query_one("#header-info").update(info)
225
+
226
+ def _add_message_to_scroll(self, m):
227
+ mid = m['id']
228
+ if mid not in self.displayed_ids:
229
+ self.displayed_ids.add(mid)
230
+ scroll = self.query_one("#chat-scroll")
231
+ is_own = m['nickname'] == self.nickname
232
+ msg_widget = MessageItem(m, is_own)
233
+ scroll.mount(msg_widget)
234
+ scroll.scroll_end(animate=False)
235
+
236
+ async def _presence_heartbeat(self):
237
+ while self.running:
238
+ try:
239
+ self.active_users = await self.api.get_active_users(self.room_id)
240
+ self._update_header()
241
+ await asyncio.sleep(10)
242
+ except: await asyncio.sleep(10)
243
+
244
+ async def _watch_messages(self):
245
+ while self.running:
246
+ try:
247
+ data = await self.api.get_messages(self.room_id)
248
+ if isinstance(data, list):
249
+ for m in data:
250
+ self._add_message_to_scroll(m)
251
+ await asyncio.sleep(1)
252
+ except: await asyncio.sleep(2)
253
+
254
+ async def _mark_as_read(self):
255
+ while self.running:
256
+ try:
257
+ data = await self.api.get_messages(self.room_id)
258
+ if isinstance(data, list):
259
+ unread = [m['id'] for m in data if m['nickname'] != self.nickname and m.get('read_at') is None]
260
+ if unread:
261
+ await self.api.mark_read(unread)
262
+ await asyncio.sleep(1)
263
+ except: await asyncio.sleep(2)
264
+
265
+ async def action_copy_invite(self):
266
+ py = "python" if sys.platform == "win32" else "python3"
267
+ cmd = f"{py} -m pip install -U pip ; {py} -m pip install --only-binary=:all: -U {APP_NAME} ; {py} -m {INTERNAL_PKG} --join {self.room_id}"
268
+ try:
269
+ pyperclip.copy(cmd)
270
+ self.status_text = "INVITATION COPIED!"
271
+ await asyncio.sleep(3)
272
+ self.status_text = ""
273
+ except: pass
274
+
275
+ def action_new_room(self):
276
+ self.room_id = self._generate_room_id()
277
+ scroll = self.query_one("#chat-scroll")
278
+ for child in list(scroll.children): child.remove()
279
+ self.displayed_ids.clear()
280
+ self._update_header()
281
+ asyncio.create_task(self.action_copy_invite())
282
+
283
+ def on_unmount(self) -> None:
284
+ self.running = False
285
+
286
+ def main_func():
287
+ parser = argparse.ArgumentParser()
288
+ parser.add_argument("--join", help="Room ID")
289
+ parser.add_argument("--new", action="store_true", help="New room")
290
+ args = parser.parse_args()
291
+ app = FiftyNineChat(room_id=None if args.new else args.join, is_new=args.new)
292
+ app.run()
293
+
294
+ if __name__ == "__main__":
295
+ main_func()
59chat-0.3.1/main.py ADDED
@@ -0,0 +1,348 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+ import argparse
5
+ import subprocess
6
+ import json
7
+ import urllib.request
8
+ from datetime import datetime, timezone
9
+ from typing import Optional, Set, List, Dict
10
+ from textual.app import App, ComposeResult
11
+ from textual.widgets import Input, Static, Footer
12
+ from textual.containers import Vertical, Horizontal, Container, VerticalScroll
13
+ from textual.reactive import reactive
14
+ from textual.binding import Binding
15
+ from rich.markdown import Markdown
16
+ from supabase import create_client, Client
17
+ import random
18
+ import string
19
+ import pyperclip
20
+
21
+ # --- CONFIGURATION ---
22
+ VERSION = "0.1.9"
23
+ APP_NAME = "59chat"
24
+ CMD_NAME = "59chat"
25
+ SUPABASE_URL = "https://xdqxebyyjxklzisddmwl.supabase.co"
26
+ SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhkcXhlYnl5anhrbHppc2RkbXdsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTk4Mzc0NDAsImV4cCI6MjA3NTQxMzQ0MH0.37JtLjN6mGfdac-t-cqNADa8OQlYIgSkZEFSngwxlM0"
27
+
28
+ CSS = """
29
+ Screen {
30
+ background: #000000;
31
+ color: #FFFFFF;
32
+ }
33
+
34
+ #header {
35
+ dock: top;
36
+ height: 4;
37
+ background: #000000;
38
+ padding: 1 2;
39
+ }
40
+
41
+ #header-info {
42
+ width: 1fr;
43
+ content-align: left middle;
44
+ }
45
+
46
+ #header-status {
47
+ width: auto;
48
+ content-align: right middle;
49
+ color: #22C55E;
50
+ text-style: bold;
51
+ }
52
+
53
+ #chat-scroll {
54
+ height: 1fr;
55
+ padding: 1 2;
56
+ }
57
+
58
+ #bottom-bar {
59
+ dock: bottom;
60
+ height: auto;
61
+ background: #000000;
62
+ }
63
+
64
+ #input-row {
65
+ height: 1;
66
+ background: #0A0A0A;
67
+ padding: 0 2;
68
+ }
69
+
70
+ #input-prefix {
71
+ width: auto;
72
+ color: #FFFFFF;
73
+ text-style: bold;
74
+ }
75
+
76
+ Input {
77
+ background: transparent;
78
+ border: none;
79
+ width: 1fr;
80
+ height: 1;
81
+ color: #FFFFFF;
82
+ padding: 0;
83
+ }
84
+
85
+ Input:focus {
86
+ border: none;
87
+ }
88
+
89
+ .message-item {
90
+ width: 100%;
91
+ height: auto;
92
+ margin-bottom: 2;
93
+ }
94
+
95
+ .message-item.own {
96
+ align-horizontal: right;
97
+ }
98
+
99
+ .message-item.other {
100
+ align-horizontal: left;
101
+ }
102
+
103
+ .msg-container {
104
+ width: auto;
105
+ min-width: 40%;
106
+ max-width: 80%;
107
+ height: auto;
108
+ }
109
+
110
+ .msg-header {
111
+ height: 1;
112
+ color: #FFFFFF;
113
+ }
114
+
115
+ .msg-content {
116
+ height: auto;
117
+ padding: 0 1;
118
+ }
119
+
120
+ .msg-line {
121
+ height: 1;
122
+ color: #525252;
123
+ }
124
+ """
125
+
126
+ class MessageItem(Vertical):
127
+ def __init__(self, m, is_own):
128
+ super().__init__(classes=f"message-item {'own' if is_own else 'other'}")
129
+ self.m = m
130
+ self.is_own = is_own
131
+ self.rendered_markdown = False
132
+
133
+ def compose(self) -> ComposeResult:
134
+ with Vertical(classes="msg-container"):
135
+ yield Static(id="msg-header", classes="msg-header", markup=True)
136
+ yield Static(id="msg-content", classes="msg-content")
137
+ yield Static(id="msg-line", classes="msg-line", markup=True)
138
+
139
+ def on_mount(self):
140
+ self.set_interval(0.5, self.refresh_msg)
141
+ self.refresh_msg()
142
+
143
+ def refresh_msg(self):
144
+ now = datetime.now(timezone.utc)
145
+ read_at = self.m.get('read_at')
146
+
147
+ status = " [dim]○[/]"
148
+ if read_at:
149
+ read_dt = datetime.fromisoformat(read_at.replace('Z', '+00:00'))
150
+ rem = 59 - (now - read_dt).total_seconds()
151
+ if rem <= 0:
152
+ self.remove()
153
+ return
154
+ status = f" [bold #22C55E]●[/] [bold #EF4444]{int(rem)}s[/]"
155
+
156
+ user_style = "bold #FFFFFF" if self.is_own else "bold #525252"
157
+ prefix = "› "
158
+ time_part = self.m['created_at'].split('T')[1][:5]
159
+
160
+ header_text = f"[{user_style}]{prefix}{self.m['nickname']}[/] [dim]{time_part}[/]{status}"
161
+ self.query_one("#msg-header").update(header_text)
162
+
163
+ if not self.rendered_markdown:
164
+ self.query_one("#msg-content").update(Markdown(self.m['content']))
165
+ self.rendered_markdown = True
166
+
167
+ # Responsive Çizgi Mantığı (Önce Yatayda Genişle)
168
+ edge_padding = 4
169
+ screen_w = (self.app.size.width or 80) - edge_padding
170
+ w_min = int(screen_w * 0.4)
171
+ w_max = int(screen_w * 0.8)
172
+
173
+ # İçerik uzunluğu tahmini
174
+ lines = self.m['content'].split('\n')
175
+ max_line = max(len(l) for l in lines) if lines else 0
176
+ target_w = max(w_min, min(max_line + 10, w_max))
177
+
178
+ container = self.query_one(".msg-container")
179
+ container.styles.width = target_w
180
+ self.query_one("#msg-line").update(f"[dim]{'─' * target_w}[/]")
181
+
182
+ class FiftyNineChat(App):
183
+ CSS = CSS
184
+ status_text = reactive("")
185
+
186
+ BINDINGS = [
187
+ Binding("ctrl+c", "quit", "QUIT"),
188
+ Binding("ctrl+r", "new_room", "NEW ROOM"),
189
+ Binding("ctrl+l", "copy_invite", "INVITE"),
190
+ ]
191
+
192
+ def __init__(self, room_id: Optional[str] = None, is_new: bool = False):
193
+ super().__init__()
194
+ self.supabase: Optional[Client] = None
195
+ self.room_id = room_id or ""
196
+ self.nickname = ""
197
+ self.running = True
198
+ self.active_users = 1
199
+ self.is_new_session = is_new
200
+ self.displayed_ids = set()
201
+
202
+ def compose(self) -> ComposeResult:
203
+ with Horizontal(id="header"):
204
+ yield Static(id="header-info")
205
+ yield Static(id="header-status")
206
+ yield VerticalScroll(id="chat-scroll")
207
+ with Vertical(id="bottom-bar"):
208
+ with Horizontal(id="input-row"):
209
+ yield Static("› ", id="input-prefix")
210
+ yield Input(placeholder="Type message...", id="message-input")
211
+ yield Footer()
212
+
213
+ def on_mount(self) -> None:
214
+ try:
215
+ self.supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
216
+ self.nickname = self._generate_nickname()
217
+ if not self.room_id:
218
+ self.room_id = self._generate_room_id()
219
+ self.is_new_session = True
220
+
221
+ self._update_header()
222
+
223
+ welcome_msg = {
224
+ 'id': 'welcome-msg',
225
+ 'nickname': 'SYSTEM',
226
+ 'content': '**WELCOME TO 59CHAT**\nMessages vanish in 59s. Type and press Enter.',
227
+ 'created_at': datetime.now(timezone.utc).isoformat(),
228
+ 'read_at': datetime.now(timezone.utc).isoformat()
229
+ }
230
+ self._add_message_to_scroll(welcome_msg)
231
+
232
+ asyncio.create_task(self._watch_messages())
233
+ asyncio.create_task(self._mark_as_read())
234
+ asyncio.create_task(self._presence_heartbeat())
235
+
236
+ if self.is_new_session:
237
+ asyncio.create_task(self.action_copy_invite())
238
+
239
+ self.query_one("#message-input").focus()
240
+ except Exception as e:
241
+ self.notify(f"Init failed: {e}", severity="error")
242
+
243
+ def watch_status_text(self, text: str):
244
+ try:
245
+ self.query_one("#header-status").update(text)
246
+ except: pass
247
+
248
+ async def on_input_submitted(self, event: Input.Submitted):
249
+ content = event.value.strip()
250
+ if not content or not self.supabase: return
251
+ try:
252
+ self.supabase.table('messages').insert({
253
+ 'room_id': self.room_id,
254
+ 'nickname': self.nickname,
255
+ 'content': content
256
+ }).execute()
257
+ event.input.value = ""
258
+ except: pass
259
+
260
+ def _generate_nickname(self) -> str:
261
+ adj = ["Cold", "Swift", "Pure", "Thin", "Hard", "Dark"]
262
+ noun = ["Grid", "Line", "Type", "Form", "Node", "Void"]
263
+ return f"{random.choice(adj)}{random.choice(noun)}"
264
+
265
+ def _generate_room_id(self) -> str:
266
+ return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
267
+
268
+ def _update_header(self):
269
+ info = f"ROOM [bold #FFFFFF]{self.room_id}[/] │ USER [bold #FFFFFF]{self.nickname}[/] │ ONLINE [bold #22C55E]{self.active_users}[/]"
270
+ self.query_one("#header-info").update(info)
271
+
272
+ def _add_message_to_scroll(self, m):
273
+ mid = m['id']
274
+ if mid not in self.displayed_ids:
275
+ self.displayed_ids.add(mid)
276
+ scroll = self.query_one("#chat-scroll")
277
+ is_own = m['nickname'] == self.nickname
278
+ msg_widget = MessageItem(m, is_own)
279
+ scroll.mount(msg_widget)
280
+ scroll.scroll_end(animate=False)
281
+
282
+ async def _presence_heartbeat(self):
283
+ while self.running and self.supabase:
284
+ try:
285
+ res = self.supabase.table('messages').select('nickname').eq('room_id', self.room_id).execute()
286
+ self.active_users = max(1, len({m['nickname'] for m in res.data}))
287
+ self._update_header()
288
+ await asyncio.sleep(10)
289
+ except: await asyncio.sleep(10)
290
+
291
+ async def _watch_messages(self):
292
+ while self.running and self.supabase:
293
+ try:
294
+ res = self.supabase.table('messages').select('*').eq('room_id', self.room_id).order('created_at').execute()
295
+ for m in res.data:
296
+ self._add_message_to_scroll(m)
297
+ await asyncio.sleep(1)
298
+ except: await asyncio.sleep(2)
299
+
300
+ async def _mark_as_read(self):
301
+ while self.running and self.supabase:
302
+ try:
303
+ res = self.supabase.table('messages').select('id, nickname').eq('room_id', self.room_id).is_('read_at', 'null').execute()
304
+ unread = [m['id'] for m in res.data if m['nickname'] != self.nickname]
305
+ if unread:
306
+ now = datetime.now(timezone.utc).isoformat()
307
+ for mid in unread:
308
+ self.supabase.table('messages').update({'read_at': now}).eq('id', mid).execute()
309
+ await asyncio.sleep(1)
310
+ except: await asyncio.sleep(2)
311
+
312
+ async def action_copy_invite(self):
313
+ # UNIVERSAL MAGIC JOIN COMMAND
314
+ if sys.platform == "win32":
315
+ py, sep = "python", ";" # PowerShell/CMD uyumlu güvenli ayırıcı
316
+ else:
317
+ py, sep = "python3", "&&"
318
+
319
+ cmd = f"{py} -m pip install -U pip {sep} {py} -m pip install -U {APP_NAME} {sep} {py} -c \"import main; main.main_func()\" --join {self.room_id}"
320
+
321
+ try:
322
+ pyperclip.copy(cmd)
323
+ self.status_text = "INVITATION COPIED!"
324
+ await asyncio.sleep(3)
325
+ self.status_text = ""
326
+ except: pass
327
+
328
+ def action_new_room(self):
329
+ self.room_id = self._generate_room_id()
330
+ scroll = self.query_one("#chat-scroll")
331
+ for child in list(scroll.children): child.remove()
332
+ self.displayed_ids.clear()
333
+ self._update_header()
334
+ asyncio.create_task(self.action_copy_invite())
335
+
336
+ def on_unmount(self) -> None:
337
+ self.running = False
338
+
339
+ def main_func():
340
+ parser = argparse.ArgumentParser()
341
+ parser.add_argument("--join", help="Room ID")
342
+ parser.add_argument("--new", action="store_true", help="New room")
343
+ args = parser.parse_args()
344
+ app = FiftyNineChat(room_id=None if args.new else args.join, is_new=args.new)
345
+ app.run()
346
+
347
+ if __name__ == "__main__":
348
+ main_func()
@@ -0,0 +1,4 @@
1
+ textual>=0.45.0
2
+ supabase>=2.3.0
3
+ python-dotenv>=1.0.0
4
+ pyperclip>=1.8.2
59chat-0.3.1/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
59chat-0.3.1/setup.py ADDED
@@ -0,0 +1,23 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="59chat",
5
+ version="0.3.1",
6
+ author="YourName",
7
+ description="59-second zero-trace terminal chat (Ultra-Light Edition)",
8
+ long_description=open("README.md", encoding="utf-8").read(),
9
+ long_description_content_type="text/markdown",
10
+ url="https://github.com/yourusername/59chat",
11
+ packages=find_packages(),
12
+ install_requires=[
13
+ "textual>=0.45.0",
14
+ "httpx>=0.25.0",
15
+ "pyperclip>=1.8.2",
16
+ ],
17
+ entry_points={
18
+ "console_scripts": [
19
+ "59chat=chat59.main:main_func",
20
+ ],
21
+ },
22
+ python_requires=">=3.8",
23
+ )