arcanechat-tui 0.11.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.
@@ -0,0 +1,127 @@
1
+ """Conversation area widget"""
2
+
3
+ from datetime import datetime
4
+ from typing import Any, Optional, Tuple
5
+
6
+ import urwid
7
+ from deltachat2 import (
8
+ Client,
9
+ Message,
10
+ MessageState,
11
+ SpecialContactId,
12
+ SystemMessageType,
13
+ )
14
+
15
+ from .lazylistwaker import LazyListWalker
16
+ from .util import shorten_text
17
+
18
+
19
+ class DayMarker(urwid.Columns):
20
+ """Day marker separating messages by day"""
21
+
22
+ def __init__(self, timestamp: int):
23
+ date = datetime.utcfromtimestamp(timestamp) # timestamp is in local time already
24
+ date_format = "%b %d, %Y"
25
+ date_label = urwid.Text(("date", date.strftime(f"\n {date_format} ")))
26
+ divider = urwid.AttrMap(urwid.Divider("─", top=1, bottom=1), "date")
27
+ date_wgt = urwid.Columns([divider, ("flow", date_label), divider])
28
+ margin = ("fixed", 1, urwid.Text(" "))
29
+ super().__init__([margin, date_wgt, margin])
30
+
31
+
32
+ class MessageItem(urwid.AttrMap):
33
+ """A single message item"""
34
+
35
+ def __init__(self, msg: Any, nickbg: str) -> None:
36
+ sender = msg.sender
37
+ sent_date = datetime.fromtimestamp(msg.timestamp)
38
+ if (
39
+ msg.show_padlock
40
+ or (sender.id <= SpecialContactId.LAST_SPECIAL and sender.id != SpecialContactId.SELF)
41
+ or msg.system_message_type == SystemMessageType.WEBXDC_INFO_MESSAGE
42
+ ):
43
+ timestamp = sent_date.strftime(" %H:%M ")
44
+ date_wgt = (len(timestamp), urwid.SelectableIcon(("encrypted", timestamp)))
45
+ else:
46
+ timestamp = sent_date.strftime("!%H:%M ")
47
+ date_wgt = (len(timestamp), urwid.SelectableIcon(("unencrypted", timestamp)))
48
+
49
+ header_wgt = get_sender_label(msg, nickbg)
50
+
51
+ lines = []
52
+ if msg.quote:
53
+ if msg.quote.kind == "WithMessage":
54
+ quote_sender = msg.quote.override_sender_name or msg.quote.author_display_name
55
+ quote_color = urwid.AttrSpec(msg.quote.author_display_color, nickbg)
56
+ lines.append((quote_color, f"│ {quote_sender}\n"))
57
+ else:
58
+ quote_color = "quote"
59
+ lines.append((quote_color, "│ "))
60
+ lines.append(("quote", f"{shorten_text(msg.quote.text, 150, placeholder='[…]')}\n"))
61
+ text = msg.text
62
+ if msg.file_name:
63
+ text = f"[{msg.file_name}]{' – ' if text else ''}{text}"
64
+ if msg.is_info:
65
+ lines.append(("system_msg", text))
66
+ else:
67
+ if sender.id == SpecialContactId.SELF:
68
+ lines.append(("self_msg", text))
69
+ else:
70
+ lines.append(text)
71
+ body_wgt = urwid.Text(lines or "")
72
+
73
+ cols = urwid.Columns([date_wgt, urwid.Pile([header_wgt, body_wgt])])
74
+ super().__init__(cols, None, focus_map="focused_item")
75
+
76
+
77
+ class ConversationWidget(urwid.ListBox):
78
+ """Display a list of messages"""
79
+
80
+ def __init__(self, client: Client, nickbg: str) -> None:
81
+ self.client = client
82
+ self.nickbg = nickbg
83
+ self.chat: Optional[Tuple[int, int]] = None
84
+ super().__init__(LazyListWalker([], self._create_message_item))
85
+
86
+ def set_chat(self, chat: Optional[Tuple[int, int]]) -> None:
87
+ self.chat = chat
88
+ if chat:
89
+ self.client.rpc.marknoticed_chat(*chat)
90
+ self._update_conversation()
91
+
92
+ def messages_changed(self, _client: Client, accid: int, chatid: int, _msgid: int) -> None:
93
+ if self.chat and accid == self.chat[0] and chatid in (self.chat[1], 0):
94
+ self._update_conversation()
95
+
96
+ def _update_conversation(self) -> None:
97
+ self.body.clear_cache()
98
+ if self.chat:
99
+ items = self.client.rpc.get_message_list_items(*self.chat, False, True)
100
+ self.body[:] = [
101
+ (self.chat[0], item.kind, item.msg_id if item.kind == "message" else item.timestamp)
102
+ for item in items
103
+ ]
104
+ if items:
105
+ self.set_focus(len(items) - 1)
106
+ else:
107
+ self.body.clear()
108
+
109
+ def _create_message_item(self, item: Tuple[int, str, int]) -> urwid.Widget:
110
+ if item[1] == "message":
111
+ self.client.rpc.markseen_msgs(item[0], [item[2]])
112
+ return MessageItem(self.client.rpc.get_message(item[0], item[2]), self.nickbg)
113
+ return DayMarker(item[2])
114
+
115
+
116
+ def get_sender_label(msg: Message, nickbg: str) -> urwid.Text:
117
+ name = shorten_text(msg.override_sender_name or msg.sender.display_name, 50)
118
+ components: list = [(urwid.AttrSpec(msg.sender.color, nickbg), name)]
119
+ if msg.state == MessageState.OUT_MDN_RCVD:
120
+ components.append(" ✓✓")
121
+ elif msg.state == MessageState.OUT_DELIVERED:
122
+ components.append(" ✓")
123
+ elif msg.state == MessageState.OUT_PENDING:
124
+ components.append(" →")
125
+ elif msg.state == MessageState.OUT_FAILED:
126
+ components.extend([" ", ("failed", " ! ")])
127
+ return urwid.Text(components)
@@ -0,0 +1,45 @@
1
+ """Event center"""
2
+
3
+ import urwid
4
+ from deltachat2 import Client, CoreEvent, EventType
5
+
6
+ CHATLIST_CHANGED = "chatlist_changed"
7
+ CHAT_CHANGED = "chat_changed"
8
+ MESSAGES_CHANGED = "msgs_changed"
9
+
10
+
11
+ class EventCenter:
12
+ """Event center dispatching Delta Chat core events"""
13
+
14
+ signals = [CHATLIST_CHANGED, CHAT_CHANGED, MESSAGES_CHANGED]
15
+
16
+ def __init__(self) -> None:
17
+ urwid.register_signal(self.__class__, self.signals)
18
+
19
+ def process_core_event(self, client: Client, accid: int, event: CoreEvent) -> None:
20
+ if event.kind == EventType.CHAT_MODIFIED:
21
+ urwid.emit_signal(self, CHAT_CHANGED, client, accid, event.chat_id)
22
+ urwid.emit_signal(self, CHATLIST_CHANGED, client, accid)
23
+
24
+ elif event.kind == EventType.CONTACTS_CHANGED:
25
+ urwid.emit_signal(self, CHATLIST_CHANGED, client, accid)
26
+
27
+ elif event.kind == EventType.INCOMING_MSG:
28
+ urwid.emit_signal(self, MESSAGES_CHANGED, client, accid, event.chat_id, event.msg_id)
29
+ urwid.emit_signal(self, CHATLIST_CHANGED, client, accid)
30
+
31
+ elif event.kind == EventType.MSGS_CHANGED:
32
+ urwid.emit_signal(self, MESSAGES_CHANGED, client, accid, event.chat_id, event.msg_id)
33
+ urwid.emit_signal(self, CHATLIST_CHANGED, client, accid)
34
+
35
+ elif event.kind == EventType.MSGS_NOTICED:
36
+ urwid.emit_signal(self, CHATLIST_CHANGED, client, accid)
37
+
38
+ elif event.kind == EventType.MSG_DELIVERED:
39
+ urwid.emit_signal(self, MESSAGES_CHANGED, client, accid, event.chat_id, event.msg_id)
40
+
41
+ elif event.kind == EventType.MSG_FAILED:
42
+ urwid.emit_signal(self, MESSAGES_CHANGED, client, accid, event.chat_id, event.msg_id)
43
+
44
+ elif event.kind == EventType.MSG_READ:
45
+ urwid.emit_signal(self, MESSAGES_CHANGED, client, accid, event.chat_id, event.msg_id)
@@ -0,0 +1,33 @@
1
+ """A ListWalker that creates the widgets dynamically as needed."""
2
+
3
+ from functools import lru_cache
4
+ from typing import Any, Callable, Iterable, NoReturn
5
+
6
+ import urwid
7
+
8
+
9
+ class LazyListWalker(urwid.SimpleListWalker):
10
+ """A ListWalker that creates the widgets dynamically as needed."""
11
+
12
+ def __init__(
13
+ self,
14
+ contents: Iterable,
15
+ widget_factory: Callable[[Any], urwid.Widget],
16
+ cache_size=1000,
17
+ wrap_around: bool = False,
18
+ ) -> None:
19
+ self.cache_size = cache_size
20
+ self._widget_factory = widget_factory
21
+ self.widget_factory = lru_cache(maxsize=cache_size)(widget_factory)
22
+ super().__init__(contents, wrap_around)
23
+
24
+ def clear_cache(self) -> None:
25
+ self.widget_factory = lru_cache(maxsize=self.cache_size)(self._widget_factory)
26
+
27
+ def __getitem__(self, position: int) -> urwid.Widget:
28
+ """return widget at position or raise an IndexError or KeyError"""
29
+ return self.widget_factory(super().__getitem__(position))
30
+
31
+ def set_modified_callback(self, callback: Callable[[], Any]) -> NoReturn:
32
+ """Ignore this, just copied from SimpleListWalker to avoid pylint warning"""
33
+ raise NotImplementedError('Use connect_signal(list_walker, "modified", ...) instead.')
@@ -0,0 +1,29 @@
1
+ """Logging to log files"""
2
+
3
+ import logging
4
+ from logging.handlers import RotatingFileHandler
5
+ from pathlib import Path
6
+
7
+ from . import APP_NAME
8
+
9
+
10
+ def create_logger(level: str, folder: Path) -> logging.Logger:
11
+ logger = logging.Logger(APP_NAME)
12
+ logger.parent = None
13
+
14
+ if level == "disabled":
15
+ logger.disabled = True
16
+ return logger
17
+
18
+ formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
19
+
20
+ log_path = folder / "logs"
21
+ log_path.mkdir(parents=True, exist_ok=True)
22
+ log_path /= "log.txt"
23
+
24
+ fhandler = RotatingFileHandler(log_path, backupCount=3, maxBytes=2000000)
25
+ fhandler.setLevel(level.upper())
26
+ fhandler.setFormatter(formatter)
27
+ logger.addHandler(fhandler)
28
+
29
+ return logger
arcanechat_tui/main.py ADDED
@@ -0,0 +1,64 @@
1
+ """Program's entry point"""
2
+
3
+ import logging
4
+ import subprocess
5
+
6
+ from deltachat2 import Client, CoreEvent, EventType, IOTransport, Rpc, events
7
+
8
+ from .application import Application
9
+ from .cli import Cli
10
+ from .logger import create_logger
11
+ from .util import get_account
12
+
13
+ FG_COLOR = "white"
14
+ BG_COLOR = "g11"
15
+ dtheme = {
16
+ "background": ["", "", "", FG_COLOR, BG_COLOR],
17
+ "status_bar": ["", "", "", "white", "g23"],
18
+ "focused_item": ["", "", "", "white", "g23"],
19
+ "separator": ["", "", "", "g15", "g15"],
20
+ "date": ["", "", "", "#6f0", BG_COLOR],
21
+ "encrypted": ["", "", "", "dark gray", BG_COLOR],
22
+ "unencrypted": ["", "", "", "dark red", BG_COLOR],
23
+ "failed": ["", "", "", "white", "dark red"],
24
+ "selected_chat": ["", "", "", "black", "light blue"],
25
+ "unread_badge": ["", "", "", "#000", "#6f0"],
26
+ "unread_badge_muted": ["", "", "", "#000", "dark gray"],
27
+ "reversed": ["", "", "", BG_COLOR, FG_COLOR],
28
+ "quote": ["", "", "", "dark gray", BG_COLOR],
29
+ "mention": ["", "", "", "bold, light red", BG_COLOR],
30
+ "system_msg": ["", "", "", "dark gray", BG_COLOR],
31
+ "self_msg": ["", "", "", "dark green", BG_COLOR],
32
+ }
33
+ dkeymap = {
34
+ "quit": "q",
35
+ "send_msg": "enter",
36
+ "insert_new_line": "meta enter",
37
+ "next_chat": "meta up",
38
+ "prev_chat": "meta down",
39
+ }
40
+ hooks = events.HookCollection()
41
+
42
+
43
+ @hooks.on(events.RawEvent)
44
+ def log_event(client: Client, accid: int, event: CoreEvent) -> None:
45
+ if event.kind == EventType.INFO:
46
+ client.logger.debug("[acc=%s] %s", accid, event.msg)
47
+ elif event.kind == EventType.WARNING:
48
+ client.logger.warning("[acc=%s] %s", accid, event.msg)
49
+ elif event.kind == EventType.ERROR:
50
+ client.logger.error("[acc=%s] %s", accid, event.msg)
51
+
52
+
53
+ def main() -> None:
54
+ args = Cli().parse_args()
55
+ args.program_folder.mkdir(parents=True, exist_ok=True)
56
+ accounts_dir = args.program_folder / "accounts"
57
+ logging.getLogger("deltachat2.IOTransport").disabled = True
58
+ with IOTransport(accounts_dir=accounts_dir, stderr=subprocess.DEVNULL) as trans:
59
+ client = Client(Rpc(trans), hooks, create_logger(args.log, args.program_folder))
60
+ if "cmd" in args:
61
+ args.cmd(client, args)
62
+ else:
63
+ accid = get_account(client.rpc, args.account)
64
+ Application(client, keymap=dkeymap, theme=dtheme).run(accid)
arcanechat_tui/util.py ADDED
@@ -0,0 +1,86 @@
1
+ """Utilities"""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from deltachat2 import ChatType, Rpc
7
+
8
+
9
+ def shorten_text(text: str, width: int, placeholder: str = "…") -> str:
10
+ text = " ".join(text.split())
11
+ if len(text) > width:
12
+ width -= len(placeholder)
13
+ assert width > 0, "placeholder can't be bigger than width"
14
+ text = text[:width].strip() + placeholder
15
+ return text
16
+
17
+
18
+ def get_subtitle(rpc: Rpc, accid: int, chat: Any) -> str:
19
+ if chat.is_self_talk:
20
+ return "Messages I sent to myself"
21
+ if chat.is_device_chat:
22
+ return "Locally generated messages"
23
+ if chat.chat_type == ChatType.MAILINGLIST:
24
+ return "Mailing List"
25
+
26
+ members = rpc.get_chat_contacts(accid, chat.id)
27
+ if chat.chat_type == ChatType.SINGLE:
28
+ subtitle = rpc.get_contact(accid, members[0]).address
29
+ elif chat.chat_type == ChatType.BROADCAST:
30
+ count = len(members)
31
+ subtitle = "1 recipient" if count == 1 else f"{count} recipients"
32
+ else:
33
+ count = len(members)
34
+ subtitle = "1 member" if count == 1 else f"{count} members"
35
+
36
+ return subtitle
37
+
38
+
39
+ def abspath(path: str) -> Path:
40
+ try:
41
+ return Path(path).expanduser().absolute()
42
+ except RuntimeError:
43
+ return Path(path).absolute()
44
+
45
+
46
+ def parse_docstring(txt) -> tuple:
47
+ """parse docstring, returning a tuple with short and long description"""
48
+ description = txt
49
+ i = txt.find(".")
50
+ if i == -1:
51
+ help_ = txt
52
+ else:
53
+ help_ = txt[: i + 1]
54
+ return help_, description
55
+
56
+
57
+ def get_or_create_account(rpc: Rpc, addr: str) -> int:
58
+ """Get account for address, if no account exists create a new one."""
59
+ accid = get_account(rpc, addr)
60
+ if not accid:
61
+ accid = rpc.add_account()
62
+ rpc.set_config(accid, "addr", addr)
63
+ return accid
64
+
65
+
66
+ def get_account(rpc: Rpc, addr: str) -> int:
67
+ """Get account id for address.
68
+ If no account exists with the given address, zero is returned.
69
+ """
70
+ if not addr:
71
+ return 0
72
+
73
+ try:
74
+ return int(addr)
75
+ except ValueError:
76
+ for accid in rpc.get_all_account_ids():
77
+ if addr == get_address(rpc, accid):
78
+ return accid
79
+
80
+ return 0
81
+
82
+
83
+ def get_address(rpc: Rpc, accid: int) -> str:
84
+ if rpc.is_configured(accid):
85
+ return rpc.get_config(accid, "configured_addr")
86
+ return rpc.get_config(accid, "addr")
@@ -0,0 +1,10 @@
1
+ """Welcome screen widget displayed when no chat is selected"""
2
+
3
+ import urwid
4
+
5
+
6
+ class WelcomeWidget(urwid.Filler):
7
+ """Welcome screen widget displayed when no chat is selected"""
8
+
9
+ def __init__(self) -> None:
10
+ super().__init__(urwid.Text("(No chat selected)", align="center"))