mastui 0.3.0__py3-none-any.whl
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.
- mastui/__init__.py +0 -0
- mastui/app.css +302 -0
- mastui/app.py +275 -0
- mastui/config.py +35 -0
- mastui/logging_config.py +13 -0
- mastui/login.py +76 -0
- mastui/mastodon_api.py +86 -0
- mastui/messages.py +48 -0
- mastui/post.py +61 -0
- mastui/profile.py +107 -0
- mastui/reply.py +90 -0
- mastui/splash.py +58 -0
- mastui/thread.py +159 -0
- mastui/timeline.py +287 -0
- mastui/utils.py +50 -0
- mastui/widgets.py +195 -0
- mastui-0.3.0.dist-info/LICENSE +21 -0
- mastui-0.3.0.dist-info/METADATA +98 -0
- mastui-0.3.0.dist-info/RECORD +21 -0
- mastui-0.3.0.dist-info/WHEEL +4 -0
- mastui-0.3.0.dist-info/entry_points.txt +3 -0
mastui/__init__.py
ADDED
|
File without changes
|
mastui/app.css
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
Screen {
|
|
2
|
+
layout: vertical;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
Timelines {
|
|
6
|
+
layout: horizontal;
|
|
7
|
+
height: 1fr;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
Timeline {
|
|
11
|
+
layout: vertical;
|
|
12
|
+
border: solid $accent;
|
|
13
|
+
width: 1fr;
|
|
14
|
+
height: 100%;
|
|
15
|
+
margin: 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.timeline-content {
|
|
19
|
+
/* This is now empty, as display is handled by the widget itself */
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
Timeline:focus {
|
|
23
|
+
border: double $accent-darken-2;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.timeline-header {
|
|
27
|
+
height: 1;
|
|
28
|
+
background: $accent;
|
|
29
|
+
color: $text;
|
|
30
|
+
dock: top;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.timeline_title {
|
|
34
|
+
width: 1fr;
|
|
35
|
+
height: 1;
|
|
36
|
+
text-align: center;
|
|
37
|
+
padding: 0 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.timeline-refresh-spinner {
|
|
41
|
+
dock: right;
|
|
42
|
+
width: auto;
|
|
43
|
+
height: 1;
|
|
44
|
+
display: none;
|
|
45
|
+
margin: 0 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.timeline-item {
|
|
49
|
+
height: auto;
|
|
50
|
+
margin-bottom: 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.boost-header {
|
|
54
|
+
padding: 0 1;
|
|
55
|
+
background: $primary-darken-2;
|
|
56
|
+
color: $text;
|
|
57
|
+
height: 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
SplashScreen > Vertical {
|
|
61
|
+
align: center middle;
|
|
62
|
+
height: 100%;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
#logo {
|
|
67
|
+
width: auto;
|
|
68
|
+
height: auto;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#version {
|
|
72
|
+
width: auto;
|
|
73
|
+
text-align: center;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#splash-status {
|
|
77
|
+
width: auto;
|
|
78
|
+
text-align: center;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.action-spinner {
|
|
82
|
+
display: none;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
.post-footer {
|
|
87
|
+
height: 1;
|
|
88
|
+
padding: 0 1;
|
|
89
|
+
background: $surface-darken-1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.post-footer > Static {
|
|
93
|
+
width: 1fr;
|
|
94
|
+
text-align: center;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.status-message {
|
|
98
|
+
margin-top: 1;
|
|
99
|
+
text-align: center;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
Post.favourited {
|
|
103
|
+
background: $success 20%;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
Post.reblogged {
|
|
107
|
+
background: $warning 20%;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.timeline-item.selected {
|
|
111
|
+
background: $primary 20%;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
Post.selected {
|
|
115
|
+
background: $primary 20%;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
Notification.selected {
|
|
119
|
+
background: $primary 20%;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
#dialog {
|
|
124
|
+
grid-size: 2;
|
|
125
|
+
grid-gutter: 1;
|
|
126
|
+
align: center middle;
|
|
127
|
+
width: 80%;
|
|
128
|
+
height: auto;
|
|
129
|
+
max-height: 80%;
|
|
130
|
+
background: $surface;
|
|
131
|
+
border: thick $primary 80%;
|
|
132
|
+
padding: 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#dialog > .hidden {
|
|
136
|
+
display: none;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#auth_link_container {
|
|
140
|
+
column-span: 2;
|
|
141
|
+
margin-top: 1;
|
|
142
|
+
border: round $primary;
|
|
143
|
+
padding: 1;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#auth_link_container > TextArea {
|
|
147
|
+
height: 5;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#auth_link_container.hidden {
|
|
151
|
+
display: none;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#status {
|
|
155
|
+
column-span: 2;
|
|
156
|
+
text-align: center;
|
|
157
|
+
color: $error;
|
|
158
|
+
height: auto;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#status.hidden {
|
|
162
|
+
display: none;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#post_dialog {
|
|
166
|
+
grid-size: 1;
|
|
167
|
+
grid-gutter: 1;
|
|
168
|
+
align: center middle;
|
|
169
|
+
width: 80%;
|
|
170
|
+
height: auto;
|
|
171
|
+
max-height: 80%;
|
|
172
|
+
background: $surface;
|
|
173
|
+
border: thick $primary 80%;
|
|
174
|
+
padding: 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#post_title {
|
|
178
|
+
width: 100%;
|
|
179
|
+
text-align: center;
|
|
180
|
+
background: $primary;
|
|
181
|
+
color: $text;
|
|
182
|
+
padding: 0 1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#post_content {
|
|
186
|
+
height: 10;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#post_options {
|
|
190
|
+
height: auto;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#post_language_container {
|
|
194
|
+
height: auto;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.post_option_label {
|
|
198
|
+
width: auto;
|
|
199
|
+
height: 100%;
|
|
200
|
+
content-align: left middle;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#cw_input {
|
|
204
|
+
width: 1fr;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#language_select {
|
|
208
|
+
width: 1fr;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#post_buttons {
|
|
212
|
+
width: 100%;
|
|
213
|
+
align-horizontal: right;
|
|
214
|
+
height: auto;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#post_buttons > Button {
|
|
218
|
+
margin-left: 1;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#reply_dialog {
|
|
222
|
+
grid-size: 1;
|
|
223
|
+
grid-gutter: 1;
|
|
224
|
+
align: center middle;
|
|
225
|
+
width: 80%;
|
|
226
|
+
height: auto;
|
|
227
|
+
max-height: 90%;
|
|
228
|
+
background: $surface;
|
|
229
|
+
border: thick $primary 80%;
|
|
230
|
+
padding: 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#reply_title {
|
|
234
|
+
width: 100%;
|
|
235
|
+
text-align: center;
|
|
236
|
+
background: $primary;
|
|
237
|
+
color: $text;
|
|
238
|
+
padding: 0 1;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#original_post_preview {
|
|
242
|
+
height: auto;
|
|
243
|
+
max-height: 10;
|
|
244
|
+
overflow: auto;
|
|
245
|
+
margin-bottom: 1;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#reply_content {
|
|
249
|
+
height: 10;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
#reply_options {
|
|
253
|
+
height: auto;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#reply_language_container {
|
|
257
|
+
height: auto;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.reply_option_label {
|
|
261
|
+
width: auto;
|
|
262
|
+
height: 100%;
|
|
263
|
+
content-align: left middle;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
#cw_input {
|
|
267
|
+
width: 1fr;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
#language_select {
|
|
271
|
+
width: 1fr;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#reply_buttons {
|
|
275
|
+
width: 100%;
|
|
276
|
+
align-horizontal: right;
|
|
277
|
+
height: auto;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#reply_buttons > Button {
|
|
281
|
+
margin-left: 1;
|
|
282
|
+
}
|
|
283
|
+
#thread-dialog {
|
|
284
|
+
width: 80%;
|
|
285
|
+
height: 80%;
|
|
286
|
+
background: $surface;
|
|
287
|
+
border: thick $primary 80%;
|
|
288
|
+
padding: 1;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#thread-container {
|
|
292
|
+
padding: 1;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.main-post {
|
|
296
|
+
border: round $accent;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.reply-post {
|
|
300
|
+
margin-left: 4;
|
|
301
|
+
}
|
|
302
|
+
|
mastui/app.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
from textual.app import App, ComposeResult
|
|
2
|
+
from textual.widgets import Header, Footer
|
|
3
|
+
from textual import on
|
|
4
|
+
from mastui.login import LoginScreen
|
|
5
|
+
from mastui.post import PostScreen
|
|
6
|
+
from mastui.reply import ReplyScreen
|
|
7
|
+
from mastui.splash import SplashScreen
|
|
8
|
+
from mastui.mastodon_api import get_api
|
|
9
|
+
from mastui.timeline import Timelines, Timeline
|
|
10
|
+
from mastui.widgets import Post, LikePost, BoostPost
|
|
11
|
+
from mastui.thread import ThreadScreen
|
|
12
|
+
from mastui.profile import ProfileScreen
|
|
13
|
+
from mastui.logging_config import setup_logging
|
|
14
|
+
import logging
|
|
15
|
+
import argparse
|
|
16
|
+
import os
|
|
17
|
+
from mastui.config import config
|
|
18
|
+
from mastui.messages import (
|
|
19
|
+
PostStatusUpdate,
|
|
20
|
+
ActionFailed,
|
|
21
|
+
TimelineData,
|
|
22
|
+
FocusNextTimeline,
|
|
23
|
+
FocusPreviousTimeline,
|
|
24
|
+
ViewProfile,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Set up logging
|
|
28
|
+
log = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Get the absolute path to the CSS file
|
|
32
|
+
css_path = os.path.join(os.path.dirname(__file__), "app.css")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Mastui(App):
|
|
36
|
+
"""A Textual app to interact with Mastodon."""
|
|
37
|
+
|
|
38
|
+
BINDINGS = [
|
|
39
|
+
("d", "toggle_dark", "Toggle dark mode"),
|
|
40
|
+
("r", "refresh_timelines", "Refresh timelines"),
|
|
41
|
+
("c", "compose_post", "Compose post"),
|
|
42
|
+
("p", "view_profile", "View profile"),
|
|
43
|
+
("a", "reply_to_post", "Reply to post"),
|
|
44
|
+
("l", "like_post", "Like post"),
|
|
45
|
+
("b", "boost_post", "Boost post"),
|
|
46
|
+
("up", "scroll_up", "Scroll up"),
|
|
47
|
+
("down", "scroll_down", "Scroll down"),
|
|
48
|
+
]
|
|
49
|
+
CSS_PATH = css_path
|
|
50
|
+
initial_data = None
|
|
51
|
+
|
|
52
|
+
def compose(self) -> ComposeResult:
|
|
53
|
+
"""Create child widgets for the app."""
|
|
54
|
+
yield Header()
|
|
55
|
+
yield Footer()
|
|
56
|
+
|
|
57
|
+
def on_mount(self) -> None:
|
|
58
|
+
"""Called when the app is mounted."""
|
|
59
|
+
self.dark = config.theme == "dark"
|
|
60
|
+
self.push_screen(SplashScreen())
|
|
61
|
+
self.api = get_api()
|
|
62
|
+
if self.api:
|
|
63
|
+
self.load_initial_data()
|
|
64
|
+
else:
|
|
65
|
+
self.call_later(self.show_login_screen)
|
|
66
|
+
|
|
67
|
+
def show_login_screen(self):
|
|
68
|
+
if isinstance(self.screen, SplashScreen):
|
|
69
|
+
self.pop_screen()
|
|
70
|
+
self.push_screen(LoginScreen(), self.on_login)
|
|
71
|
+
|
|
72
|
+
def load_initial_data(self):
|
|
73
|
+
"""Starts the worker to load initial data."""
|
|
74
|
+
self.run_worker(self.do_load_initial_data, exclusive=True, thread=True)
|
|
75
|
+
|
|
76
|
+
def do_load_initial_data(self):
|
|
77
|
+
"""Worker to load initial data from the Mastodon API."""
|
|
78
|
+
log.info("Starting initial data load...")
|
|
79
|
+
def update_status(message):
|
|
80
|
+
if isinstance(self.screen, SplashScreen):
|
|
81
|
+
self.call_from_thread(self.screen.update_status, message)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
update_status("Fetching home timeline...")
|
|
85
|
+
home_timeline = self.api.timeline_home()
|
|
86
|
+
log.info(f"Fetched {len(home_timeline)} posts for home timeline.")
|
|
87
|
+
|
|
88
|
+
update_status("Fetching notifications...")
|
|
89
|
+
notifications = self.api.notifications()
|
|
90
|
+
log.info(f"Fetched {len(notifications)} notifications.")
|
|
91
|
+
|
|
92
|
+
update_status("Fetching federated timeline...")
|
|
93
|
+
federated_timeline = self.api.timeline_public()
|
|
94
|
+
log.info(f"Fetched {len(federated_timeline)} posts for federated timeline.")
|
|
95
|
+
|
|
96
|
+
self.initial_data = {
|
|
97
|
+
"home": home_timeline,
|
|
98
|
+
"notifications": notifications,
|
|
99
|
+
"federated": federated_timeline,
|
|
100
|
+
}
|
|
101
|
+
log.info("Initial data load complete.")
|
|
102
|
+
self.call_from_thread(self.show_timelines)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
log.error(f"Error loading initial data: {e}", exc_info=True)
|
|
105
|
+
self.notify(f"Error loading initial data: {e}", severity="error")
|
|
106
|
+
self.call_from_thread(self.show_login_screen)
|
|
107
|
+
|
|
108
|
+
def on_login(self, api) -> None:
|
|
109
|
+
"""Called when the login screen is dismissed."""
|
|
110
|
+
log.info("Login successful.")
|
|
111
|
+
self.api = api
|
|
112
|
+
self.push_screen(SplashScreen())
|
|
113
|
+
self.load_initial_data()
|
|
114
|
+
|
|
115
|
+
def show_timelines(self):
|
|
116
|
+
if isinstance(self.screen, SplashScreen):
|
|
117
|
+
self.pop_screen()
|
|
118
|
+
log.info("Showing timelines...")
|
|
119
|
+
self.mount(Timelines(initial_data=self.initial_data))
|
|
120
|
+
try:
|
|
121
|
+
self.query_one("#home", Timeline).focus()
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
def watch_dark(self, dark: bool) -> None:
|
|
126
|
+
"""Called when dark mode is toggled."""
|
|
127
|
+
config.theme = "dark" if dark else "light"
|
|
128
|
+
config.save_config()
|
|
129
|
+
|
|
130
|
+
def action_refresh_timelines(self) -> None:
|
|
131
|
+
"""An action to refresh the timelines."""
|
|
132
|
+
log.info("Refreshing all timelines...")
|
|
133
|
+
for timeline in self.query(Timeline):
|
|
134
|
+
timeline.refresh_posts()
|
|
135
|
+
|
|
136
|
+
def action_compose_post(self) -> None:
|
|
137
|
+
"""An action to compose a new post."""
|
|
138
|
+
self.push_screen(PostScreen(), self.on_post_screen_dismiss)
|
|
139
|
+
|
|
140
|
+
def action_reply_to_post(self) -> None:
|
|
141
|
+
focused = self.query("Timeline:focus")
|
|
142
|
+
if focused:
|
|
143
|
+
focused.first().reply_to_post()
|
|
144
|
+
|
|
145
|
+
def on_post_screen_dismiss(self, result: dict) -> None:
|
|
146
|
+
"""Called when the post screen is dismissed."""
|
|
147
|
+
if result:
|
|
148
|
+
try:
|
|
149
|
+
log.info("Sending post...")
|
|
150
|
+
self.api.status_post(
|
|
151
|
+
status=result["content"],
|
|
152
|
+
spoiler_text=result["spoiler_text"],
|
|
153
|
+
language=result["language"],
|
|
154
|
+
)
|
|
155
|
+
log.info("Post sent successfully.")
|
|
156
|
+
self.notify("Post sent successfully!", severity="information")
|
|
157
|
+
self.action_refresh_timelines()
|
|
158
|
+
except Exception as e:
|
|
159
|
+
log.error(f"Error sending post: {e}", exc_info=True)
|
|
160
|
+
self.notify(f"Error sending post: {e}", severity="error")
|
|
161
|
+
|
|
162
|
+
def on_reply_screen_dismiss(self, result: dict) -> None:
|
|
163
|
+
"""Called when the reply screen is dismissed."""
|
|
164
|
+
if result:
|
|
165
|
+
try:
|
|
166
|
+
log.info(f"Sending reply to post {result['in_reply_to_id']}...")
|
|
167
|
+
self.api.status_post(
|
|
168
|
+
status=result["content"],
|
|
169
|
+
spoiler_text=result["spoiler_text"],
|
|
170
|
+
language=result["language"],
|
|
171
|
+
in_reply_to_id=result["in_reply_to_id"],
|
|
172
|
+
)
|
|
173
|
+
log.info("Reply sent successfully.")
|
|
174
|
+
self.notify("Reply sent successfully!", severity="information")
|
|
175
|
+
self.action_refresh_timelines()
|
|
176
|
+
except Exception as e:
|
|
177
|
+
log.error(f"Error sending reply: {e}", exc_info=True)
|
|
178
|
+
self.notify(f"Error sending reply: {e}", severity="error")
|
|
179
|
+
|
|
180
|
+
@on(LikePost)
|
|
181
|
+
def handle_like_post(self, message: LikePost):
|
|
182
|
+
self.run_worker(lambda: self.do_like_post(message.post_id), exclusive=True, thread=True)
|
|
183
|
+
|
|
184
|
+
def do_like_post(self, post_id: str):
|
|
185
|
+
try:
|
|
186
|
+
post_data = self.api.status_favourite(post_id)
|
|
187
|
+
self.post_message(PostStatusUpdate(post_data))
|
|
188
|
+
except Exception as e:
|
|
189
|
+
log.error(f"Error liking post {post_id}: {e}", exc_info=True)
|
|
190
|
+
self.post_message(ActionFailed(post_id))
|
|
191
|
+
|
|
192
|
+
@on(BoostPost)
|
|
193
|
+
def handle_boost_post(self, message: BoostPost):
|
|
194
|
+
self.run_worker(lambda: self.do_boost_post(message.post_id), exclusive=True, thread=True)
|
|
195
|
+
|
|
196
|
+
def do_boost_post(self, post_id: str):
|
|
197
|
+
try:
|
|
198
|
+
post_data = self.api.status_reblog(post_id)
|
|
199
|
+
self.post_message(PostStatusUpdate(post_data))
|
|
200
|
+
except Exception as e:
|
|
201
|
+
log.error(f"Error boosting post {post_id}: {e}", exc_info=True)
|
|
202
|
+
self.post_message(ActionFailed(post_id))
|
|
203
|
+
|
|
204
|
+
def on_post_status_update(self, message: PostStatusUpdate) -> None:
|
|
205
|
+
updated_post_data = message.post_data
|
|
206
|
+
target_post = updated_post_data.get("reblog") or updated_post_data
|
|
207
|
+
target_id = target_post["id"]
|
|
208
|
+
|
|
209
|
+
for container in [self.screen, *self.query(Timelines)]:
|
|
210
|
+
for post_widget in container.query(Post):
|
|
211
|
+
original_status = post_widget.post.get("reblog") or post_widget.post
|
|
212
|
+
if original_status["id"] == target_id:
|
|
213
|
+
post_widget.update_from_post(updated_post_data)
|
|
214
|
+
|
|
215
|
+
def on_action_failed(self, message: ActionFailed) -> None:
|
|
216
|
+
for container in [self.screen, *self.query(Timelines)]:
|
|
217
|
+
for post_widget in container.query(Post):
|
|
218
|
+
original_status = post_widget.post.get("reblog") or post_widget.post
|
|
219
|
+
if original_status["id"] == message.post_id:
|
|
220
|
+
post_widget.hide_spinner()
|
|
221
|
+
|
|
222
|
+
@on(FocusNextTimeline)
|
|
223
|
+
def on_focus_next_timeline(self, message: FocusNextTimeline) -> None:
|
|
224
|
+
timelines = self.query(Timeline)
|
|
225
|
+
for i, timeline in enumerate(timelines):
|
|
226
|
+
if timeline.has_focus:
|
|
227
|
+
timelines[(i + 1) % len(timelines)].focus()
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
@on(FocusPreviousTimeline)
|
|
231
|
+
def on_focus_previous_timeline(self, message: FocusPreviousTimeline) -> None:
|
|
232
|
+
timelines = self.query(Timeline)
|
|
233
|
+
for i, timeline in enumerate(timelines):
|
|
234
|
+
if timeline.has_focus:
|
|
235
|
+
timelines[(i - 1) % len(timelines)].focus()
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
@on(ViewProfile)
|
|
239
|
+
def on_view_profile(self, message: ViewProfile) -> None:
|
|
240
|
+
self.push_screen(ProfileScreen(message.account_id, self.api))
|
|
241
|
+
|
|
242
|
+
def action_like_post(self) -> None:
|
|
243
|
+
focused = self.query("Timeline:focus")
|
|
244
|
+
if focused:
|
|
245
|
+
focused.first().like_post()
|
|
246
|
+
|
|
247
|
+
def action_boost_post(self) -> None:
|
|
248
|
+
focused = self.query("Timeline:focus")
|
|
249
|
+
if focused:
|
|
250
|
+
focused.first().boost_post()
|
|
251
|
+
|
|
252
|
+
def action_scroll_up(self) -> None:
|
|
253
|
+
focused = self.query("Timeline:focus")
|
|
254
|
+
if focused:
|
|
255
|
+
focused.first().scroll_up()
|
|
256
|
+
|
|
257
|
+
def action_scroll_down(self) -> None:
|
|
258
|
+
focused = self.query("Timeline:focus")
|
|
259
|
+
if focused:
|
|
260
|
+
focused.first().scroll_down()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def main():
|
|
264
|
+
parser = argparse.ArgumentParser(description="A Textual app to interact with Mastodon.")
|
|
265
|
+
parser.add_argument("--no-ssl-verify", action="store_false", dest="ssl_verify", help="Disable SSL verification.")
|
|
266
|
+
parser.add_argument("--debug", action="store_true", help="Enable debug logging.")
|
|
267
|
+
args = parser.parse_args()
|
|
268
|
+
setup_logging(debug=args.debug)
|
|
269
|
+
config.ssl_verify = args.ssl_verify
|
|
270
|
+
app = Mastui()
|
|
271
|
+
app.run()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
main()
|
mastui/config.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dotenv import load_dotenv
|
|
3
|
+
|
|
4
|
+
load_dotenv()
|
|
5
|
+
|
|
6
|
+
class Config:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.mastodon_host = os.getenv("MASTODON_HOST")
|
|
9
|
+
self.mastodon_client_id = os.getenv("MASTODON_CLIENT_ID")
|
|
10
|
+
self.mastodon_client_secret = os.getenv("MASTODON_CLIENT_SECRET")
|
|
11
|
+
self.mastodon_access_token = os.getenv("MASTODON_ACCESS_TOKEN")
|
|
12
|
+
self.ssl_verify = True
|
|
13
|
+
self.theme = os.getenv("THEME", "dark")
|
|
14
|
+
|
|
15
|
+
def save_config(self):
|
|
16
|
+
with open(".env", "w") as f:
|
|
17
|
+
if self.mastodon_host:
|
|
18
|
+
f.write(f"MASTODON_HOST={self.mastodon_host}\n")
|
|
19
|
+
if self.mastodon_client_id:
|
|
20
|
+
f.write(f"MASTODON_CLIENT_ID={self.mastodon_client_id}\n")
|
|
21
|
+
if self.mastodon_client_secret:
|
|
22
|
+
f.write(f"MASTODON_CLIENT_SECRET={self.mastodon_client_secret}\n")
|
|
23
|
+
if self.mastodon_access_token:
|
|
24
|
+
f.write(f"MASTODON_ACCESS_TOKEN={self.mastodon_access_token}\n")
|
|
25
|
+
if self.theme:
|
|
26
|
+
f.write(f"THEME={self.theme}\n")
|
|
27
|
+
|
|
28
|
+
def save_credentials(self, host, client_id, client_secret, access_token):
|
|
29
|
+
self.mastodon_host = host
|
|
30
|
+
self.mastodon_client_id = client_id
|
|
31
|
+
self.mastodon_client_secret = client_secret
|
|
32
|
+
self.mastodon_access_token = access_token
|
|
33
|
+
self.save_config()
|
|
34
|
+
|
|
35
|
+
config = Config()
|
mastui/logging_config.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
def setup_logging(debug=False):
|
|
5
|
+
"""Set up logging to a file."""
|
|
6
|
+
log_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "mastui.log")
|
|
7
|
+
level = logging.DEBUG if debug else logging.INFO
|
|
8
|
+
logging.basicConfig(
|
|
9
|
+
level=level,
|
|
10
|
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
11
|
+
filename=log_file,
|
|
12
|
+
filemode="w", # Overwrite the log file on each run
|
|
13
|
+
)
|
mastui/login.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import pyperclip
|
|
2
|
+
from textual.app import App, ComposeResult
|
|
3
|
+
from textual.containers import Grid, Vertical
|
|
4
|
+
from textual.widgets import Button, Label, Input, Static, TextArea
|
|
5
|
+
from textual.screen import ModalScreen
|
|
6
|
+
|
|
7
|
+
from mastui.mastodon_api import login, create_app
|
|
8
|
+
|
|
9
|
+
class LoginScreen(ModalScreen):
|
|
10
|
+
"""Screen for user to login."""
|
|
11
|
+
|
|
12
|
+
def compose(self) -> ComposeResult:
|
|
13
|
+
with Grid(id="dialog"):
|
|
14
|
+
yield Label("Mastodon Instance:")
|
|
15
|
+
yield Input(placeholder="mastodon.social", id="host")
|
|
16
|
+
|
|
17
|
+
yield Static() # Spacer
|
|
18
|
+
yield Button("Get Auth Link", variant="primary", id="get_auth")
|
|
19
|
+
|
|
20
|
+
yield Static(id="status", classes="hidden")
|
|
21
|
+
|
|
22
|
+
with Vertical(id="auth_link_container", classes="hidden"):
|
|
23
|
+
yield Static("1. Link copied to clipboard! Open it in your browser to authorize.")
|
|
24
|
+
yield TextArea("", id="auth_link",)
|
|
25
|
+
yield Static("2. Paste the authorization code here:")
|
|
26
|
+
yield Input(placeholder="Authorization Code", id="auth_code")
|
|
27
|
+
yield Button("Login", variant="primary", id="login")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def on_mount(self) -> None:
|
|
31
|
+
"""Called when the screen is mounted."""
|
|
32
|
+
self.query_one("#host").focus()
|
|
33
|
+
|
|
34
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
35
|
+
status = self.query_one("#status")
|
|
36
|
+
if event.button.id == "get_auth":
|
|
37
|
+
host = self.query_one("#host").value
|
|
38
|
+
if not host:
|
|
39
|
+
status.update("[red]Please enter a Mastodon instance.[/red]")
|
|
40
|
+
status.remove_class("hidden")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
status.update("Generating authorization link...")
|
|
44
|
+
status.remove_class("hidden")
|
|
45
|
+
auth_url, error = create_app(host)
|
|
46
|
+
if error:
|
|
47
|
+
status.update(f"[red]Error: {error}[/red]")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
pyperclip.set_clipboard("xclip")
|
|
51
|
+
pyperclip.copy(auth_url)
|
|
52
|
+
|
|
53
|
+
auth_link_input = self.query_one("#auth_link")
|
|
54
|
+
auth_link_input.text = auth_url
|
|
55
|
+
auth_link_input.read_only = True
|
|
56
|
+
self.query_one("#auth_link_container").remove_class("hidden")
|
|
57
|
+
self.query_one("#get_auth").parent.add_class("hidden") # Hide the button's parent container
|
|
58
|
+
self.query_one("#host").disabled = True
|
|
59
|
+
self.query_one("#auth_code").focus()
|
|
60
|
+
status.add_class("hidden")
|
|
61
|
+
|
|
62
|
+
elif event.button.id == "login":
|
|
63
|
+
auth_code = self.query_one("#auth_code").value
|
|
64
|
+
host = self.query_one("#host").value
|
|
65
|
+
if not auth_code:
|
|
66
|
+
status.update("[red]Please enter the authorization code.[/red]")
|
|
67
|
+
status.remove_class("hidden")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
status.update("Logging in...")
|
|
71
|
+
status.remove_class("hidden")
|
|
72
|
+
api, error = login(host, auth_code)
|
|
73
|
+
if api:
|
|
74
|
+
self.dismiss(api)
|
|
75
|
+
else:
|
|
76
|
+
status.update(f"[red]Login failed: {error}[/red]")
|