Author SHA1 Message Date
richandClaude Opus 5.5 d9426166c8 Search every emoji by name in the reaction dialog
CI / lint (push) Canceled after 0s
CI / test macos-latest py3.14 (push) Canceled after 0s
CI / test ubuntu-24.04 py3.10 (push) Canceled after 0s
CI / test ubuntu-latest py3.11 (push) Canceled after 0s
CI / test ubuntu-latest py3.12 (push) Canceled after 0s
CI / test ubuntu-latest py3.13 (push) Canceled after 0s
CI / test ubuntu-latest py3.14 (push) Canceled after 0s
CI / test windows-latest py3.10 (push) Canceled after 0s
CI / test windows-latest py3.14 (push) Canceled after 0s
CI / build (push) Canceled after 0s
Issue trailers / check (push) Canceled after 0s
^E still lists the reaction_emoji preference first, under "Your reactions". Under
"All emoji" it now lists each other emoji with its Unicode name, and the list
filters as you type: "fire" finds 🔥, "heart" finds the hearts. The catalog comes
from the Unicode database of the Python that runs MeshTerm and from Rich's table of
text-default emoji, so it needs no data file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:47:13 -04:00
richandClaude Opus 5.5 f3aa8e5aef Allow channel sender names of up to 31 characters
The firmware keeps a node name in 32 bytes, but the sender prefix and the @[Name]
mention matched only 20 characters. A longer name stayed in the body: the transcript
showed no sender for it, and its reactions showed as new messages. Found on hardware
with "Hazelwood r0b3.Anduril" (22 characters).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:47:13 -04:00
richandClaude Haiku 5.5 a323342971 Add emoji reactions in the wire format of MeshCore One
Select a message in a channel or a DM and press ^E (Shift+F2 on the handhelds) to
react with an emoji. A reaction is a usual message on the wire: MeshTerm writes the
format of MeshCore One and reads it and the two formats of meshcore-open. The
transcript folds each reaction under the message that it names, with a count, or
the names of the nodes while that message is selected. A reaction does not raise
the unread badge, and the Messages list previews it as "reacted 👍".

A reaction names its target by the sender's timestamp. Thus MeshTerm now gives the
device the timestamp of each message it sends and stores exactly that second. A
retry of a DM keeps the timestamp and counts the try number, as the protocol
specifies. The emoji that ^E offers are the reaction_emoji preference. Rooms have
no reactions.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-15 07:47:13 -04:00
19 changed files with 1554 additions and 621 deletions

No files matched your search

