Added markdown rendering and compose support to messages

This commit is contained in:
Mark Qvist 2026-05-19 23:20:19 +02:00
parent 4114df70d1
commit f640f074d8
8 changed files with 109 additions and 56 deletions

View file

@ -417,6 +417,7 @@ class ConversationMessage:
self.timestamp = self.lxm.timestamp
self.sort_timestamp = os.path.getmtime(self.file_path)
if self.lxm.state > LXMF.LXMessage.GENERATING and self.lxm.state < LXMF.LXMessage.SENT:
found = False
@ -597,6 +598,18 @@ class ConversationMessage:
return {}
def content_renderer(self):
if not self.loaded: self.load()
RNS.log(self.lxm)
if self.lxm and hasattr(self.lxm, "get_fields"):
fields = self.lxm.get_fields()
RNS.log(f"has fields: {fields}")
if fields and isinstance(fields, dict):
if LXMF.FIELD_RENDERER in fields:
return fields[LXMF.FIELD_RENDERER]
return None
def has_attachments(self):
if self._cached_has_attachments is not None:
return self._cached_has_attachments

View file

@ -138,6 +138,7 @@ class NomadNetworkApp:
self.try_propagation_on_fail = True
self.disable_propagation = True
self.notify_on_new_message = True
self.compose_markdown = True
self.lxmf_max_propagation_size = None
self.lxmf_max_sync_size = None
@ -836,6 +837,10 @@ class NomadNetworkApp:
value = self.config["client"].as_bool(option)
self.notify_on_new_message = value
if option == "compose_in_markdown":
value = self.config["client"].as_bool(option)
self.compose_markdown = value
if option == "user_interface":
value = value.lower()
if value == "none":
@ -1146,7 +1151,6 @@ downloads_path = ~/Downloads
# Where to save received attachments. If not set,
# attachments will be saved to the downloads path.
# attachment_save_path = ~/Downloads
notify_on_new_message = yes
# By default, the peer is announced at startup
@ -1210,6 +1214,10 @@ max_accepted_size = 500
# been received, for every destination.
compact_announce_stream = yes
# You can choose whether or not to compose
# messages in markdown format.
compose_in_markdown = yes
[textui]
# Amount of time to show intro screen

View file

@ -5,6 +5,7 @@ import os
import platform
import nomadnet
from nomadnet.ui import THEME_DARK, THEME_LIGHT
from nomadnet.ui.textui import *
from nomadnet import NomadNetworkApp
@ -13,8 +14,6 @@ COLORMODE_16 = 16
COLORMODE_88 = 88
COLORMODE_256 = 256
COLORMODE_TRUE = 2**24
THEME_DARK = 0x01
THEME_LIGHT = 0x02
THEMES = {
THEME_DARK: {

View file

@ -8,6 +8,8 @@ pyc_modules = glob.glob(os.path.dirname(__file__)+"/*.pyc")
modules = py_modules+pyc_modules
__all__ = list(set([os.path.basename(f).replace(".pyc", "").replace(".py", "") for f in modules if not (f.endswith("__init__.py") or f.endswith("__init__.pyc"))]))
THEME_DARK = 0x01
THEME_LIGHT = 0x02
UI_NONE = 0x00
UI_MENU = 0x01

View file

@ -13,10 +13,11 @@ from nomadnet.ui.textui.MicronParser import LinkableText, LinkSpec
from RNS.Utilities.rngit.util import MarkdownToMicron
from RNS.Utilities.rngit.highlight import SyntaxHighlighter
from .MicronParser import markup_to_attrmaps
from nomadnet.util import strip_modifiers
from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags
theme_dark = { "ts": "888",
theme_dark = { "text": "ddd",
"ts": "888",
"nick_self": "6c5",
"nick_peer": "3cd",
"notice": "fd3",
@ -25,7 +26,8 @@ theme_dark = { "ts": "888",
"mention": "fb4",
"link": "79d", }
theme_light = { "ts": "888",
theme_light = { "text": "111",
"ts": "888",
"nick_self": "3a0",
"nick_peer": "077",
"notice": "a70",
@ -899,47 +901,6 @@ class _ChatLinkDelegate:
except Exception as e:
RNS.log("Could not open page link: "+str(e), RNS.LOG_ERROR)
def strip_micron(text):
text = re.sub(r'`[FB][0-9a-fA-F]{3}', '', text)
text = re.sub(r'`[FB]T[0-9a-fA-F]{6}', '', text)
text = re.sub(r'`[!*_=]', '', text)
text = re.sub(r'`f`b', '', text)
text = re.sub(r'`f', '', text)
text = re.sub(r'`b', '', text)
text = re.sub(r'`<', '', text)
text = re.sub(r'`>', '', text)
text = re.sub(r'`{', '', text)
return text
def strip_escaped_micron(text):
text = re.sub(r'¦[FB][0-9a-fA-F]{3}', '', text)
text = re.sub(r'¦[FB]T[0-9a-fA-F]{6}', '', text)
text = re.sub(r'¦[!*_=]', '', text)
text = re.sub(r'¦f`b', '', text)
text = re.sub(r'¦f', '', text)
text = re.sub(r'¦b', '', text)
text = re.sub(r'¦<', '', text)
text = re.sub(r'¦>', '', text)
text = re.sub(r'¦{', '', text)
return text
def unescape_micron(text):
text = re.sub(r'¦([FB][0-9a-fA-F]{3})', r'`\1', text)
text = re.sub(r'¦([FB]T[0-9a-fA-F]{6})', r'`\1', text)
text = re.sub(r'¦([!*_=])', r'`\1', text)
text = re.sub(r'¦(f`b)', r'`\1', text)
text = re.sub(r'¦(f)', r'`\1', text)
text = re.sub(r'¦(b)', r'`\1', text)
text = re.sub(r'¦(<)', r'`\1', text)
text = re.sub(r'¦(>)', r'`\1', text)
text = re.sub(r'¦({)', r'`\1', text)
return text
def strip_non_formatting_tags(text):
text = re.sub(r'`<', '', text)
text = re.sub(r'`>', '', text)
text = re.sub(r'`{', '', text)
return text
mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None)
def _message_widget(app, hub, m, link_delegate=None):
@ -1023,22 +984,22 @@ def _message_widget(app, hub, m, link_delegate=None):
prefix_micron = f"{irc_ts}{_ts_prefix_raw(m.ts)}"
nick_micron = f"`f{nick_attr}<{sender}>`f "
if app.rrc_ui_justify_msgs:
prefix_rendered = _render_body(f"{prefix_micron}")
body_rendered = _render_body(f"{nick_micron}{message_body}")
prefix_rendered = _render_body(f"{prefix_micron}", fg=t["text"])
body_rendered = _render_body(f"{nick_micron}{message_body}", fg=t["text"])
if app.rrc_ui_space_msgs: body_rendered.append(urwid.Text(""))
columns = urwid.Columns([(urwid.PACK, urwid.Pile(prefix_rendered)), urwid.Pile(body_rendered)], dividechars=1)
final_widget = urwid.Padding(columns, left=1)
else:
rendered = _render_body(f"{prefix_micron}{nick_micron}{message_body}", link_delegate=ld)
rendered = _render_body(f"{prefix_micron}{nick_micron}{message_body}", link_delegate=ld, fg=t["text"])
if app.rrc_ui_space_msgs: rendered.append(urwid.Text(""))
final_widget = urwid.Padding(urwid.Pile(rendered), left=1)
final_widget.msg = m
return final_widget
def _render_body(markup, link_delegate=None):
try: return markup_to_attrmaps(strip_modifiers(markup), url_delegate=link_delegate, link_class=_ChatLinkableText)
def _render_body(markup, link_delegate=None, fg="bbb"):
try: return markup_to_attrmaps(strip_modifiers(markup), url_delegate=link_delegate, link_class=_ChatLinkableText, fg_color=fg)
except Exception as e:
RNS.trace_exception(e)
return []

View file

@ -15,6 +15,11 @@ from nomadnet.Conversation import ConversationMessage
from nomadnet.util import strip_modifiers
from nomadnet.util import sanitize_name
from RNS.Utilities.rngit.util import MarkdownToMicron
from RNS.Utilities.rngit.highlight import SyntaxHighlighter
from .MicronParser import markup_to_attrmaps
from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags
from nomadnet.ui import THEME_DARK, THEME_LIGHT
def relative_time(timestamp):
now = time.time()
@ -1302,7 +1307,7 @@ class ConversationWidget(urwid.WidgetWrap):
if not message_hash in added_hashes:
added_hashes.append(message_hash)
was_loaded = message.loaded
message_widget = LXMessageWidget(message)
message_widget = LXMessageWidget(message, theme=self.app.config["textui"]["theme"])
self.message_widgets.append(message_widget)
if not was_loaded and message.loaded:
needs_index.append(message)
@ -1467,6 +1472,10 @@ class ConversationWidget(urwid.WidgetWrap):
if file_attachments:
fields = {LXMF.FIELD_FILE_ATTACHMENTS: file_attachments}
if self.app.compose_markdown:
if not fields: fields = {}
fields[LXMF.FIELD_RENDERER] = LXMF.RENDERER_MARKDOWN
if self.conversation.send(content, title, fields=fields):
self.clear_editor()
@ -1609,7 +1618,9 @@ class ConversationWidget(urwid.WidgetWrap):
class LXMessageWidget(urwid.WidgetWrap):
def __init__(self, message):
mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None)
def __init__(self, message, theme=THEME_DARK):
app = nomadnet.NomadNetworkApp.get_shared_instance()
g = app.ui.glyphs
self.timestamp = message.get_timestamp()
@ -1623,6 +1634,7 @@ class LXMessageWidget(urwid.WidgetWrap):
msg_method = message._cached_method
time_format = app.time_format
message_time = datetime.fromtimestamp(self.timestamp)
renderer = message.content_renderer()
encryption_string = ""
if message.get_transport_encrypted():
encryption_string = " "+g["encrypted"]
@ -1685,7 +1697,16 @@ class LXMessageWidget(urwid.WidgetWrap):
content_text = message.get_content()
content_lines = content_text.split("\n")
indented = "\n".join(" "+line for line in content_lines)
markdown = renderer == LXMF.RENDERER_MARKDOWN
default_fg = "bbb" if theme == THEME_DARK else "444"
if markdown:
formatted = self.mdc.format_block(content_text)
message_body = strip_non_formatting_tags(formatted)
rendered = markup_to_attrmaps(strip_modifiers(message_body), url_delegate=None, fg_color=default_fg, bg_color=None)
content_pile = urwid.Padding(urwid.Pile(rendered), left=2, right=2)
else: indented = "\n".join(" "+line for line in content_lines)
pile_widgets = [title]
@ -1711,7 +1732,8 @@ class LXMessageWidget(urwid.WidgetWrap):
pile_widgets.append(self.progress_attr)
self._start_progress_poll()
pile_widgets.append(urwid.Text(indented))
if markdown: pile_widgets.append(content_pile)
else: pile_widgets.append(urwid.Text(indented))
if has_attachments and cached_names:
att_file_idx = 0

View file

@ -771,6 +771,12 @@ The maximum accepted unpacked size for messages received directly from other pee
With this option enabled, Nomad Network will only display one entry in the announce stream per destination. Older announces are culled when a new one arrives.
<
>>>
`!compose_in_markdown = yes`!
>>>>
Configures whether sent messages are composed in markdown format.
<
>> RRC Section
This section hold configuration directives related to the RRC client behaviour. It is delimited by the `![rrc]`! header in the configuration file. Available directives, along with their default values, are as follows:

View file

@ -141,3 +141,45 @@ def sanitize_name(name):
name = name.strip()
return name
def strip_micron(text):
text = re.sub(r'`[FB][0-9a-fA-F]{3}', '', text)
text = re.sub(r'`[FB]T[0-9a-fA-F]{6}', '', text)
text = re.sub(r'`[!*_=]', '', text)
text = re.sub(r'`f`b', '', text)
text = re.sub(r'`f', '', text)
text = re.sub(r'`b', '', text)
text = re.sub(r'`<', '', text)
text = re.sub(r'`>', '', text)
text = re.sub(r'`{', '', text)
return text
def strip_escaped_micron(text):
text = re.sub(r'¦[FB][0-9a-fA-F]{3}', '', text)
text = re.sub(r'¦[FB]T[0-9a-fA-F]{6}', '', text)
text = re.sub(r'¦[!*_=]', '', text)
text = re.sub(r'¦f`b', '', text)
text = re.sub(r'¦f', '', text)
text = re.sub(r'¦b', '', text)
text = re.sub(r'¦<', '', text)
text = re.sub(r'¦>', '', text)
text = re.sub(r'¦{', '', text)
return text
def unescape_micron(text):
text = re.sub(r'¦([FB][0-9a-fA-F]{3})', r'`\1', text)
text = re.sub(r'¦([FB]T[0-9a-fA-F]{6})', r'`\1', text)
text = re.sub(r'¦([!*_=])', r'`\1', text)
text = re.sub(r'¦(f`b)', r'`\1', text)
text = re.sub(r'¦(f)', r'`\1', text)
text = re.sub(r'¦(b)', r'`\1', text)
text = re.sub(r'¦(<)', r'`\1', text)
text = re.sub(r'¦(>)', r'`\1', text)
text = re.sub(r'¦({)', r'`\1', text)
return text
def strip_non_formatting_tags(text):
text = re.sub(r'`<', '', text)
text = re.sub(r'`>', '', text)
text = re.sub(r'`{', '', text)
return text