-1
View File
@@ -1734,7 +1734,6 @@ flood_advert_cooldown_s 60 60
weekly_flood_advert off off Flood an advert after a week without one
advert_quiet_s 30 30 Silence to wait for before the weekly advert
direct_message_soft_retries 0 0 Times to resend a message that gets no reply
message_parts_max 3 3 Send a long message as this many parts at most
tx_opt_min 18 18 Weakest power it will try
tx_opt_max 28 28 Strongest power it will try
tx_snr_tolerance_db 1 1 Signals this close are a tie, so less power wins
+1 -2
View File
@@ -194,8 +194,7 @@ and in quoted text.
| packet | One radio transmission | frame (for radio data) |
| protocol frame | One unit of the companion protocol on USB, Bluetooth, or TCP: a command, a reply, or a push from the companion. In code that handles only this protocol, "frame" alone is permitted | packet (for this) |
| message | A text message, direct or on a channel | |
| part | One of the messages that MeshTerm sends for a message that is too long for one packet | chunk, segment |
| part number | The `[1/3]` at the end of a part | marker (for this) |
| reaction | An emoji that a node sends to answer one message. It is a message on the wire, and the chat shows it under the message that it names | |
| send | MeshTerm gives a message, a packet, or a command to the device | emit |
| transmit | A radio puts a packet on the air | |
| broadcast | Transmit to all nodes, with no destination | |
+1 -1
View File
@@ -15,7 +15,7 @@ This page lists each interactive screen with its scripted equivalent, where one
| Feature | What it does | Scripted |
| --- | --- | --- |
| **💬 Chat** | Live full-screen messages on channels and to contacts. The transcript scrolls, the input line stays at the bottom, and sent and received messages appear together. The rooms that you joined are also here. Each post shows under the name of its author. A message that is too long for one packet goes out as parts, each ending with its number (`[1/3]`). MeshTerm stores each message. Unread counts show in the menu header. | `meshterm chat send / history / list / listen` |
| **💬 Chat** | Live full-screen messages on channels and to contacts. The transcript scrolls, the input line stays at the bottom, and sent and received messages appear together. The rooms that you joined are also here. Each post shows under the name of its author. Select a message and press **^E** to react to it with an emoji. The reaction shows under the message, and MeshCore One and meshcore-open show it too. The emoji that ^E offers are a preference. MeshTerm stores each message. Unread counts show in the menu header. | `meshterm chat send / history / list / listen` |
| **📻 Channels** | Create, join, reorder, mute, and share mesh channels. You can share a channel with a QR code or a `meshcore://` link. If you give a channel a **send scope**, its messages are flooded into one region. Only the repeaters that carry that region relay them. When you change to a new device, export all the channels to a file, then import the file on the new device. The **channel guide** lists the public channels that MeshTerm heard, also the channels that are not on your device, and adds one with Enter. | `meshterm channels list / add / join / import / share / clear / scope / export / guide` |
| **📌 Rooms** | Each room server that your radio knows. A room server is a message board on a radio. It keeps the latest posts and sends you the posts that you missed. Join a room with its password. If a room does not answer, MeshTerm tells you what it knows and offers the next step to try. For example, it can send again by flood when the route that your radio learned is no longer good. | `meshterm rooms list / join / forget` |
| **📨 Courier** | A store-and-forward outbox for contacts that you cannot reach yet. Queue a message. MeshTerm sends it when the contact is next heard, or at a time that you set. It tracks the acks, and it waits longer between each try (exponential backoff). | `meshterm courier queue / list / send / cancel / clear` |
+9 -5
View File
@@ -35,17 +35,21 @@ from urllib.parse import parse_qs, quote, urlsplit
# not one time for each candidate channel. Thus the reception path pays almost nothing.
#: Matches the ``Name: message`` convention that channel senders use to identify themselves
#: (the protocol has no sender field). The name is 1–20 characters that are not colons, and
#: (the protocol has no sender field). The name is 1–31 characters that are not colons, and
#: ``": "`` must follow it. This rule is strict enough that it does not match ``http://…``
#: or ``note:x``.
SENDER_PREFIX = re.compile(r"^([^\s:][^:]{0,19}):[ \t]+(.*)$", re.DOTALL)
#: or ``note:x``. The firmware keeps a node name in 32 bytes with its terminator, so 31 is
#: the longest name that a sender can have. A limit of 20 left
#: a longer name in the body. Then the transcript showed no sender for it, and a reaction
#: from it did not read as a reaction.
SENDER_PREFIX = re.compile(r"^([^\s:][^:]{0,30}):[ \t]+(.*)$", re.DOTALL)
#: Matches an ``@[Name]`` mention token, which the reply flow puts into the compose line in
#: advance. A transcript renders each token as a bare ``@Name`` in the hue of that sender,
#: and does not show the brackets. The history face of the CLI also takes out the same
#: names. Thus the token is defined here, with the remainder of the message-body parsing, and
#: not in one of the two renderers. The name is 1–20 characters that are not ``]``.
MENTION = re.compile(r"@\[([^\]]{1,20})\]")
#: not in one of the two renderers. The name is 1–31 characters that are not ``]``, the
#: same limit as :data:`SENDER_PREFIX`.
MENTION = re.compile(r"@\[([^\]]{1,31})\]")
def split_channel_sender(text: str) -> tuple[str | None, str]:
+31 -12
View File
@@ -1557,12 +1557,21 @@ class Device(ABC):
# -- messaging ---------------------------------------------------------------
@abstractmethod
async def send_direct_message(self, contact: Contact, text: str) -> Delivery:
async def send_direct_message(
self, contact: Contact, text: str, *, timestamp: int | None = None, attempt: int = 0
) -> Delivery:
"""Send a direct text message to a contact, and wait some time for its ack.
Args:
contact: The recipient. Its ``public_key`` addresses the message.
text: The message body.
timestamp: The timestamp that the message carries, in seconds. ``None`` uses
the clock of this computer. The recipient sees this value, and a reaction
names the message by it (refer to :mod:`~meshterm.core.reactions`). Thus the
caller gives it when it must know the value.
attempt: The number of the try (0 to 3). A retry of the same message has the
same timestamp and the next number, as the protocol specifies. The number
makes the packet of the retry different from the first one.
Returns:
The :class:`~meshterm.core.models.Delivery`: the ack code that the radio
@@ -1574,12 +1583,16 @@ class Device(ABC):
"""
@abstractmethod
async def send_channel_message(self, index: int, text: str) -> None:
async def send_channel_message(
self, index: int, text: str, *, timestamp: int | None = None
) -> None:
"""Broadcast a text message on a channel slot.
Args:
index: The zero-based channel slot on which to transmit.
text: The message body.
timestamp: The timestamp that the message carries, in seconds. ``None`` uses
the clock of this computer (refer to :meth:`send_direct_message`).
Raises:
DeviceCommandError: If the companion rejected the send.
@@ -1631,7 +1644,9 @@ class Device(ABC):
self._scope_leaked = None
yield
async def send_channel_in_scope(self, index: int, text: str, scope: str | None) -> None:
async def send_channel_in_scope(
self, index: int, text: str, scope: str | None, *, timestamp: int | None = None
) -> None:
"""Broadcast on a channel in a given scope, and leave the session scope as it was.
This is the only way that MeshTerm sends a channel message. ``None`` is the plain
@@ -1659,6 +1674,8 @@ class Device(ABC):
text: The message body.
scope: A region name, :data:`~meshterm.core.regions.WILDCARD` for unscoped, or
``None`` for the default of the device.
timestamp: The timestamp that the message carries (refer to
:meth:`send_channel_message`).
Raises:
FloodScopeError: If the companion cannot send in ``scope``. Nothing was sent.
@@ -1671,10 +1688,10 @@ class Device(ABC):
bare = validate_region(bare)
async with self.transmitting():
if not bare:
await self.send_channel_message(index, text)
await self.send_channel_message(index, text, timestamp=timestamp)
return
if bare == REGION_WILDCARD and await self._unscoped_is_plain():
await self.send_channel_message(index, text)
await self.send_channel_message(index, text, timestamp=timestamp)
return
try:
await self.set_flood_scope(bare)
@@ -1682,7 +1699,7 @@ class Device(ABC):
raise FloodScopeError(bare, _scope_refusal(bare, exc)) from exc
self._scope_active = True
try:
await self.send_channel_message(index, text)
await self.send_channel_message(index, text, timestamp=timestamp)
finally:
self._scope_active = False
try:
@@ -4117,7 +4134,7 @@ class MeshCoreDevice(Device):
return stop_pump
async def send_direct_message( # noqa: D102 - inherited docstring
self, contact: Contact, text: str
self, contact: Contact, text: str, *, timestamp: int | None = None, attempt: int = 0
) -> Delivery:
transmit_gate.mark()
from meshcore import EventType
@@ -4146,7 +4163,9 @@ class MeshCoreDevice(Device):
try:
# Only the hand-over holds the transmit lock. The ack wait below is not a send.
async with self.transmitting():
result = await mc.commands.send_msg(pub, text)
result = await mc.commands.send_msg(
pub, text, timestamp=timestamp, attempt=attempt & 3
)
if result is None or getattr(result, "is_error", lambda: False)():
# A recipient for which the firmware has no entry is the only rejection with
# an obvious repair. Thus it gets its own class, so the chat screen can offer
@@ -4202,11 +4221,11 @@ class MeshCoreDevice(Device):
return Delivery(code[0], ack, flood)
async def send_channel_message( # noqa: D102 - inherited docstring
self, index: int, text: str
self, index: int, text: str, *, timestamp: int | None = None
) -> None:
transmit_gate.mark()
async with self.transmitting():
self._ok(await self._require().commands.send_chan_msg(index, text))
self._ok(await self._require().commands.send_chan_msg(index, text, timestamp))
@staticmethod
def _ok(event): # type: ignore[no-untyped-def]
@@ -4886,7 +4905,7 @@ class MockDevice(Device):
self._tx_power = value
async def send_direct_message( # noqa: D102 - inherited docstring
self, contact: Contact, text: str
self, contact: Contact, text: str, *, timestamp: int | None = None, attempt: int = 0
) -> Ack | None:
transmit_gate.mark()
async with self.transmitting():
@@ -4931,7 +4950,7 @@ class MockDevice(Device):
task.add_done_callback(self._bg_tasks.discard)
async def send_channel_message( # noqa: D102 - inherited docstring
self, index: int, text: str
self, index: int, text: str, *, timestamp: int | None = None
) -> None:
transmit_gate.mark()
async with self.transmitting():
-176
View File
@@ -1,176 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""Cut a message that is too long for one packet into parts that each fit.
A MeshCore message must fit in one LoRa packet (refer to
:data:`~meshterm.ui.tui.prompt.DM_BYTE_LIMIT`). When the text of a message is longer than
this limit, MeshTerm can send it as more than one message. Each of these messages is a
**part**, and each part ends with its **part number**: ``[1/3]``, ``[2/3]``, ``[3/3]``. The
part number goes at the end, so that the start of the first part (the ``@[mention]`` of a
reply) stays where a person reads first. Each part, with its part number, fits in the
limit.
A cut goes at a space when the window of the part has one. Thus each word stays complete.
A word that is longer than a part (a long URL) has no space to cut at, and MeshTerm cuts it
between two characters. Such a cut never divides a character, and it does not go into a
sequence that shows as one symbol: an accent after its letter, a skin tone or a variation
selector after its emoji, or the two sides of a zero-width joiner.
The receiver needs nothing to read the parts. Each part is an ordinary message.
"""
from __future__ import annotations
import re
import unicodedata
#: The marks that join to the character before them. A cut before one of these divides a
#: symbol that the user sees as one: U+200D zero-width joiner, U+FE0E and U+FE0F variation
#: selectors, U+20E3 combining keycap.
_JOINERS = frozenset("‍︎️⃣")
_SPACE = re.compile(r"\s")
def _bytes(text: str) -> int:
"""The UTF-8 length of ``text``: what counts against the limit of a packet."""
return len(text.encode("utf-8"))
def part_number(index: int, total: int) -> str:
"""The text that ends part ``index`` of ``total``: a space, then ``[index/total]``."""
return f" [{index}/{total}]"
def _joins_previous(ch: str) -> bool:
"""Whether ``ch`` joins to the character before it, so that a cut cannot go before it."""
code = ord(ch)
return (
ch in _JOINERS
or unicodedata.category(ch) in ("Mn", "Me", "Mc")
or 0x1F3FB <= code <= 0x1F3FF # skin tone modifiers
or 0xE0020 <= code <= 0xE007F # tag characters (subdivision flags)
)
def _hard_cut(text: str, end: int) -> int:
"""Move a cut at ``end`` back, so that it does not divide a symbol.
Returns the new cut. It is never less than 1, so that each part has text.
"""
cut = end
while cut > 1 and (_joins_previous(text[cut]) or text[cut - 1] == "‍"):
cut -= 1
return cut if cut > 1 else end
def _fit(text: str, start: int, budget: int) -> int:
"""The end of the longest run of ``text`` from ``start`` that fits in ``budget`` bytes."""
used = 0
end = start
while end < len(text):
used += _bytes(text[end])
if used > budget:
break
end += 1
return end
def _spans(text: str, budget: int) -> list[tuple[int, int]]:
"""Cut ``text`` into runs of ``budget`` bytes or less, as ``(start, end)`` indexes.
The runs have no whitespace at their two ends. The whitespace between two runs is not
in a run.
"""
spans: list[tuple[int, int]] = []
start = 0
while start < len(text) and text[start].isspace():
start += 1
while start < len(text):
end = _fit(text, start, budget)
if end == len(text):
spans.append((start, end))
break
if end == start: # one character is larger than the budget: it cannot fit at all
end = start + 1
# Look for the last space in the window, and also at the first character after it:
# a space there means that the window ends at the end of a word.
space = next(
(i for i in range(end, start, -1) if _SPACE.match(text[i])),
None,
)
if space is not None:
stop = space
while stop > start and text[stop - 1].isspace():
stop -= 1
spans.append((start, stop))
start = space
else:
cut = start + _hard_cut(text[start:], end - start)
spans.append((start, cut))
start = cut
while start < len(text) and text[start].isspace():
start += 1
return spans
def _plan(text: str, limit: int, max_parts: int) -> tuple[list[tuple[int, int]], int]:
"""Cut ``text`` for ``max_parts`` parts at most, as spans and the total of the cut.
The size of a part number changes with the total (``[9/9]`` is shorter than
``[10/10]``). Thus the budget for the text of a part comes from a total, and the cut
gives a new total. The plan starts at two parts and goes up until the two agree. The
returned spans can be more than ``max_parts``: the caller then knows where the text
that does not fit starts.
"""
total = 2
while True:
budget = limit - _bytes(part_number(total, total))
spans = _spans(text, max(1, budget))
if len(spans) <= total or total >= max_parts:
return spans, total
total = min(max_parts, max(total + 1, len(spans)))
def split(text: str, limit: int, max_parts: int) -> list[str] | None:
"""Cut ``text`` into the parts that MeshTerm sends, each with its part number.
Args:
text: The text of the message.
limit: The UTF-8 byte limit of one message.
max_parts: The largest number of parts. With 1, MeshTerm never cuts a message.
Returns:
The texts to send, in order. A text that fits in ``limit`` is one part with no part
number. ``None`` when the text needs more than ``max_parts`` parts.
"""
if _bytes(text) <= limit:
return [text]
if max_parts < 2:
return None
spans, _ = _plan(text, limit, max_parts)
if len(spans) > max_parts:
return None
total = len(spans)
return [text[a:b] + part_number(i, total) for i, (a, b) in enumerate(spans, 1)]
def overflow_at(text: str, limit: int, max_parts: int) -> int | None:
"""The index of the first character of ``text`` that does not fit, or ``None``.
With ``max_parts`` of 1, this is the first character whose bytes go past ``limit``.
With more, it is the start of the text that would go in the part after the last one.
"""
if _bytes(text) <= limit:
return None
if max_parts < 2:
return _fit(text, 0, limit)
spans, _ = _plan(text, limit, max_parts)
if len(spans) <= max_parts:
return None
return spans[max_parts][0]
def unnumbered(part: str, index: int, total: int) -> str:
"""The text of ``part`` without its part number (``part`` itself when it has none)."""
tail = part_number(index, total)
return part[: -len(tail)] if total > 1 and part.endswith(tail) else part
+8 -10
View File
@@ -55,6 +55,7 @@ from .atomicwrite import write_atomically
from .courier_store import DONE_CAP
from .geo import DEFAULT_VIEW_FRACTION
from .models import NAME_OVERLAP_DAYS
from .reactions import DEFAULT_REACTIONS
from .watch_store import ALERT_CAP, DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
if sys.version_info >= (3, 11):
@@ -291,17 +292,14 @@ PREFERENCES: tuple[PrefSpec, ...] = (
maximum=2,
),
PrefSpec(
key="message_parts_max",
label="Message parts",
help="Send a long message as this many parts at most",
key="reaction_emoji",
label="Reactions",
help="Emoji to offer when you react to a message",
group="Sending",
value_type="int",
# Each part is a transmission, and a channel part is a flood. Three parts hold
# about 370 bytes on a channel. With 1, MeshTerm never cuts a message, and a long
# message is refused as before. Refer to meshterm.core.parts.
default=3,
minimum=1,
maximum=9,
value_type="str",
# The default set of MeshCore One. A word that is not one emoji is ignored when the
# chat reads the list (refer to meshterm.core.reactions.reaction_set).
default=DEFAULT_REACTIONS,
),
PrefSpec(
key="room_relogin_minutes",
+538
View File
@@ -0,0 +1,538 @@
# SPDX-License-Identifier: Apache-2.0
"""Emoji reactions to chat messages, in the wire format of MeshCore One.
A reaction is a usual text message. The firmware and the repeaters do not know the format.
Only the apps do. MeshTerm writes the format of MeshCore One, and reads it and the two
formats of meshcore-open. The protocol is in ``docs/Reactions.md`` of MeshCore One. In short:
* A reaction names its target message by a hash of the body of the target and the
timestamp of its sender: SHA-256 of the UTF-8 body and the timestamp as a little-endian
32-bit integer, the first five bytes, in Crockford Base32 (eight characters). Each node
that received the target calculates the same hash, because the sender's timestamp travels
with the message.
* On a channel, the reaction also names the author of the target, because two nodes can
send the same body in the same second: ``@[Author]👍`` then a newline and the hash.
Before MeshCore One 1.4.1, the emoji came first (``👍@[Author]``). MeshTerm reads the two
orders and writes the new one.
* In a direct exchange, there are two persons only, so the reaction is ``👍``, a newline,
and the hash.
A body that only starts with an emoji is a usual message. The emoji part must be exactly
one emoji (one glyph) for the body to be a reaction.
MeshTerm stores a reaction as the usual message that it is. Thus the history keeps all that
was received. The chat screen folds the reactions of a transcript onto their targets when it
shows the transcript (refer to :class:`ReactionFold`).
"""
from __future__ import annotations
import hashlib
import unicodedata
from dataclasses import dataclass, field
from datetime import datetime
from .channels import split_channel_sender
from .models import ChatMessage
#: The reactions that the reaction dialog offers when the preference is empty. They are the
#: default set of MeshCore One, in its order.
DEFAULT_REACTIONS = "👍 👎 ❤️ 😂 😮 😢"
#: How far apart in time, in seconds, a reaction of meshcore-open and its target can be. The
#: short hash of meshcore-open (16 bits) matches many messages in a long history. Thus
#: MeshTerm compares it only with the messages near the reaction in time. MeshCore One uses
#: the same window.
WINDOW_S = 300
#: The maximum number of reactions that wait for a target that did not arrive yet.
PENDING_CAP = 100
# -- Crockford Base32 ---------------------------------------------------------------------
_CROCKFORD = "0123456789abcdefghjkmnpqrstvwxyz"
_DECODE = {char: char for char in _CROCKFORD}
_DECODE.update({char.upper(): char for char in _CROCKFORD})
# The letters that a person can mistake for a digit read as that digit.
_DECODE.update({"o": "0", "O": "0", "i": "1", "I": "1", "l": "1", "L": "1"})
def _crockford(data: bytes) -> str:
"""Encode five bytes as eight Crockford Base32 characters, the most significant first."""
bits = int.from_bytes(data[:5], "big")
return "".join(_CROCKFORD[(bits >> shift) & 0x1F] for shift in range(35, -5, -5))
def _normal_hash(raw: str) -> str | None:
"""The canonical (lowercase) form of an eight-character hash, or ``None`` if it is not one."""
if len(raw) != 8:
return None
out = [_DECODE.get(char) for char in raw]
if None in out:
return None
return "".join(out) # type: ignore[arg-type] - None is excluded above
def message_hash(body: str, timestamp: int) -> str:
"""The hash with which a reaction names its target message.
Args:
body: The body of the target. On a channel, this is the text after the ``Name: ``
prefix of the sender.
timestamp: The timestamp that the sender gave the target, in seconds.
Returns:
Eight lowercase Crockford Base32 characters.
"""
data = body.encode("utf-8") + (timestamp & 0xFFFFFFFF).to_bytes(4, "little")
return _crockford(hashlib.sha256(data).digest())
def wire_timestamp(when: datetime) -> int:
"""The timestamp of a message as its sender put it on the wire: whole seconds."""
return int(when.timestamp())
# -- the emoji part -----------------------------------------------------------------------
_VS = frozenset("︎️")
_ZWJ = "‍"
_KEYCAP = "⃣"
_TONES = frozenset(chr(cp) for cp in range(0x1F3FB, 0x1F400))
_TAGS = frozenset(chr(cp) for cp in range(0xE0020, 0xE0080))
def _is_regional(char: str) -> bool:
"""Whether ``char`` is a Regional Indicator (one half of a flag)."""
return 0x1F1E6 <= ord(char) <= 0x1F1FF
def _is_pictograph(char: str) -> bool:
"""Whether ``char`` can start an emoji: a symbol outside the Latin and punctuation blocks."""
return ord(char) >= 0x2000 and unicodedata.category(char) == "So"
def is_reaction_emoji(value: str) -> bool:
"""Whether ``value`` is exactly one emoji: the emoji part of a reaction.
One emoji is one glyph. It can be a flag (two Regional Indicators), a keycap (``1️⃣``),
or pictographs joined with zero-width joiners (``❤️‍🔥``). Each pictograph can have a
variation selector, a skin tone, and a tag sequence. Text, or an emoji with text after
it, is not a reaction.
"""
if not value or value.isascii():
return False
if len(value) == 2 and _is_regional(value[0]) and _is_regional(value[1]):
return True
if value[0] in "0123456789#*":
return value[1:] in (_KEYCAP, "️" + _KEYCAP)
for part in value.split(_ZWJ):
if not part or not _is_pictograph(part[0]):
return False
if any(char not in _VS and char not in _TONES and char not in _TAGS for char in part[1:]):
return False
return True
def emoji_name(emoji: str) -> str:
"""A name for an emoji, for a person to read: its Unicode name in lowercase."""
try:
return unicodedata.name(emoji[0]).lower()
except (ValueError, IndexError):
return "emoji"
def reaction_set(preference: str) -> list[str]:
"""The emoji that the reaction dialog offers, from the ``reaction_emoji`` preference.
The preference is a list of emoji with spaces between them. A word that is not one emoji
is ignored, and so is a second copy of an emoji. An empty result gives
:data:`DEFAULT_REACTIONS`, because a reaction dialog with no rows is of no use.
"""
seen: list[str] = []
for word in (preference or "").split():
if is_reaction_emoji(word) and word not in seen:
seen.append(word)
return seen or DEFAULT_REACTIONS.split()
# -- the wire format ----------------------------------------------------------------------
#: The three forms of a reaction on the wire.
MC1 = "mc1" # MeshCore One: an eight-character hash
MCO = "mco" # meshcore-open v3: a 16-bit hash, and an index into its table of emoji
MCO_V1 = "mco1" # meshcore-open v1 (before January 2026): the time and two 30-bit hashes
@dataclass(frozen=True, slots=True)
class Reaction:
"""A reaction, as read from the body of a message.
Attributes:
emoji: The emoji of the reaction.
form: :data:`MC1`, :data:`MCO`, or :data:`MCO_V1`.
hash: How the reaction names its target. For :data:`MC1`, the eight-character hash
(refer to :func:`message_hash`). For :data:`MCO`, four lowercase hex digits.
For :data:`MCO_V1`, the message id of meshcore-open.
author: For a channel reaction of :data:`MC1`, the name of the author of the
target. ``None`` in all other cases.
timestamp: For :data:`MCO_V1`, the timestamp of the target, in seconds.
author_hash: For :data:`MCO_V1`, the Dart hash of the name of the author.
body_hash: For :data:`MCO_V1`, the Dart hash of the body of the target.
"""
emoji: str
form: str
hash: str
author: str | None = None
timestamp: int | None = None
author_hash: int | None = None
body_hash: int | None = None
def parse(body: str, *, channel: bool) -> Reaction | None:
"""Read a message body as a reaction.
Args:
body: The body of the message. On a channel, this is the text after the
``Name: `` prefix of the sender.
channel: Whether the message is on a channel. A channel reaction of MeshCore One
names the author of its target, and a direct one does not.
Returns:
The reaction, or ``None`` when the body is a usual message.
"""
if body.startswith("r:"):
return _parse_mco(body) or _parse_mco_v1(body)
head, newline, tail = body.rpartition("\n")
if not newline:
return None
digest = _normal_hash(tail)
if digest is None:
return None
if not channel:
if "@[" in head or not is_reaction_emoji(head):
return None
return Reaction(head, MC1, digest)
before, mark, after = head.partition("@[")
if not mark:
return None
if before:
# The order before MeshCore One 1.4.1: the emoji, then the mention.
if not after.endswith("]"):
return None
emoji, author = before, after[:-1]
else:
author, close, emoji = after.partition("]")
if not close:
return None
if not author or not is_reaction_emoji(emoji):
return None
return Reaction(emoji, MC1, digest, author=author)
def channel_text(emoji: str, author: str, body: str, timestamp: int) -> str:
"""The body of a channel reaction to the message ``body`` of ``author``.
Args:
emoji: The emoji of the reaction.
author: The name of the author of the target, as the target carried it.
body: The body of the target, without the ``Name: `` prefix.
timestamp: The timestamp that the author gave the target.
"""
return f"@[{author}]{emoji}\n{message_hash(body, timestamp)}"
def direct_text(emoji: str, body: str, timestamp: int) -> str:
"""The body of a direct reaction to the message ``body`` sent at ``timestamp``."""
return f"{emoji}\n{message_hash(body, timestamp)}"
# -- meshcore-open ------------------------------------------------------------------------
#: The table of emoji of meshcore-open (its ``emoji_picker.dart``), in order: the quick set,
#: then the smileys, gestures, hearts, and objects. A reaction of v3 sends an index into it.
# fmt: off
MCO_EMOJI: tuple[str, ...] = (
"👍", "❤️", "😂", "🎉", "👏", "🔥",
"😀", "😃", "😄", "😁", "😅", "😂", "🤣", "😊",
"😇", "🙂", "🙃", "😉", "😌", "😍", "🥰", "😘",
"😗", "😙", "😚", "😋", "😛", "😝", "😜", "🤪",
"🤨", "🧐", "🤓", "😎", "🥸", "🤩", "🥳", "😏",
"😒", "😞", "😔", "😟", "😕", "🙁", "😣", "😖",
"😫", "😩", "🥺", "😢", "😭", "😤", "😠", "😡",
"🤬", "🤯", "😳", "🥵", "🥶", "😱", "😨", "😰",
"😥", "😓", "🤗", "🤔", "🤭", "🤫", "🤥", "😶",
"👍", "👎", "👊", "✊", "🤛", "🤜", "🤞", "✌️",
"🤟", "🤘", "👌", "🤌", "🤏", "👈", "👉", "👆",
"👇", "☝️", "👋", "🤚", "🖐️", "✋", "🖖", "👏",
"🙌", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳",
"💪",
"❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍",
"🤎", "💔", "❤️‍🔥", "❤️‍🩹", "💕", "💞", "💓", "💗",
"💖", "💘", "💝", "💟", "💌", "💢", "💥", "💫",
"💦", "💨", "🕳️", "💬", "👁️‍🗨️", "🗨️", "🗯️", "💭",
"🎉", "🎊", "🎈", "🎁", "🎀", "🪅", "🪆", "🏆",
"🥇", "🥈", "🥉", "⚽", "⚾", "🥎", "🏀", "🏐",
"🏈", "🏉", "🎾", "🥏", "🎳", "🏏", "🏑", "🏒",
"🥍", "🏓", "🏸", "🥊", "🥋", "🥅", "⛳", "🔥",
"⭐", "🌟", "✨", "⚡", "💡", "🔦", "🏮", "🪔",
"📱", "💻", "⌚", "📷", "📺", "📻", "🎵", "🎶",
"🚀",
)
# fmt: on
_HEX = frozenset("0123456789abcdef")
def _parse_mco(body: str) -> Reaction | None:
"""Read ``r:{hash}:{index}``, a reaction of meshcore-open v3 (four and two hex digits)."""
if len(body) != 9 or body[6] != ":":
return None
digest, index = body[2:6], body[7:]
if not set(digest + index) <= _HEX:
return None
position = int(index, 16)
if position >= len(MCO_EMOJI):
return None
return Reaction(MCO_EMOJI[position], MCO, digest)
def _parse_mco_v1(body: str) -> Reaction | None:
"""Read ``r:{millis}_{author hash}_{body hash}:{emoji}``, a reaction of meshcore-open v1."""
ident, colon, emoji = body[2:].rpartition(":")
if not colon or not ident or not emoji:
return None
parts = ident.split("_")
if len(parts) != 3 or not all(part.isdigit() for part in parts):
return None
seconds = int(parts[0]) // 1000
if seconds > 0xFFFFFFFF:
return None
return Reaction(
emoji,
MCO_V1,
ident,
timestamp=seconds,
author_hash=int(parts[1]),
body_hash=int(parts[2]),
)
def dart_hash(units: list[int]) -> int:
"""The ``String.hashCode`` of the Dart VM over UTF-16 code units: a 30-bit value, never 0."""
mask = 0xFFFFFFFF
value = 0
for unit in units:
value = (value + unit) & mask
value = (value + (value << 10)) & mask
value ^= value >> 6
value = (value + (value << 3)) & mask
value ^= value >> 11
value = (value + (value << 15)) & mask
value &= (1 << 30) - 1
return value or 1
def _units(text: str) -> list[int]:
"""The UTF-16 code units of ``text``."""
raw = text.encode("utf-16-le")
return [int.from_bytes(raw[i : i + 2], "little") for i in range(0, len(raw), 2)]
def mco_hash(timestamp: int, author: str | None, body: str) -> str:
"""The 16-bit hash with which meshcore-open v3 names a target, as four hex digits.
It is the Dart hash of the timestamp in decimal, the name of the author (on a channel
only), and the first five UTF-16 code units of the body.
"""
units = _units(str(timestamp)) + (_units(author) if author else []) + _units(body)[:5]
return f"{dart_hash(units) & 0xFFFF:04x}"
# -- the fold -----------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class Reactor:
"""Who sent a reaction: our node, or another node by the name that it gave.
Attributes:
name: The name of the node. ``None`` for our node, and for the peer of a direct
exchange (the conversation names the peer).
ours: Whether our node sent the reaction.
"""
name: str | None
ours: bool = False
@dataclass(slots=True)
class Badge:
"""One emoji on one message, and the nodes that reacted with it, the first first."""
emoji: str
reactors: list[Reactor] = field(default_factory=list)
@property
def ours(self) -> bool:
"""Whether our node is one of the nodes that reacted with this emoji."""
return any(reactor.ours for reactor in self.reactors)
@dataclass(slots=True)
class _Target:
"""A message of the transcript, as the fold matches reactions against it."""
message: ChatMessage
author: str | None
body: str
timestamp: int
class ReactionFold:
"""Fold the reactions of one conversation onto the messages that they react to.
Give each message of the transcript to :meth:`take`, in the order of arrival. A message
that is not a reaction becomes a target, and :meth:`take` returns ``False``. A reaction
attaches to its target, and :meth:`take` returns ``True``. Then the transcript does not
show it as a message. A reaction whose target is not known yet waits (at most
:data:`PENDING_CAP` of them), because on a mesh a reaction can arrive before its target.
:meth:`badges` gives the reactions on one message.
The fold counts each emoji one time for each node. A node can send the same reaction
again, and a repeater can deliver a copy two times.
"""
def __init__(self, *, channel: bool, self_name: str | None = None) -> None:
"""Make an empty fold.
Args:
channel: Whether the conversation is a channel. A channel reaction names the
author of its target, and a direct one does not.
self_name: The name of our node. On a channel, it is the author of each message
that we sent. Without it, the reactions to our channel messages of
MeshCore One cannot find their target.
"""
self._channel = channel
self._self_name = self_name
self._targets: list[_Target] = []
self._by_id: dict[int, _Target] = {}
self._by_hash: dict[tuple[str | None, str], _Target] = {}
self._badges: dict[int, list[Badge]] = {}
self._pending: list[tuple[Reaction, Reactor]] = []
def take(self, message: ChatMessage) -> bool:
"""Fold one message in, and return whether it is a reaction.
Args:
message: The next message of the transcript.
Returns:
``True`` when the message is a reaction. The transcript then does not show it.
``False`` when it is a usual message, which other reactions can now name.
"""
author, body = self._author_and_body(message)
reaction = parse(body, channel=self._channel)
if reaction is None:
self._index(message, author, body)
return False
reactor = Reactor(
None if message.outbound else (author if self._channel else None), ours=message.outbound
)
target = self._find(reaction, wire_timestamp(message.created_at))
if target is not None:
self._attach(target.message, reaction.emoji, reactor)
elif reaction.form == MC1:
self._pending.append((reaction, reactor))
del self._pending[:-PENDING_CAP]
return True
def badges(self, message: ChatMessage) -> list[Badge]:
"""The reactions on ``message``: the most frequent emoji first, then the earliest."""
badges = self._badges.get(id(message), [])
order = {id(badge): index for index, badge in enumerate(badges)}
return sorted(badges, key=lambda badge: (-len(badge.reactors), order[id(badge)]))
def count(self, message: ChatMessage) -> int:
"""The number of reactions on ``message``. A render cache compares it to see a change."""
return sum(len(badge.reactors) for badge in self._badges.get(id(message), []))
def reacted(self, message: ChatMessage, emoji: str) -> bool:
"""Whether our node already reacted to ``message`` with ``emoji``."""
return any(
badge.emoji == emoji and badge.ours for badge in self._badges.get(id(message), [])
)
def target_of(self, message: ChatMessage) -> tuple[str | None, str, int] | None:
"""The author, the body, and the timestamp with which a reaction names ``message``.
``None`` when ``message`` is not a target of this fold (a reaction, or a message
that the fold never took).
"""
target = self._by_id.get(id(message))
if target is None or target.message is not message:
return None
return target.author, target.body, target.timestamp
def _author_and_body(self, message: ChatMessage) -> tuple[str | None, str]:
"""The author and the body of a message, as the hash of a reaction sees them.
On a channel, the body of a received message is the text after the ``Name: ``
prefix, without the spaces at its two ends. Our channel messages have no prefix in
the history, and their author is our node.
"""
if not self._channel:
return None, message.text
if message.outbound:
return self._self_name, message.text.strip(" \t")
name, body = split_channel_sender(message.text)
return name, body.strip(" \t")
def _index(self, message: ChatMessage, author: str | None, body: str) -> None:
"""Make ``message`` a target, and attach the reactions that waited for it."""
target = _Target(message, author, body, wire_timestamp(message.created_at))
self._targets.append(target)
self._by_id[id(message)] = target
if self._channel and not author:
return # a channel reaction of MeshCore One must name the author
key = (author if self._channel else None, message_hash(body, target.timestamp))
self._by_hash[key] = target
waiting = [entry for entry in self._pending if (entry[0].author, entry[0].hash) == key]
if waiting:
self._pending = [entry for entry in self._pending if entry not in waiting]
for reaction, reactor in waiting:
self._attach(message, reaction.emoji, reactor)
def _find(self, reaction: Reaction, at: int) -> _Target | None:
"""The target that ``reaction`` names, or ``None``. The newest target wins a tie."""
if reaction.form == MC1:
return self._by_hash.get((reaction.author, reaction.hash))
if reaction.form == MCO:
anchor = at
else:
anchor = reaction.timestamp or 0
for target in reversed(self._targets):
if abs(target.timestamp - anchor) > WINDOW_S:
continue
if reaction.form == MCO:
author = target.author if self._channel else None
if mco_hash(target.timestamp, author, target.body) == reaction.hash:
return target
elif dart_hash(_units(target.body)) == reaction.body_hash:
if self._channel and target.author is not None:
if dart_hash(_units(target.author)) != reaction.author_hash:
continue
return target
return None
def _attach(self, message: ChatMessage, emoji: str, reactor: Reactor) -> None:
"""Add one reaction to ``message``, if that node did not already send that emoji."""
badges = self._badges.setdefault(id(message), [])
badge = next((b for b in badges if b.emoji == emoji), None)
if badge is None:
badge = Badge(emoji)
badges.append(badge)
if reactor not in badge.reactors:
badge.reactors.append(reactor)
+70 -12
View File
@@ -19,11 +19,14 @@ MeshTerm always stores them while a device listens.
from __future__ import annotations
import asyncio
import time
from collections import OrderedDict
from collections.abc import Callable
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from ..core.channels import channel_identity
from ..core import reactions
from ..core.channels import channel_identity, split_channel_sender
from ..core.connection import Unsubscribe
from ..core.events import EventKind, MeshEvent
from ..core.models import (
@@ -49,6 +52,29 @@ _AWAITING_CAP = 16
#: Called with a message when its ack arrives after the send stopped its wait for the ack.
LateAck = Callable[[ChatMessage], None]
#: The highest try number that a direct message can carry. The protocol keeps it in two bits.
_LAST_ATTEMPT = 3
def _stamp() -> tuple[int, datetime]:
"""The timestamp for a message that MeshTerm is about to send, and the same instant as a date.
MeshTerm gives the timestamp to the device, and does not let the library read the clock
a second time. Thus the stored time of the message is exactly the time that the
recipients see. A reaction names its target by that time (refer to
:mod:`~meshterm.core.reactions`), so the two must agree to the second.
"""
seconds = int(time.time())
return seconds, datetime.fromtimestamp(seconds, tz=timezone.utc)
def _is_reaction(message: Message) -> bool:
"""Whether an inbound message is a reaction. A room post never is one (rooms have none)."""
if message.is_post:
return False
body = split_channel_sender(message.text)[1] if message.is_channel else message.text
return reactions.parse(body.strip(" \t"), channel=message.is_channel) is not None
def _fallback_channel_id(idx: int | None) -> str:
"""The identity of a channel that MeshTerm cannot read.
@@ -103,6 +129,9 @@ class ChatService:
# that wants to know when its ack arrives (refer to :meth:`_settle_ack`).
self._awaiting: OrderedDict[str, tuple[ChatMessage, LateAck | None]] = OrderedDict()
self._ack_unsubscribe: Unsubscribe | None = None
# The next try number of each direct message that was sent (by its row id). A
# retry with ^R sends the same message again: the same timestamp, the next number.
self._next_attempt: dict[int, int] = {}
@property
def active(self) -> bool:
@@ -408,6 +437,9 @@ class ChatService:
that comes from the slot.
* A muted channel (refer to :class:`~meshterm.core.mute_store.MuteStore`). This is
the one suppression that the user asks for, not one that the device implies.
* A reaction (refer to :mod:`~meshterm.core.reactions`). The transcript shows it on
the message that it reacts to, not as a new message. Thus a badge for it would
point at nothing new.
The method asks the device through the session cache. Thus this rule costs nothing
for each message, more than the channel read that the identity already made.
@@ -422,6 +454,8 @@ class ChatService:
Returns:
``True`` to increase the unread count of the conversation.
"""
if _is_reaction(message):
return False
if message.is_channel:
store = getattr(self._ctx, "mute_store", None)
if store is not None and store.is_muted(channel_id):
@@ -481,7 +515,9 @@ class ChatService:
return is_direct_messageable(contact.node_type)
return False
async def _deliver_direct(self, contact: Contact, text: str) -> tuple[bool, list[str]]:
async def _deliver_direct(
self, contact: Contact, text: str, *, timestamp: int, first_attempt: int = 0
) -> tuple[bool, list[str], int]:
"""Transmit a direct message, and try again softly until it is acknowledged.
One logical send makes one first transmission, plus up to
@@ -496,13 +532,22 @@ class ChatService:
stores the message as unacknowledged. Then Ctrl-R in the chat screen is the retry
that the user starts, in addition to these automatic retries.
Each try carries the same timestamp and the next try number, as the protocol
specifies. Thus the recipient sees one message with one timestamp, whichever try
arrives. A reaction to it names that timestamp. The try number makes each packet
different, so a repeater does not discard a retry as a copy of the first packet. The
number stops at 3 (the protocol has two bits for it).
Args:
contact: The recipient.
text: The message body.
timestamp: The timestamp of the message, the same for each try.
first_attempt: The try number of the first transmission.
Returns:
Whether an attempt was acknowledged in its wait, and the ack code of each
attempt. A late ack can still carry any of these codes (the device keeps them).
Whether an attempt was acknowledged in its wait, the ack code of each attempt (a
late ack can still carry any of these codes, because the device keeps them),
and the try number for the next retry.
Raises:
Exception: A hard send failure (the companion rejects the send) goes to the
@@ -512,12 +557,16 @@ class ChatService:
device = await self._ctx.device()
attempts = max(0, self._ctx.preferences.direct_message_soft_retries) + 1
codes: list[str] = []
number = first_attempt
for attempt in range(1, attempts + 1):
delivery: Delivery = await device.send_direct_message(contact, text)
delivery: Delivery = await device.send_direct_message(
contact, text, timestamp=timestamp, attempt=min(number, _LAST_ATTEMPT)
)
number += 1
if delivery.code:
codes.append(delivery.code)
if delivery.acked:
return True, codes
return True, codes, number
if attempt < attempts:
self._ctx.log.debug(
"chat: DM to %s unacked on try %d/%d; soft-retrying",
@@ -525,7 +574,7 @@ class ChatService:
attempt,
attempts,
)
return False, codes
return False, codes, number
async def send_direct(
self, contact: Contact, text: str, *, on_late_ack: LateAck | None = None
@@ -549,7 +598,8 @@ class ChatService:
The stored outbound :class:`ChatMessage`. Its ``acked`` shows whether a
delivery acknowledgement arrived in the wait, until now.
"""
acked, codes = await self._deliver_direct(contact, text)
seconds, sent_at = _stamp()
acked, codes, number = await self._deliver_direct(contact, text, timestamp=seconds)
chat = ChatMessage(
text=text,
outbound=True,
@@ -557,10 +607,11 @@ class ChatService:
peer=contact.key_prefix or contact.public_key[:12] or None,
peer_name=contact.name,
acked=acked,
created_at=utcnow(),
created_at=sent_at,
)
chat.row_id = self._ctx.repo.record_chat_message(chat, run_id=self._run_id)
if not acked:
self._next_attempt[chat.row_id] = number
self._await_ack(chat, codes, on_late_ack)
return chat
@@ -583,7 +634,13 @@ class ChatService:
Returns:
The same ``message``, with :attr:`~ChatMessage.acked` updated.
"""
acked, codes = await self._deliver_direct(contact, message.text)
seconds = int(message.created_at.timestamp())
first = self._next_attempt.get(message.row_id, 1) if message.row_id is not None else 1
acked, codes, number = await self._deliver_direct(
contact, message.text, timestamp=seconds, first_attempt=first
)
if message.row_id is not None:
self._next_attempt[message.row_id] = number
if message.acked:
return message # a late ack for an earlier attempt settled it during this send
message.acked = acked
@@ -756,7 +813,8 @@ class ChatService:
device = await self._ctx.device()
channel_id = await self.channel_id_for(index)
asked = scope if scope is not None else self.channel_scope(channel_id)
await device.send_channel_in_scope(index, text, asked)
seconds, sent_at = _stamp()
await device.send_channel_in_scope(index, text, asked, timestamp=seconds)
chat = ChatMessage(
text=text,
outbound=True,
@@ -764,7 +822,7 @@ class ChatService:
channel_id=channel_id,
channel_idx=index,
peer_name=label,
created_at=utcnow(),
created_at=sent_at,
scope=await self._sent_under(asked),
)
chat.row_id = self._ctx.repo.record_chat_message(chat, run_id=self._run_id)
+25 -3
View File
@@ -27,7 +27,7 @@ from rich.cells import cell_len
from rich.text import Text
from ..context import AppContext
from ..core import exitcodes
from ..core import exitcodes, reactions
from ..core.channels import (
CHANNEL_SLOT_PROBE_CAP,
DEFAULT_PUBLIC_SECRET,
@@ -60,7 +60,7 @@ from ..ui.fields import ChannelRef, NodeRef
from ..ui.menus import Lane, column_header, fit_cells, section_heading
from ..ui.pathline import SELF_GLYPH, PathHop
from ..ui.termfont import powerline_enabled
from ..ui.theme import name_style
from ..ui.theme import emoticon, name_style
from ..ui.tui import Choice, DeleteRequest, Separator
from ..ui.widgets import (
DESTINATION,
@@ -1047,7 +1047,7 @@ def _preview_text(
was cut before to approximately the visible lane has nothing more to show. The list
cuts it at the right edge, the same as any other row, and the scroll goes past that.
"""
body_raw = last.text.replace("\n", " ")
body_raw = _reaction_line(last) or last.text.replace("\n", " ")
text = Text()
if last.outbound:
_append_author(text, PathHop(SELF_GLYPH, you=True), Text("you: ", style="accent"))
@@ -1071,6 +1071,28 @@ def _preview_text(
return text
def _reaction_line(last: ChatMessage) -> str | None:
"""The preview body of a reaction, ``reacted 👍``, or ``None`` for a usual message.
A reaction is a message on the wire, and it can be the latest message of a
conversation. Its raw body (a mention, a newline, and a hash) means nothing to a person.
The name of the node keeps its place in front of the body, as for each message.
"""
if last.author is not None:
return None # a room post: rooms have no reactions
body = (
split_channel_sender(last.text)[1] if last.is_channel and not last.outbound else last.text
)
reaction = reactions.parse(body.strip(" \t"), channel=last.is_channel)
if reaction is None:
return None
line = f"reacted {emoticon(reaction.emoji)}"
if last.is_channel and not last.outbound:
name = split_channel_sender(last.text)[0]
return f"{name}: {line}" if name else line
return line
def _append_author(text: Text, hop: PathHop, byline: Text) -> None:
"""Append the author of a preview: an origin chip where chips are drawn, else ``byline``.
+334 -136
View File
@@ -18,14 +18,16 @@ from __future__ import annotations
import asyncio
import re
import unicodedata
from collections.abc import Awaitable, Callable
from functools import lru_cache
from typing import TYPE_CHECKING, Any
from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.text import Text
from ..core import links, parts
from ..core import links, reactions
from ..core.channels import MENTION, split_channel_sender
from ..core.connection import ContactNotOnDeviceError
from ..core.events import EventKind, MeshEvent
@@ -34,7 +36,7 @@ from ..core.regions import WILDCARD
from ..platforms import Platform, on_platform
from .pathline import PATH_INK
from .qr import QrScreen, qr_strip
from .theme import snr_style
from .theme import emoticon, name_style, snr_style
from .tui.prompt import (
CHANNEL_BYTE_LIMIT,
DM_BYTE_LIMIT,
@@ -74,12 +76,18 @@ _URL_GUTTER = 2
#: only on the PicoCalc). Bound for each platform, never asked at each frame.
_URL_CODES = False
#: The mark in front of the count of a reaction. Where the font has no emoji, an emoticon
#: such as ``+1`` or ``<3`` stands in for the emoji, and a bare count after it reads as part
#: of it (``+1 3``). There, the count is ``x3``. Bound for each platform.
_COUNT_MARK = ""
@on_platform
def _bind(platform: Platform) -> None:
"""Hang codes under URLs where the platform draws them (now and at each switch)."""
global _URL_CODES
"""Bind the choices of the transcript that depend on the platform (now and at each switch)."""
global _URL_CODES, _COUNT_MARK
_URL_CODES = platform.url_codes
_COUNT_MARK = "" if platform.emoji else "x"
#: The maximum width of a footer hint, in cells (refer to the UX standards).
@@ -113,9 +121,15 @@ class ChatScreen(Screen):
of the selected message. A path belongs to one message, so when no message is
selected, there is nothing to show. ^U shows the links of the selected message as QR
codes, on the share screen. ^L opens the map on the node of the author of the selected
message, when that node has a location. When no message is selected, Esc leaves the
message, when that node has a location. ^E reacts to the selected message with an emoji
(refer to :mod:`~meshterm.core.reactions`). When no message is selected, Esc leaves the
chat.
A reaction is a message on the wire, but the transcript does not show it as one. It shows
each reaction under the message that it reacts to: the emoji and a count, or the names of
the nodes that reacted while that message is selected. A room has no reactions, so a
room shows each message as it arrived.
A channel with a send scope shows its region in the title (``#ops · yul``). There, ^R
sends a message again *unscoped*: the selected message, or the newest when no message is
selected. This is the solution for a scoped message that no repeater in range relays.
@@ -148,6 +162,11 @@ class ChatScreen(Screen):
same size, so there the chip only shows one code alone. ``Map`` is lit while the
author of the selection has a location (refer to :meth:`_locate_target`).
``React`` (^E) is on the Shift half of F2, behind ``URL→QR``. A channel and a DM have
it, and a room does not. Thus it goes on the Shift bank, the bank for a verb that not
all three screens share. A room puts ``Log in`` there instead. ``React`` is lit while
the selection is a message that a reaction can name (refer to :meth:`_can_react`).
``Author`` (^O, the node page of the author of the selection) is the fourth shared
verb, and the plain bank has only three slots. Thus it is on the Shift half of F3,
behind ``Map``, the other verb about the author. It is lit while the author has a
@@ -193,7 +212,14 @@ class ChatScreen(Screen):
enabled=self._selected is not None and self._paths is not None,
opp_enabled=self._retry_target() is not None,
)
lane[1] = FPair("URL→QR", "url_code", enabled=bool(self._picked_urls()))
react = ("React", "react") if self._fold is not None else ("", "")
lane[1] = FPair(
"URL→QR",
"url_code",
*react,
enabled=bool(self._picked_urls()),
opp_enabled=self._can_react(),
)
if self._locator is not None or self._pages is not None:
where = ("Map", "locate") if self._locator is not None else ("", "")
who = ("Author", "author_page") if self._pages is not None else ("", "")
@@ -222,8 +248,8 @@ class ChatScreen(Screen):
resend_unscoped: Callable[[ChatMessage], Awaitable[ChatMessage | None]] | None = None,
locator: Locator | None = None,
pages: ContactPages | None = None,
max_parts: int = 1,
part_gap_s: float = 0.0,
self_name: str | None = None,
reaction_set: list[str] | None = None,
) -> None:
"""Build the chat screen.
@@ -261,12 +287,11 @@ class ChatScreen(Screen):
pages: The contacts, for ^O: the node page of the author of the selected
message (refer to :class:`~meshterm.ui.node_detail_screen.ContactPages`).
With ``None``, ^O does nothing.
max_parts: The largest number of parts for a message that is too long for one
packet (refer to :mod:`meshterm.core.parts`). With 1, a message that is
too long is refused.
part_gap_s: The time to wait between two parts of a channel message, so that
the parts do not go out as a burst. A direct part waits for its
acknowledgement, and does not wait this time.
self_name: The name of our node. A channel reaction names the author of its
target, and our node is the author of each message that we sent. With
``None``, our channel messages get no reactions.
reaction_set: The emoji that ^E offers, in order. ``None`` offers
:data:`~meshterm.core.reactions.DEFAULT_REACTIONS`.
"""
super().__init__()
self._scope = scope if conversation.is_channel else None
@@ -289,12 +314,18 @@ class ChatScreen(Screen):
if conversation.is_channel or contact is None
else (contact.public_key or contact.key_prefix or "")
)
self._messages = list(messages)
# The reactions of the conversation, folded onto their targets. A room has none.
self._fold = (
None
if conversation.is_room
else reactions.ReactionFold(channel=conversation.is_channel, self_name=self_name)
)
self._reaction_set = reaction_set or reactions.DEFAULT_REACTIONS.split()
self._peer_label = conversation.label
self._messages = [m for m in messages if not self._folds(m)]
self._send = send
self._resend = resend
self._resend_unscoped = resend_unscoped if conversation.is_channel else None
self._max_parts = max(1, max_parts)
self._part_gap_s = max(0.0, part_gap_s)
self._paths = paths
self._locator = locator
self._pages = pages
@@ -315,6 +346,7 @@ class ChatScreen(Screen):
self._codes_open = False # one share screen of links at a time
self._paste_open = False # one paste-confirm dialog at a time
self._resend_open = False # one resend-unscoped confirm at a time
self._react_open = False # one reaction dialog at a time
# The selection: the index of the highlighted message (or None when the compose
# line has the focus), and the body line where it rendered, so that the frame keeps
# it visible.
@@ -358,6 +390,7 @@ class ChatScreen(Screen):
code = "^U QR" if self._picked_urls() else ""
where = "^L map" if self._locate_target() is not None else ""
who = "^O author" if self._author_page() is not None else ""
react = "^E react" if self._can_react() else ""
if self._is_channel and self._retry_target() is not None:
# A selected scoped message of ours can be sent again unscoped, and the hint
# must name that key. The "(@mention)" of the reply and ^End are removed to
@@ -372,11 +405,12 @@ class ChatScreen(Screen):
code,
where,
who,
react,
"^R resend unscoped",
"↑↓ pick",
"Esc cancel",
],
spare=("↑↓ pick", "^P paths", "^O author"),
spare=("↑↓ pick", "^P paths", "^O author", "^E react"),
)
if self._replies:
# With all the keys of a selection, the long words do not fit in 72 cells.
@@ -388,6 +422,7 @@ class ChatScreen(Screen):
code,
where,
who,
react,
"↑↓ pick",
"^End/Esc cancel",
],
@@ -395,13 +430,15 @@ class ChatScreen(Screen):
)
if cell_len(hint) <= _HINT_CELLS:
return hint
# ^E goes after ↑↓ when even the short words do not fit. The keys of the
# selection that were here before ^E keep their places.
return _fit_hint(
["Enter reply", "^P paths", code, where, who, "↑↓ pick", "Esc cancel"],
spare=("↑↓ pick",),
["Enter reply", "^P paths", code, where, who, react, "↑↓ pick", "Esc cancel"],
spare=("↑↓ pick", "^E react"),
)
return _fit_hint(
["Enter paths", code, where, who, "↑↓ pick", "^End/Esc cancel"],
spare=("↑↓ pick",),
["Enter paths", code, where, who, react, "↑↓ pick", "^End/Esc cancel"],
spare=("↑↓ pick", "^E react"),
)
if self._retry_target() is not None:
if self._is_channel:
@@ -412,7 +449,14 @@ class ChatScreen(Screen):
# --- live updates --------------------------------------------------------
def append(self, message: ChatMessage) -> None:
"""Append an inbound message to the transcript and paint."""
"""Append an inbound message to the transcript and paint.
A reaction goes onto the message that it reacts to, and the transcript does not
move for it.
"""
if self._folds(message):
self._session.invalidate()
return
self._messages.append(message)
# Do not pull the viewport to the tail while the user selects a message to reply to.
if self._selected is None:
@@ -508,33 +552,22 @@ class ChatScreen(Screen):
return len(self._editor.text.encode("utf-8"))
def _overflow_at(self, limit: int) -> int | None:
"""The index of the first compose character that cannot be sent, or ``None``.
"""The index of the first compose character with bytes past ``limit``, or ``None``.
With no parts, this is the first character with bytes past ``limit``. With parts,
it is the first character that does not fit in the last part (refer to
:func:`~meshterm.core.parts.overflow_at`). The count goes by character (not by
byte), so multibyte input stays complete: an emoji or an accented letter is fully
under or fully over the line, never divided in the middle of its sequence.
The count goes by character (not by byte), so multibyte input stays complete: an
emoji or an accented letter is fully under or fully over the line, never divided
in the middle of its sequence.
"""
return parts.overflow_at(self._editor.text, limit, self._max_parts)
def _parts(self, text: str) -> list[str] | None:
"""The messages that ``text`` goes out as, or ``None`` when it has too many parts."""
return parts.split(text, self._byte_limit(), self._max_parts)
total = 0
for i, ch in enumerate(self._editor.text):
total += len(ch.encode("utf-8"))
if total > limit:
return i
return None
def _byte_counter(self, limit: int) -> Text:
"""The inline ``used/limit`` counter: the shared compose gauge (:func:`byte_counter`).
When the text goes out as more than one part, the counter starts with the number
of parts, and the gauge shows the bytes of the last part, with its part number.
Thus the gauge tells how much the user can add before one more part starts.
"""
cut = self._parts(self._editor.text.strip())
if cut is None or len(cut) < 2:
return byte_counter(self._used_bytes(), limit)
counter = Text(f"{len(cut)} parts · ", style="muted")
counter.append_text(byte_counter(len(cut[-1].encode("utf-8")), limit))
return counter
"""The inline ``used/limit`` counter: the shared compose gauge (:func:`byte_counter`)."""
return byte_counter(self._used_bytes(), limit)
def _name(self, peer: str | None) -> str | None:
"""Find the contact name for a sender key prefix (exact match, then prefix match)."""
@@ -570,7 +603,8 @@ class ChatScreen(Screen):
tick),
* it is the selected message (the selection sets its style, not the message), or
* its identity or ``acked`` is different from the cache (it was appended, it was
replaced in place when a pending bubble resolved, or it was removed).
replaced in place when a pending bubble resolved, or it was removed), or
* its number of reactions is different from the cache (a reaction arrived).
Most importantly, this works for each message. It is not a cached prefix that
stops at the first such row. Thus a message stays cheap to draw again, however far
@@ -586,11 +620,12 @@ class ChatScreen(Screen):
cached_ids = cache["ids"]
cached_acked = cache["acked"]
cached_selected = cache["selected"]
cached_reacts = cache["reacts"]
cached_boundaries = cache["boundaries"]
cached_lines = cache["lines"]
cache_count = len(cached_ids)
else:
cached_ids = cached_acked = cached_selected = cached_boundaries = ()
cached_ids = cached_acked = cached_selected = cached_reacts = cached_boundaries = ()
cached_lines = []
cache_count = 0
@@ -604,6 +639,7 @@ class ChatScreen(Screen):
ids: list[int] = []
ackeds: list[bool | None] = []
selecteds: list[bool] = []
reacts: list[int] = []
boundaries: list[int] = []
prev_group: tuple[bool, str] | None = None
prev_day = None
@@ -619,6 +655,7 @@ class ChatScreen(Screen):
new_day = day != prev_day
selected = idx == self._selected
pending = message.acked is None and message.outbound and not message.is_channel
react_count = self._fold.count(message) if self._fold is not None else 0
reusable = (
not selected
and not pending
@@ -626,6 +663,7 @@ class ChatScreen(Screen):
and not cached_selected[idx]
and cached_ids[idx] == id(message)
and cached_acked[idx] == message.acked
and cached_reacts[idx] == react_count
)
if reusable:
start = cached_boundaries[idx - 1] if idx > 0 else 0
@@ -667,6 +705,7 @@ class ChatScreen(Screen):
ids.append(id(message))
ackeds.append(message.acked)
selecteds.append(selected)
reacts.append(react_count)
boundaries.append(len(lines))
self._render_cache = {
@@ -674,6 +713,7 @@ class ChatScreen(Screen):
"ids": ids,
"acked": ackeds,
"selected": selecteds,
"reacts": reacts,
"lines": lines,
"boundaries": boundaries,
}
@@ -762,8 +802,51 @@ class ChatScreen(Screen):
urls = links.urls(body) if _URL_CODES else []
if urls:
lines += render_lines(qr_strip(urls, width, indent=prefix.cell_len), width)
badges = self._fold.badges(message) if self._fold is not None else []
if badges:
row, runs = self._badge_text(badges, selected=selected)
lines += render_hanging(
Text(" " * prefix.cell_len), row, width, indent=prefix.cell_len, whole=runs
)
return lines
def _badge_text(
self, badges: list[reactions.Badge], *, selected: bool
) -> tuple[Text, list[tuple[int, int]]]:
"""The reactions on one message, as one line under its body, and the runs to keep whole.
Each emoji has its count after it. The count is ``accent`` when our node is one of
the nodes that reacted, else ``muted``. On the selected message, the names of the
nodes take the place of the count, each in the hue of its node, and our node is the
white ``you``. Where the font has no emoji, an emoticon stands in for each default
reaction (refer to :func:`~meshterm.ui.theme.emoticon`).
"""
text = Text()
runs: list[tuple[int, int]] = []
for index, badge in enumerate(badges):
if index:
text.append(" ")
start = len(text)
text.append(emoticon(badge.emoji))
text.append(" ")
if not selected:
count = f"{_COUNT_MARK}{len(badge.reactors)}"
text.append(count, style="accent" if badge.ours else "muted")
runs.append((start, len(text)))
continue
for number, reactor in enumerate(badge.reactors):
if number:
text.append(", ", style="muted")
if reactor.ours:
text.append("you", style="you")
else:
name = reactor.name or self._peer_label
key = self._peer_key if reactor.name is None else self._sender_key(name)
text.append(name, style=name_style(name, key or None))
if not number:
runs.append((start, len(text))) # the emoji never ends a line alone
return text, runs
def _body_text(self, body: str, message: ChatMessage, *, selected: bool) -> Text:
"""Build the styled body of a message: mentions in colour, then the glyphs at the end."""
text = self._render_mentions(body, selected=selected)
@@ -893,10 +976,10 @@ class ChatScreen(Screen):
Ctrl+PgUp/PgDn (day dividers), and the viewport moves with it. Enter on a selected
message starts a reply with an ``@mention`` in a channel, and opens the delivery
paths in a direct chat. In the two types of chat, ^P opens the paths of the
selected message, ^U the QR codes of its links, and ^L the map on its author. The
three do nothing when no message is selected. ^End (or a move past the newest
message) goes back to the compose line. The first Esc clears the selection, and the
second Esc leaves the screen.
selected message, ^U the QR codes of its links, ^L the map on its author, and ^E the
reaction dialog. The four do nothing when no message is selected. ^End (or a move
past the newest message) goes back to the compose line. The first Esc clears the
selection, and the second Esc leaves the screen.
"""
if action == "enter":
if self._selected is not None:
@@ -916,6 +999,8 @@ class ChatScreen(Screen):
self._open_map()
elif action == "author_page":
self._open_author_page()
elif action == "react":
self._begin_react()
elif action == "retry":
if self._is_channel:
self._begin_resend_unscoped()
@@ -1210,22 +1295,17 @@ class ChatScreen(Screen):
def _submit(self) -> None:
"""Send the current input line as a message (scheduled from the key handler).
A line that is too long for one packet goes out as parts, each with its part number
(refer to :mod:`meshterm.core.parts`), when the ``message_parts_max`` preference
permits more than one part. A line that is too long for all the parts is refused.
The buffer stays the same (so that the user can make it shorter), and the status
shows why, the same as the red overflow that the compose bar already shows.
A line over the limit is refused. The buffer stays the same (so that the user can
make it shorter), and the status shows the overage, the same as the red overflow
that the compose bar already shows.
"""
text = self._editor.text.strip()
if not text or self._sending:
return
cut = self._parts(text)
if cut is None:
if self._max_parts < 2:
over = self._used_bytes() - self._byte_limit()
self._status = f"Too long by {over} byte{'s' if over != 1 else ''} — trim to send."
else:
self._status = f"Too long for {self._max_parts} parts — trim to send."
limit = self._byte_limit()
over = self._used_bytes() - limit
if over > 0:
self._status = f"Too long by {over} byte{'s' if over != 1 else ''} — trim to send."
self._session.invalidate()
return
self._editor = LineEditor()
@@ -1234,53 +1314,26 @@ class ChatScreen(Screen):
if self._is_channel:
self._status = "sending…"
self._session.invalidate()
self._spawn(self._send_channel(cut))
self._spawn(self._send_channel(text))
else:
# A direct chat shows an optimistic bubble. The mark at its end shows the
# delivery: a spinner now, then ✓/✗ when the ack resolves (or times out).
# acked=None ⇒ the spinner still turns.
pending = ChatMessage(text=text, outbound=True, created_at=utcnow())
self._messages.append(pending)
self._status = ""
self._session.invalidate()
self._spawn(self._send_direct(cut))
self._spawn(self._send_direct(pending))
def _sending_part(self, index: int, total: int) -> None:
"""Show which part goes out now, when a message has more than one."""
if total > 1:
self._status = f"sending part {index} of {total}…"
self._session.invalidate()
def _keep_unsent(self, cut: list[str], first: int) -> None:
"""Put the text of the parts that did not go out back in the compose line.
``first`` is the index in ``cut`` of the first part that did not go out. The text
goes back without its part numbers, so that Enter cuts it again. If the user typed
something new in the meantime, the line stays as it is, and the text is lost.
"""
if self._editor.text or first >= len(cut):
return
total = len(cut)
rest = " ".join(parts.unnumbered(cut[i], i + 1, total) for i in range(first, total))
self._editor = LineEditor(rest)
async def _send_channel(self, cut: list[str]) -> None:
"""Broadcast each part of a channel message, and append each when the device takes it.
Between two parts, the send waits ``part_gap_s``, so that the floods do not go out
as a burst. If a part fails, the parts after it do not go out, and their text goes
back in the compose line.
"""
sent = 0
async def _send_channel(self, text: str) -> None:
"""Broadcast a channel message, and append it when the companion accepts it."""
try:
for i, text in enumerate(cut):
if i:
await asyncio.sleep(self._part_gap_s)
self._sending_part(i + 1, len(cut))
message = await self._send(text)
sent += 1
if message is not None:
self._messages.append(message)
self._session.invalidate()
message = await self._send(text)
if message is not None:
self._admit(message)
self._status = ""
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
self._status = f"send failed: {exc}"
self._keep_unsent(cut, sent)
finally:
self._sending = False
self._stick = True
@@ -1317,40 +1370,25 @@ class ChatScreen(Screen):
except Exception: # noqa: BLE001 - a spinner problem must never break a send
pass
async def _send_direct(self, cut: list[str]) -> None:
"""Send each part of a direct message, and show each as an optimistic bubble.
async def _send_direct(self, pending: ChatMessage) -> None:
"""Await the delivery of the optimistic ``pending`` bubble, then put in the stored row.
Each part shows as a pending bubble while its delivery is in flight. On success,
the stored message replaces the bubble. The stored message has its resolved
``acked`` state and its row id, so that the user can retry a ✗ later in place. A
part goes out when the part before it is acknowledged or its wait ends. A part with
no acknowledgement can still have arrived, so the parts after it go out too, and ^R
retries each ✗ in turn. A hard failure (the companion refuses the send) removes the
bubble, shows the error inline, and stops the parts after it. Their text goes back
in the compose line.
On success, the stored message replaces the pending bubble. The stored message has
its resolved ``acked`` state and its row id, so that the user can retry a ✗ later
in place. A hard failure (the companion refuses the send) removes the bubble and
shows the error inline, the same as a failed send always did.
"""
sent = 0
try:
for i, text in enumerate(cut):
self._sending_part(i + 1, len(cut))
pending = ChatMessage(text=text, outbound=True, created_at=utcnow())
self._messages.append(pending)
self._session.invalidate()
try:
message = await self._spin_while(self._send(text))
except Exception:
self._discard(pending)
raise
if message is None: # nothing went out (the contact was not restored)
self._discard(pending)
self._keep_unsent(cut, sent)
break
sent += 1
self._swap(pending, message)
self._status = ""
message = await self._spin_while(self._send(pending.text))
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
self._discard(pending)
self._status = f"send failed: {exc}"
self._keep_unsent(cut, sent)
else:
if message is not None:
self._swap(pending, message)
else:
self._discard(pending)
self._status = ""
finally:
self._sending = False
self._stick = True
@@ -1450,7 +1488,7 @@ class ChatScreen(Screen):
try:
message = await self._resend_unscoped(target)
if message is not None:
self._messages.append(message)
self._admit(message)
self._status = ""
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
self._status = f"resend failed: {exc}"
@@ -1477,12 +1515,126 @@ class ChatScreen(Screen):
self._session.invalidate()
def _swap(self, old: ChatMessage, new: ChatMessage) -> None:
"""Replace an optimistic bubble with its stored message, in place."""
"""Replace an optimistic bubble with its stored message, in place.
The stored message is the one that a reaction can name, because it has the
timestamp that went out. Thus the fold takes it here, and not the bubble.
"""
if self._folds(new):
self._discard(old)
return
try:
self._messages[self._messages.index(old)] = new
except ValueError: # pragma: no cover - the bubble is always still there
self._messages.append(new)
def _folds(self, message: ChatMessage) -> bool:
"""Give ``message`` to the fold. ``True`` for a reaction, which the transcript omits."""
return self._fold is not None and self._fold.take(message)
def _admit(self, message: ChatMessage) -> None:
"""Append a message that we sent, unless it is a reaction (the fold shows that one)."""
if not self._folds(message):
self._messages.append(message)
# --- reactions -------------------------------------------------------------
def _react_target(self) -> tuple[ChatMessage, str | None, str, int] | None:
"""The selected message, and the author, body, and timestamp that a reaction names it by.
``None`` when nothing is selected, or when a reaction cannot name the selection: a
room, a message that is still in flight, or a channel message with no author (a
line with no ``Name: `` prefix, or one of ours when our name is not known).
"""
if self._fold is None or self._selected is None or not self._messages:
return None
message = self._messages[min(self._selected, len(self._messages) - 1)]
named = self._fold.target_of(message)
if named is None:
return None
author, body, timestamp = named
if self._is_channel and not author:
return None
return message, author, body, timestamp
def _can_react(self) -> bool:
"""Whether ^E can start a reaction: a selection that a reaction can name, and no send."""
return not self._sending and self._composing and self._react_target() is not None
def _begin_react(self) -> None:
"""Ask which emoji, then send the reaction to the selected message (^E).
The dialog is a question, so it floats as a box over the chat. The emoji of the
``reaction_emoji`` preference come first, then each other emoji (refer to
:func:`emoji_catalog`). Each row has the name of its emoji, so a typed word such as
``fire`` or ``heart`` filters the list to the emoji that it names. A row that our
node already sent to this message says so. A second copy of it would only count one
time, so the selection of that row sends nothing. The reaction goes out as a usual
message in the conversation. When it is stored, the fold puts it under its target
(refer to :meth:`_admit`).
"""
target = self._react_target()
if target is None or self._sending or self._react_open or self._fold is None:
return
message, author, body, timestamp = target
fold = self._fold
self._react_open = True
sender, _ = self._sender_and_body(message)
who = "your message" if message.outbound else sender
async def run() -> None:
from .menus import menu_rows, section_heading
def row(emoji: str) -> tuple[str, str, str]:
name = reactions.emoji_name(emoji)
sent = fold.reacted(message, emoji)
return emoticon(emoji), f"{name} · sent" if sent else name, emoji
mine = list(self._reaction_set)
others = [emoji for emoji in emoji_catalog() if emoji not in mine]
rows = menu_rows([row(emoji) for emoji in mine + others])
items = [
section_heading("Your reactions"),
*rows[: len(mine)],
section_heading("All emoji"),
*rows[len(mine) :],
]
try:
emoji = await self._session.select(
f"React — {who}", items, filterable=True, floating=True
)
finally:
self._react_open = False
if not emoji or self._sending:
self._session.invalidate()
return
if fold.reacted(message, emoji):
self._status = "you already reacted with that"
self._session.invalidate()
return
if self._is_channel:
text = reactions.channel_text(emoji, author or "", body, timestamp)
else:
text = reactions.direct_text(emoji, body, timestamp)
self._sending = True
self._status = "reacting…"
self._session.invalidate()
try:
sent_message = await self._send(text)
if sent_message is not None:
self._admit(sent_message)
no_ack = not self._is_channel and sent_message.acked is False
self._status = "reaction sent · no ack" if no_ack else ""
else:
self._status = ""
except Exception as exc: # noqa: BLE001 - show the error inline, keep the chat open
self._status = f"reaction failed: {exc}"
finally:
self._sending = False
self._session.invalidate()
self._spawn(run())
def _discard(self, message: ChatMessage) -> None:
"""Remove an optimistic bubble that never became a real message."""
try:
@@ -1491,6 +1643,47 @@ class ChatScreen(Screen):
pass
#: The blocks of Unicode where an emoji with the emoji presentation can be. A wide symbol
#: between them is a CJK symbol or radical, not an emoji.
_EMOJI_BLOCKS = ((0x2000, 0x2E7F), (0x1F000, 0x1FAFF))
#: The I Ching symbols, which Unicode 16 draws wide. They are symbols, not emoji: the
#: firmware and the other apps never offer them.
_NOT_EMOJI = frozenset(range(0x268A, 0x2690)) | frozenset(range(0x2630, 0x2638))
@lru_cache(maxsize=1)
def emoji_catalog() -> tuple[str, ...]:
"""Each single emoji that the reaction dialog offers, in the order of Unicode.
The standard library has no list of emoji. Thus the catalog takes two sets. The first is
each symbol in the emoji blocks that Unicode draws wide by default (``🔥``, ``⌚``). The
second is the emoji that are text by default (``❤``), which the table of Rich lists.
Each of these gets the variation selector that asks for the emoji form (``❤️``), as the
apps send it. A flag, a skin tone, and a joined sequence are not in the catalog, but a
reaction with one still shows when it arrives. The names come from the Unicode database
of the Python that runs MeshTerm, so a newer Python offers newer emoji.
"""
from rich._unicode_data import load
found: dict[int, str] = {}
for first, last in _EMOJI_BLOCKS:
for cp in range(first, last + 1):
char = chr(cp)
if cp in _NOT_EMOJI:
continue
if unicodedata.category(char) == "So" and unicodedata.east_asian_width(char) == "W":
found[cp] = char
for char in load("auto").narrow_to_wide:
if not char.isascii() and len(char) == 1:
found.setdefault(ord(char), char + "\ufe0f")
return tuple(
emoji
for _, emoji in sorted(found.items())
if not reactions._is_regional(emoji[0]) and reactions.is_reaction_emoji(emoji)
)
async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
"""Open the live chat screen for ``conversation`` and run it until the user closes it.
@@ -1572,6 +1765,11 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
from .node_detail_screen import ContactPages
paths = await _make_paths_presenter(ctx, conversation, device)
self_name: str | None = None
try:
self_name = str((await ctx.devstate.self_info()).get("name") or "") or None
except Exception: # noqa: BLE001 - with no name, our channel messages get no reactions
self_name = None
screen = ChatScreen(
conversation,
history,
@@ -1585,8 +1783,8 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
resend_unscoped=resend_unscoped,
locator=await Locator.gather(ctx),
pages=await ContactPages.gather(ctx),
max_parts=ctx.preferences.message_parts_max,
part_gap_s=ctx.preferences.trace_cooldown_s,
self_name=self_name,
reaction_set=reactions.reaction_set(ctx.preferences.reaction_emoji),
)
ctx.chat.set_active(conversation.key)
+33 -1
View File
@@ -825,6 +825,36 @@ def _glyph_identity(icon: str) -> str:
return icon
#: The emoticons that stand in for the default reactions where the font has no emoji (the
#: PicoCalc and the Cardputer). Each default reaction is two cells wide, and so is each
#: emoticon. Thus a badge keeps its width. A reaction that is not here goes through the fold
#: at the render boundary, as each other emoji does.
# fmt: off
_EMOTICONS: dict[str, str] = {
"👍": "+1", "👎": "-1", "❤️": "<3", "❤": "<3", "😂": ":D", "😮": ":O", "😢": ":(",
}
# fmt: on
def emoticon(emoji: str) -> str:
"""The rendering of a reaction on the platform: the emoji itself, or its emoticon.
Args:
emoji: The emoji of a reaction (one glyph, refer to
:func:`~meshterm.core.reactions.is_reaction_emoji`).
Returns:
The emoji on a platform that draws emoji. Else its emoticon from
:data:`_EMOTICONS`, or the emoji unchanged when it has none.
"""
return _emoticon_impl(emoji)
def _emoticon_compact(emoji: str) -> str:
"""A platform with no emoji: the emoticon of a reaction, when it has one."""
return _EMOTICONS.get(emoji, emoji)
def _glyph_compact(icon: str) -> str:
"""The PicoCalc: icons become their single-cell console-font glyph."""
return _GLYPH_MAP.get(icon, icon)
@@ -1099,6 +1129,7 @@ def snr_style(snr: float | None) -> str:
_ACTIVE_THEME: Theme = MESH_THEME
_node_impl: Callable[[str], str] = _node_style_spectrum
_glyph_impl: Callable[[str], str] = _glyph_identity
_emoticon_impl: Callable[[str], str] = _glyph_identity
_fold_impl: Callable[[str], str] = _no_fold
#: The map from a marker colour to RGB. :func:`mark_rgb` fills it, and a platform switch
@@ -1109,11 +1140,12 @@ _STYLE_RGB: dict[str, tuple[int, int, int]] = {}
@on_platform
def _bind(platform: Platform) -> None:
"""Bind the choices of the theme that depend on the platform (runs now and at each switch)."""
global _ACTIVE_THEME, _node_impl, _glyph_impl, _fold_impl, _FOLD_TABLE
global _ACTIVE_THEME, _node_impl, _glyph_impl, _emoticon_impl, _fold_impl, _FOLD_TABLE
global _FOLD_FONT, _FOLD_ALLOWED, _FOLD_QUANTIZE
_ACTIVE_THEME = MESH_THEME if platform.truecolor else MESH_THEME_16
_node_impl = _node_style_spectrum if platform.truecolor else _node_style_quantized
_glyph_impl = _glyph_identity if platform.emoji else _glyph_compact
_emoticon_impl = _glyph_identity if platform.emoji else _emoticon_compact
font = FONTS.get(platform.font)
if font is not None:
_FOLD_FONT = font
+3
View File
@@ -102,6 +102,9 @@ from .spinner import spinner_interval
#: ``IXON``/``IXOFF``, so ^Q is not XON here.
_CTRL_LETTER_CHORDS: dict[str, str] = {
"c": "quit",
# ^E reacts to the selected message with an emoji. The raw mode of prompt_toolkit has no
# line editing, so ^E is not "end of line" here (the End key moves the caret).
"e": "react",
# ^G logs in to the room that a room screen shows. In the input, ^G is not a special key:
# it is the bell only in output, and no terminal or multiplexer takes it.
"g": "login",
+7 -2
View File
@@ -1673,14 +1673,19 @@ class _ScriptedDevice:
def __init__(self, acks: list) -> None:
self._acks = list(acks)
self.sent: list[str] = []
# The timestamp and the try number of each send, in order.
self.stamps: list[tuple[int | None, int]] = []
async def connect(self) -> None: # for _StubContext.device()
return None
async def send_direct_message(self, contact: Contact, text: str):
async def send_direct_message(
self, contact: Contact, text: str, *, timestamp: int | None = None, attempt: int = 0
):
from meshterm.core.models import Delivery
self.sent.append(text)
self.stamps.append((timestamp, attempt))
ack = self._acks.pop(0) if self._acks else None
return Delivery(f"{len(self.sent):08x}", ack)
@@ -2710,7 +2715,7 @@ class _AckingMeshCore:
if sub.event_type is EventType.ACK:
sub.callback(event)
async def send_msg(self, pub, text): # noqa: ANN001, ANN201
async def send_msg(self, pub, text, timestamp=None, attempt=0): # noqa: ANN001, ANN201
from types import SimpleNamespace
if self._during:
+9 -4
View File
@@ -244,7 +244,8 @@ def test_the_app_draws_no_text_default_emoji_its_vocabulary_does_not_name() -> N
screen, and does not name in the list, fails here. Without the test, the mark would get
a blank cell next to it with no warning. The test skips prose, because MeshTerm reads
docstrings and bare strings and does not draw them. It also skips the module that defines
the list.
the list. It also skips the vocabulary of reactions, which is content that other apps
send.
"""
from rich._unicode_data import load
@@ -254,13 +255,17 @@ def test_the_app_draws_no_text_default_emoji_its_vocabulary_does_not_name() -> N
if not char.isascii() and char not in cells._SINGLE_CELLS
}
# Inputs to the substitution table of the PicoCalc, which maps them to glyphs that its
# font has. A terminal that shows emoji never draws them, so they are not marks.
fold_inputs = {"↪", "✔", "✖"}
# font has. A terminal that shows emoji never draws them, so they are not marks. The
# heart is the key of an emoticon (theme._EMOTICONS), which only a handheld uses.
fold_inputs = {"↪", "✔", "✖", "❤"}
package = pathlib.Path(ew.__file__).parents[2]
drawn: set[str] = set()
for path in package.rglob("*.py"):
if path.name == "emoji_width.py":
# The reactions module holds the emoji that other apps send (the defaults of
# MeshCore One, the table of meshcore-open). These are content, not marks of the
# app, and each one that is text-default carries its variation selector.
if path.name in ("emoji_width.py", "reactions.py"):
continue
tree = ast.parse(path.read_text(encoding="utf-8"))
prose = {
+40
View File
@@ -735,6 +735,45 @@ def _chat_urls(cols: int, rows: int) -> Screen:
return ChatScreen(conv, messages, send=None, names={}, session=_GallerySession(cols, rows))
def _chat_reactions(cols: int, rows: int) -> Screen:
"""A selected channel message with reactions: the names of the nodes take the counts' place.
Five nodes react with three emoji, one of them ours. The selection draws the widest line
that reactions make, and it wraps at the indent of the body. On the PicoCalc, emoticons
stand in for the emoji that have one.
"""
from datetime import datetime, timezone
from meshterm.core import reactions
stamp = 1_760_000_000
at = datetime.fromtimestamp(stamp, tz=timezone.utc)
def react(name: str, emoji: str) -> ChatMessage:
body = reactions.channel_text(emoji, "Alice", "bridge on 3rd is open again", stamp)
return ChatMessage(text=f"{name}: {body}", is_channel=True, created_at=at)
conv = Conversation(label="Lakeside emergency", is_channel=True, channel_idx=2)
messages = [
ChatMessage(text="Alice: bridge on 3rd is open again", is_channel=True, created_at=at),
react("Hilltop-Repeater", "👍"),
react("Bob", "👍"),
react("Carol-Mobile", "❤️"),
react("Dave", "🔥"),
ChatMessage(
text=reactions.channel_text("👍", "Alice", "bridge on 3rd is open again", stamp),
outbound=True,
is_channel=True,
created_at=at,
),
]
screen = ChatScreen(
conv, messages, send=None, names={}, session=_GallerySession(cols, rows), self_name="Me"
)
screen.handle("up")
return screen
def _chat_long_url(cols: int, rows: int) -> Screen:
"""A picked message with a link that is wider than the body lane.
@@ -1738,6 +1777,7 @@ _ENTRIES: list[_Entry] = [
_Entry("chat", _chat),
_Entry("chat_channel_scoped", _chat_channel_scoped),
_Entry("chat_urls", _chat_urls),
_Entry("chat_reactions", _chat_reactions),
_Entry("chat_long_url", _chat_long_url),
_Entry("chat_picker", _chat_picker),
_Entry("room", _room),
-249
View File
@@ -1,249 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""Tests for the parts of a long message: the cut, and how the chat screen sends them."""
from __future__ import annotations
import asyncio
import pytest
from meshterm.core import parts
from meshterm.core.models import ChatMessage, Contact, Conversation
from meshterm.core.preferences import Preferences
from meshterm.ui.chat import ChatScreen
LIMIT = 130
_TEXT = " ".join(f"word{i:03d}" for i in range(40)) # 319 bytes, words of 7 characters
def _bodies(cut: list[str]) -> list[str]:
return [parts.unnumbered(p, i, len(cut)) for i, p in enumerate(cut, 1)]
# --- the cut -----------------------------------------------------------------------------
def test_a_text_that_fits_is_one_part_with_no_part_number() -> None:
"""A text in the limit goes out as it is, with no part number."""
assert parts.split("hello", LIMIT, 3) == ["hello"]
assert parts.split("x" * LIMIT, LIMIT, 3) == ["x" * LIMIT]
def test_each_part_fits_and_ends_with_its_part_number() -> None:
"""Each part, with its part number, fits in the limit."""
cut = parts.split(_TEXT, LIMIT, 3)
assert cut is not None and len(cut) == 3
for i, part in enumerate(cut, 1):
assert len(part.encode("utf-8")) <= LIMIT
assert part.endswith(f" [{i}/3]")
def test_the_parts_cut_at_spaces_and_keep_all_the_words() -> None:
"""Each cut is at a space, and the parts together hold all the words."""
cut = parts.split(_TEXT, LIMIT, 3)
assert cut is not None
bodies = _bodies(cut)
assert " ".join(bodies) == _TEXT
for body in bodies:
assert body == body.strip()
assert all(word.startswith("word") and len(word) == 7 for word in body.split(" "))
def test_a_word_longer_than_a_part_is_cut_between_characters() -> None:
"""A word with no space to cut at is cut between two characters."""
url = "https://example.com/" + "a" * 200
cut = parts.split(url, LIMIT, 3)
assert cut is not None and len(cut) == 2
assert "".join(_bodies(cut)) == url
assert all(len(p.encode("utf-8")) <= LIMIT for p in cut)
def test_a_cut_never_divides_an_emoji_sequence_or_an_accent() -> None:
"""A cut does not divide a joined emoji, or an accent from its letter."""
family = "👨‍👩‍👧" # three emoji and two zero-width joiners: one symbol
cut = parts.split(family * 20, LIMIT, 9)
assert cut is not None
for body in _bodies(cut):
assert body == family * (len(body) // len(family))
accented = "é" * 100 # e and a combining acute accent, 3 bytes for each pair
cut = parts.split(accented, LIMIT, 9)
assert cut is not None
for body in _bodies(cut):
assert not body.startswith("́")
assert body.endswith("́")
def test_a_text_that_needs_too_many_parts_is_refused() -> None:
"""A text that needs more than the largest number of parts has no cut."""
assert parts.split("x" * 400, LIMIT, 3) is None
assert parts.split("x" * (LIMIT + 1), LIMIT, 1) is None
def test_the_part_number_grows_with_the_total() -> None:
"""The part number shows the index and the total."""
assert parts.part_number(1, 3) == " [1/3]"
assert parts.part_number(2, 9) == " [2/9]"
def test_overflow_at_finds_the_text_after_the_last_part() -> None:
"""The overflow starts where the text after the last part starts."""
assert parts.overflow_at("x" * LIMIT, LIMIT, 1) is None
assert parts.overflow_at("x" * (LIMIT + 5), LIMIT, 1) == LIMIT # the old rule
assert parts.overflow_at(_TEXT, LIMIT, 3) is None
long = "y" * 500
cut_at = parts.overflow_at(long, LIMIT, 3)
budget = LIMIT - len(parts.part_number(3, 3))
assert cut_at == 3 * budget
def test_unnumbered_removes_only_its_own_part_number() -> None:
"""Only the part number of the part itself is removed."""
assert parts.unnumbered("abc [2/3]", 2, 3) == "abc"
assert parts.unnumbered("abc [2/3]", 1, 3) == "abc [2/3]"
assert parts.unnumbered("abc", 1, 1) == "abc"
def test_the_preference_permits_three_parts_by_default() -> None:
"""The default of the preference is three parts."""
assert Preferences().message_parts_max == 3
# --- the chat screen ---------------------------------------------------------------------
class _Session:
def invalidate(self) -> None:
pass
def run_detached(self, work): # noqa: ANN001, ANN201
return asyncio.ensure_future(work)
def _screen(send, *, channel: bool, max_parts: int = 3, gap: float = 0.0) -> ChatScreen:
if channel:
conv = Conversation(label="#public", is_channel=True, channel_idx=0)
else:
conv = Conversation(
label="Alice",
is_channel=False,
contact=Contact(name="Alice", public_key="d4" + "0" * 62, key_prefix="d4e5f6a7"),
)
return ChatScreen(
conv,
[],
send=send,
names={},
session=_Session(),
max_parts=max_parts,
part_gap_s=gap,
)
async def _enter(screen: ChatScreen, text: str) -> None:
screen.handle("text", text)
screen.handle("enter")
await asyncio.gather(*list(screen._tasks))
async def test_a_channel_sends_each_part_in_order_with_the_gap(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A channel sends the parts in order, with the gap between them."""
sent: list[str] = []
waits: list[float] = []
real_sleep = asyncio.sleep
async def sleep(delay: float) -> None:
waits.append(delay)
await real_sleep(0)
async def send(text: str) -> ChatMessage:
sent.append(text)
return ChatMessage(text=text, outbound=True, is_channel=True, channel_idx=0)
screen = _screen(send, channel=True, gap=5.0)
monkeypatch.setattr("meshterm.ui.chat.asyncio.sleep", sleep)
await _enter(screen, _TEXT)
assert sent == parts.split(_TEXT, LIMIT, 3)
assert waits == [5.0, 5.0] # between the parts, not before the first
assert [m.text for m in screen._messages] == sent
assert screen._status == ""
assert not screen._sending
async def test_a_direct_message_sends_each_part_after_the_one_before() -> None:
"""A direct part goes out only after the part before it."""
sent: list[str] = []
in_flight = 0
async def send(text: str) -> ChatMessage:
nonlocal in_flight
in_flight += 1
assert in_flight == 1 # one part at a time
await asyncio.sleep(0)
sent.append(text)
in_flight -= 1
return ChatMessage(text=text, outbound=True, peer="d4e5f6a7", acked=False)
screen = _screen(send, channel=False, max_parts=4)
await _enter(screen, _TEXT + " more")
assert len(sent) == 3
assert [m.text for m in screen._messages] == sent
assert all(m.acked is False for m in screen._messages) # an unacked part did not stop the rest
async def test_a_failed_part_stops_the_rest_and_keeps_their_text() -> None:
"""A part that fails stops the parts after it, and their text goes back."""
calls = 0
async def send(text: str) -> ChatMessage:
nonlocal calls
calls += 1
if calls == 2:
raise RuntimeError("radio busy")
return ChatMessage(text=text, outbound=True, peer="d4e5f6a7", acked=True)
screen = _screen(send, channel=False)
await _enter(screen, _TEXT)
cut = parts.split(_TEXT, 150, 3) # the limit of a direct message
assert cut is not None
assert calls == 2
assert len(screen._messages) == 1 # the failed bubble is gone
assert "radio busy" in screen._status
assert screen._editor.text == " ".join(_bodies(cut)[1:]) # parts 2 and 3, unnumbered
async def test_too_many_parts_is_refused_and_the_text_stays() -> None:
"""A text with too many parts is refused, and the text stays."""
sent: list[str] = []
async def send(text: str) -> ChatMessage:
sent.append(text)
return ChatMessage(text=text, outbound=True)
screen = _screen(send, channel=True, max_parts=2)
await _enter(screen, _TEXT)
assert sent == []
assert screen._editor.text == _TEXT
assert screen._status == "Too long for 2 parts — trim to send."
def test_the_gauge_counts_the_parts() -> None:
"""The gauge shows the number of parts and the bytes of the last part."""
screen = _screen(None, channel=True)
screen.handle("text", _TEXT)
gauge = screen._byte_counter(LIMIT).plain
cut = parts.split(_TEXT, LIMIT, 3)
assert cut is not None
assert gauge == f"3 parts · {len(cut[-1].encode('utf-8'))}/{LIMIT}"
assert screen._overflow_at(LIMIT) is None # nothing is marked red
def test_one_part_keeps_the_old_gauge_and_the_old_overflow() -> None:
"""With one part, the gauge and the overflow are as before."""
screen = _screen(None, channel=True, max_parts=1)
screen.handle("text", "x" * (LIMIT + 2))
assert screen._byte_counter(LIMIT).plain == f"{LIMIT + 2}/{LIMIT}"
assert screen._overflow_at(LIMIT) == LIMIT
+436
View File
@@ -0,0 +1,436 @@
# SPDX-License-Identifier: Apache-2.0
"""Emoji reactions: the wire format of MeshCore One, the fold, the send path, and the chat.
The parse cases come from the tests of MeshCore One (``ReactionParserTests.swift`` and
``MeshCoreOpenReactionParserTests.swift``). Thus MeshTerm reads the same bodies as the app
whose format it shares. The hash test calculates the expected value a second way, with the
Base32 of the standard library, so that the test does not only repeat the code.
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import logging
from datetime import datetime, timezone
from pathlib import Path
import pytest
from meshterm.core import reactions
from meshterm.core.config import Settings
from meshterm.core.models import ChatMessage, Contact, Conversation, Delivery, Message
from meshterm.core.preferences import Preferences
from meshterm.core.reactions import MC1, MCO, MCO_V1, ReactionFold, parse
from meshterm.persistence.repository import Repository
from meshterm.services.chat_service import ChatService
from meshterm.ui.chat import ChatScreen
from tests.conftest import plain
_T = 1_704_067_200 # 2024-01-01 00:00:00 UTC
def _at(seconds: int) -> datetime:
return datetime.fromtimestamp(seconds, tz=timezone.utc)
# -- the hash -----------------------------------------------------------------------------
def test_the_hash_is_crockford_base32_of_the_first_five_bytes_of_sha256() -> None:
"""SHA-256 of the UTF-8 body and the little-endian timestamp, five bytes, Crockford Base32."""
digest = hashlib.sha256(b"Hello" + _T.to_bytes(4, "little")).digest()[:5]
rfc = base64.b32encode(digest).decode() # five bytes make exactly eight characters
expected = rfc.translate(
str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", "0123456789abcdefghjkmnpqrstvwxyz")
)
assert reactions.message_hash("Hello", _T) == expected
assert reactions.message_hash("Hello", _T + 1) != expected
assert reactions.message_hash("World", _T) != expected
def test_the_texts_that_meshterm_sends_name_their_target() -> None:
"""A channel reaction names the author first. A direct one has the emoji and the hash."""
digest = reactions.message_hash("hi all", _T)
assert reactions.channel_text("👍", "Alice", "hi all", _T) == f"@[Alice]👍\n{digest}"
assert reactions.direct_text("👍", "hi all", _T) == f"👍\n{digest}"
assert parse(reactions.channel_text("👍", "Alice", "hi all", _T), channel=True) == (
reactions.Reaction("👍", MC1, digest, author="Alice")
)
# -- the parse ----------------------------------------------------------------------------
@pytest.mark.parametrize(
("body", "emoji", "author", "digest"),
[
("@[AlphaNode]👍\n7f3a9c12", "👍", "AlphaNode", "7f3a9c12"),
("❤️@[BetaNode]\ne4d8b1a0", "❤️", "BetaNode", "e4d8b1a0"), # before 1.4.1
("@[Node]👍\nOOOOOOOO", "👍", "Node", "00000000"),
("@[Node]👍\niiiiiiii", "👍", "Node", "11111111"),
("@[Node]👍\nLLLLLLLL", "👍", "Node", "11111111"),
("@[Node:Alpha]👍\na1b2c3d4", "👍", "Node:Alpha", "a1b2c3d4"),
("@[Alice]👍\nreceived", "👍", "Alice", "rece1ved"),
("@[A]👍🏽\na1b2c3d4", "👍🏽", "A", "a1b2c3d4"),
("@[A]👨‍👩‍👧\na1b2c3d4", "👨‍👩‍👧", "A", "a1b2c3d4"),
("@[A]🇨🇦\na1b2c3d4", "🇨🇦", "A", "a1b2c3d4"),
],
)
def test_a_channel_reaction_parses(body: str, emoji: str, author: str, digest: str) -> None:
"""The two orders, the Crockford substitutions, a colon in a name, and each glyph shape."""
assert parse(body, channel=True) == reactions.Reaction(emoji, MC1, digest, author=author)
@pytest.mark.parametrize(
"body",
[
"Just a normal message",
"@[Node]👍",
"👍 [Node]\na1b2c3d4",
"👍@Node\na1b2c3d4",
"@[Node]👍\nabc",
"@[Node]👍\nuuuuuuuu",
"@[]👍\na1b2c3d4",
"@[Node]\na1b2c3d4",
"@[Alice]🎉 on my way\npassword",
"🎉 on my way@[Alice]\npassword",
"A@[Node]\na1b2c3d4",
],
)
def test_a_usual_channel_message_is_not_a_reaction(body: str) -> None:
"""A body that only starts with an emoji, or is short of a part, stays a message."""
assert parse(body, channel=True) is None
def test_a_direct_reaction_has_no_author() -> None:
"""A direct reaction is the emoji and the hash. The channel shape is not one."""
assert parse("👍\nb45pc4ek", channel=False) == reactions.Reaction("👍", MC1, "b45pc4ek")
assert parse("@[Node]👍\na1b2c3d4", channel=False) is None
assert parse("🎉 on my way\npassword", channel=False) is None
assert parse("1\na1b2c3d4", channel=False) is None
def test_the_reactions_of_meshcore_open_parse() -> None:
"""The v3 form indexes a table of 184 emoji. The v1 form carries the time and two hashes."""
assert len(reactions.MCO_EMOJI) == 184
assert parse("r:a1b2:00", channel=True) == reactions.Reaction("👍", MCO, "a1b2")
assert parse("r:0000:45", channel=False).emoji == "😶" # the last smiley
assert parse("r:0000:66", channel=False).emoji == "💪" # the last gesture
assert parse("r:0000:67", channel=False).emoji == "❤️" # the first heart
assert parse("r:0000:b7", channel=False).emoji == "🚀" # the last object
for bad in ("r:a1b2:b8", "r:A1B2:00", "r:a1b2:0A", "r:a1b:00", "x:a1b2:00", ""):
assert parse(bad, channel=True) is None
old = parse("r:1704067200000_12345_67890:👍", channel=True)
assert old == reactions.Reaction(
"👍", MCO_V1, "1704067200000_12345_67890", timestamp=_T, author_hash=12345, body_hash=67890
)
def test_the_dart_hash_is_thirty_bits_and_never_zero() -> None:
"""The ``String.hashCode`` of Dart: 1 for no input, and always under 2**30."""
assert reactions.dart_hash([]) == 1
value = reactions.dart_hash([ord(c) for c in "test string"])
assert 0 < value < 1 << 30
assert reactions.dart_hash([ord(c) for c in "hello"]) != reactions.dart_hash(
[ord(c) for c in "world"]
)
def test_the_reaction_set_keeps_only_single_emoji() -> None:
"""A word that is not one emoji is ignored. With nothing left, the default set is used."""
assert reactions.reaction_set("🔥 ok 👍 🔥 👍👍") == ["🔥", "👍"]
assert reactions.reaction_set("") == reactions.DEFAULT_REACTIONS.split()
assert reactions.reaction_set("abc") == reactions.DEFAULT_REACTIONS.split()
# -- the fold -----------------------------------------------------------------------------
def _channel(text: str, seconds: int = _T, *, outbound: bool = False) -> ChatMessage:
return ChatMessage(text=text, outbound=outbound, is_channel=True, created_at=_at(seconds))
def test_a_channel_reaction_folds_onto_its_target_once_for_each_node() -> None:
"""The target stays. The reaction leaves the transcript. A copy from the same node is one."""
fold = ReactionFold(channel=True, self_name="Me")
target = _channel("Alice: hi all")
assert fold.take(target) is False
react = reactions.channel_text("👍", "Alice", "hi all", _T)
assert fold.take(_channel(f"Bob: {react}", _T + 5)) is True
assert fold.take(_channel(f"Bob: {react}", _T + 6)) is True # a relayed copy
assert fold.take(_channel(f"Carol: {react}", _T + 7)) is True
assert fold.take(_channel(reactions.channel_text("❤️", "Alice", "hi all", _T), outbound=True))
badges = fold.badges(target)
assert [(b.emoji, [r.name for r in b.reactors]) for b in badges] == [
("👍", ["Bob", "Carol"]),
("❤️", [None]),
]
assert badges[1].ours and fold.reacted(target, "❤️") and not fold.reacted(target, "👍")
assert fold.count(target) == 3
def test_a_reaction_to_our_channel_message_names_our_node() -> None:
"""Our channel messages have no prefix in the history. Our name is their author."""
fold = ReactionFold(channel=True, self_name="Me")
ours = _channel("lunch?", outbound=True)
fold.take(ours)
fold.take(_channel("Bob: " + reactions.channel_text("😂", "Me", "lunch?", _T)))
assert [b.emoji for b in fold.badges(ours)] == ["😂"]
assert fold.target_of(ours) == ("Me", "lunch?", _T)
def test_a_reaction_that_arrives_first_waits_for_its_target() -> None:
"""On a mesh, a reaction can arrive before its target. It attaches when the target comes."""
fold = ReactionFold(channel=True)
assert fold.take(_channel("Bob: " + reactions.channel_text("👍", "Alice", "late", _T)))
target = _channel("Alice: late")
assert fold.take(target) is False
assert [b.emoji for b in fold.badges(target)] == ["👍"]
def test_the_author_tells_two_identical_messages_apart() -> None:
"""Two nodes that send the same body in the same second get their own reactions."""
fold = ReactionFold(channel=True)
alice, carol = _channel("Alice: ok"), _channel("Carol: ok")
fold.take(alice)
fold.take(carol)
fold.take(_channel("Bob: " + reactions.channel_text("👍", "Carol", "ok", _T)))
assert fold.badges(alice) == [] and len(fold.badges(carol)) == 1
def test_a_direct_reaction_folds_by_hash_alone() -> None:
"""In a direct exchange, the peer is the only other node. Its reactor has no name."""
fold = ReactionFold(channel=False)
ours = ChatMessage(text="see you", outbound=True, created_at=_at(_T))
fold.take(ours)
assert fold.take(ChatMessage(text=reactions.direct_text("👍", "see you", _T), peer="d4e5"))
(badge,) = fold.badges(ours)
assert badge.emoji == "👍" and badge.reactors == [reactions.Reactor(None)]
def test_a_reaction_of_meshcore_open_finds_its_target_near_in_time() -> None:
"""The short hash of v3 is compared with the messages within the window of the reaction."""
fold = ReactionFold(channel=True)
target = _channel("Alice: hello there")
fold.take(target)
digest = reactions.mco_hash(_T, "Alice", "hello there")
assert fold.take(_channel(f"Bob: r:{digest}:05", _T + 30))
assert [b.emoji for b in fold.badges(target)] == ["🔥"]
far = _channel("Carol: hello there", _T + 10_000)
fold.take(far)
late = reactions.mco_hash(_T + 10_000, "Carol", "hello there")
assert fold.take(_channel(f"Bob: r:{late}:05", _T)) # out of the window: no target
assert fold.badges(far) == []
# -- the send path ------------------------------------------------------------------------
class _Recorder:
"""A device that records the timestamp and the try number of each send."""
def __init__(self, acks: list[bool]) -> None:
self._acks = list(acks)
self.direct: list[tuple[str, int | None, int]] = []
self.channel: list[tuple[str, int | None]] = []
async def send_direct_message(self, contact, text, *, timestamp=None, attempt=0): # noqa: ANN001, ANN201
self.direct.append((text, timestamp, attempt))
acked = self._acks.pop(0) if self._acks else False
code = f"{len(self.direct):08x}"
return Delivery(code, object() if acked else None)
async def send_channel_in_scope(self, index, text, scope, *, timestamp=None): # noqa: ANN001, ANN201
self.channel.append((text, timestamp))
class _Ctx:
def __init__(self, device: _Recorder, repo: Repository) -> None:
self._device = device
self.repo = repo
self.log = logging.getLogger("test.reactions")
self.settings = Settings()
self.preferences = Preferences()
async def device(self) -> _Recorder:
return self._device
@pytest.fixture()
def repo(tmp_path: Path) -> Repository:
"""A Repository on a scratch database. It closes when the test ends."""
r = Repository(tmp_path / "reactions.db")
yield r
r.close()
_ALICE = Contact(name="Alice", public_key="d4e5" + "0" * 60, key_prefix="d4e5f6a7")
async def test_a_sent_message_stores_the_timestamp_that_went_out(repo: Repository) -> None:
"""The stored time is the wire time to the second. A reaction to it then names it right."""
device = _Recorder([True])
chat = ChatService(_Ctx(device, repo))
sent = await chat.send_channel(0, "hello")
assert device.channel[0][1] == int(sent.created_at.timestamp())
assert sent.created_at.microsecond == 0
dm = await chat.send_direct(_ALICE, "hey")
assert device.direct[0][1] == int(dm.created_at.timestamp())
stored = repo.recent_chat_messages(is_channel=False, peer=_ALICE.key_prefix)
assert stored[-1].created_at == dm.created_at
async def test_retries_keep_the_timestamp_and_count_the_try(repo: Repository) -> None:
"""Soft retries and ^R send one message: the same timestamp, the next try number, up to 3."""
device = _Recorder([False, False, False, False, False])
ctx = _Ctx(device, repo)
ctx.preferences.direct_message_soft_retries = 1
chat = ChatService(ctx)
sent = await chat.send_direct(_ALICE, "hey")
await chat.resend_direct(_ALICE, sent)
await chat.resend_direct(_ALICE, sent)
stamps = {timestamp for _, timestamp, _ in device.direct}
assert stamps == {int(sent.created_at.timestamp())}
assert [attempt for *_, attempt in device.direct] == [0, 1, 2, 3, 3, 3]
async def test_a_reaction_does_not_raise_the_unread_badge(repo: Repository) -> None:
"""A reaction shows on its target, so a badge for it would point at nothing new."""
chat = ChatService(_Ctx(_Recorder([]), repo))
react = Message(text=reactions.direct_text("👍", "hey", _T), sender="d4e5f6a7")
assert await chat._notifies(react, None) is False
channel = Message(
text="Bob: " + reactions.channel_text("👍", "Alice", "hi", _T), channel=0, is_channel=True
)
assert await chat._notifies(channel, "abc") is False
# -- the chat screen ----------------------------------------------------------------------
class _Session:
"""A session stand-in that answers the reaction dialog with ``answer``."""
def __init__(self, answer: str | None) -> None:
self.answer = answer
self.asked: list[str] = []
self.items: list = []
self.filterable: bool | None = None
def invalidate(self) -> None:
pass
async def select(self, title, items, *, filterable=True, **_kw): # noqa: ANN001, ANN003, ANN201
self.asked.append(title)
self.items, self.filterable = items, filterable
return self.answer
def _screen(messages: list[ChatMessage], session: _Session, sent: list[str]) -> ChatScreen:
async def send(text: str) -> ChatMessage:
sent.append(text)
return ChatMessage(text=text, outbound=True, is_channel=True, created_at=_at(_T + 60))
return ChatScreen(
Conversation(label="#public", is_channel=True, channel_idx=0),
messages,
send=send,
names={},
session=session,
self_name="Me",
)
def test_the_transcript_shows_reactions_under_their_target() -> None:
"""The reaction leaves the transcript. Its emoji and count show under the target."""
react = "Bob: " + reactions.channel_text("👍", "Alice", "hi all", _T)
screen = _screen([_channel("Alice: hi all"), _channel(react, _T + 5)], _Session(None), [])
assert len(screen._messages) == 1
body = plain(screen.render_body(60))
assert "👍 1" in body
assert reactions.message_hash("hi all", _T) not in body and "@[Alice]" not in body
screen.handle("up") # the selection names the nodes instead of the count
assert "👍 Bob" in plain(screen.render_body(60))
async def test_ctrl_e_sends_the_reaction_and_folds_our_copy() -> None:
"""^E asks which emoji, sends the text of MeshCore One, and shows our reaction on the target."""
session, sent = _Session("😂"), []
target = _channel("Alice: hi all")
screen = _screen([target], session, sent)
screen.handle("up")
assert "^E react" in screen.footer_hint
screen.handle("react")
await asyncio.sleep(0)
await asyncio.gather(*screen._tasks)
assert session.asked == ["React — Alice"]
assert sent == [reactions.channel_text("😂", "Alice", "hi all", _T)]
assert screen._messages == [target]
assert screen._fold.reacted(target, "😂")
def test_a_room_shows_reaction_text_as_it_arrived() -> None:
"""A room has no reactions. Its messages stay messages, and ^E offers nothing."""
body = reactions.direct_text("👍", "x", _T)
room = Contact(name="Board", public_key="ab" * 32, key_prefix="ab" * 6, node_type=3)
screen = ChatScreen(
Conversation(label="Board", is_channel=False, contact=room),
[ChatMessage(text=body, peer="abababababab", created_at=_at(_T))],
send=None,
names={},
session=_Session(None),
)
assert len(screen._messages) == 1
screen.handle("up")
assert not screen._can_react()
def test_the_messages_list_previews_a_reaction_in_words() -> None:
"""The latest message of a conversation can be a reaction. Its preview says what it is."""
from meshterm.tools.chat import _preview_text
react = "Bob: " + reactions.channel_text("👍", "Alice", "hi", _T)
preview = _preview_text(_channel(react), lambda name: None).plain
assert "Bob" in preview and "reacted 👍" in preview and "@[" not in preview
ours = ChatMessage(text=reactions.direct_text("😂", "hi", _T), outbound=True)
assert "reacted 😂" in _preview_text(ours, lambda name: None).plain
def test_a_reaction_from_a_node_with_a_long_name_folds() -> None:
"""A node name can have 31 characters. A sender of 22 is still split off its reaction.
The two messages are from the hardware log that found the fault: the name limit was 20,
so the reaction of ``Hazelwood r0b3.Anduril`` showed as a new message.
"""
fold = ReactionFold(channel=True)
target = _channel("Hazelwood: ⚽ Union 2-1 at Chicago Fire (59')", 1_792_029_645)
fold.take(target)
react = _channel("Hazelwood r0b3.Anduril: @[Hazelwood]🔥\nmzjsjjpk", 1_792_029_654)
assert fold.take(react) is True
(badge,) = fold.badges(target)
assert badge.emoji == "🔥" and badge.reactors == [reactions.Reactor("Hazelwood r0b3.Anduril")]
async def test_the_reaction_dialog_searches_every_emoji_by_name() -> None:
"""The preferred emoji come first, then each other emoji. A typed name filters them all."""
from meshterm.ui.chat import emoji_catalog
from meshterm.ui.tui import Choice, Separator
session = _Session(None)
screen = _screen([_channel("Alice: hi all")], session, [])
screen.handle("up")
screen.handle("react")
await asyncio.sleep(0)
await asyncio.gather(*screen._tasks)
assert session.filterable is True
values = [item.value for item in session.items if isinstance(item, Choice)]
assert values[:6] == reactions.DEFAULT_REACTIONS.split()
assert "🔥" in values and values.count("👍") == 1 # a preferred emoji is listed once
assert isinstance(session.items[0], Separator) # "Your reactions", then "All emoji"
assert sum(isinstance(item, Separator) for item in session.items) == 2
fire = next(i for i in session.items if isinstance(i, Choice) and i.value == "🔥")
assert "fire" in fire.label.plain
assert len(emoji_catalog()) > 1000
assert all(reactions.is_reaction_emoji(emoji) for emoji in emoji_catalog())
+9 -7
View File
@@ -71,12 +71,14 @@ class _TracingDevice(MockDevice):
await super().set_flood_scope(region)
self.trail.append(("scope", key))
async def send_channel_message(self, index: int, text: str) -> None:
await super().send_channel_message(index, text)
async def send_channel_message(
self, index: int, text: str, *, timestamp: int | None = None
) -> None:
await super().send_channel_message(index, text, timestamp=timestamp)
self.trail.append(("channel", text, self._send_scope))
async def send_direct_message(self, contact, text): # noqa: ANN001, ANN201
ack = await super().send_direct_message(contact, text)
async def send_direct_message(self, contact, text, **sent): # noqa: ANN001, ANN003, ANN201
ack = await super().send_direct_message(contact, text, **sent)
self.trail.append(("dm", text, self._send_scope))
return ack
@@ -148,10 +150,10 @@ async def test_a_dm_cannot_interleave_into_the_window() -> None:
release = asyncio.Event()
original = device.send_channel_message
async def slow_send(index: int, text: str) -> None:
async def slow_send(index: int, text: str, **sent) -> None: # noqa: ANN003
entered.set()
await release.wait()
await original(index, text)
await original(index, text, **sent)
device.send_channel_message = slow_send # type: ignore[method-assign]
scoped = asyncio.ensure_future(device.send_channel_in_scope(0, "scoped", "lakeside"))
@@ -186,7 +188,7 @@ async def test_a_send_that_fails_still_restores_the_scope() -> None:
"""If the channel send raises an error, the session scope must not stay on its region."""
device = _TracingDevice()
async def boom(index: int, text: str) -> None:
async def boom(index: int, text: str, **sent) -> None: # noqa: ANN003
raise DeviceCommandError("rejected")
device.send_channel_message = boom # type: ignore[method-assign]