Chat picker: companions only, and Del deletes a thread's history
The Direct section now lists only companion nodes (a contact whose type was never advertised gets the benefit of the doubt) — repeaters, rooms, and sensors are not DM targets. Pressing Del on a contacted thread offers the reserved-red Cancel/Delete confirm; on commit the peer's stored messages are removed (new Repository.delete_chat_history — channel history and other peers untouched), the unread count clears, and the re-entered picker shows the row hollow-dotted in the uncontacted tail. The Del atom rides the existing deletable-row machinery, with delete_hint now threaded through Ui/TuiUi/session select so the footer advertises it only on rows it can act on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3fe4e5b4e1
commit
7b689dfd04
5 files changed
+301
-57
No files matched your search
@@ -1533,6 +1533,26 @@ class Repository:
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def delete_chat_history(self, peer: Optional[str]) -> int:
|
||||
"""Delete every stored message of one direct conversation.
|
||||
|
||||
The chat picker's per-contact history delete: removes only that peer's direct
|
||||
messages — channel history and every other conversation stay untouched. The
|
||||
peer matches how :meth:`record_chat_message` stores it (lowercased key prefix).
|
||||
|
||||
Args:
|
||||
peer: The contact's key prefix (the direct conversation's identity).
|
||||
|
||||
Returns:
|
||||
How many messages were deleted.
|
||||
"""
|
||||
cur = self._conn.execute(
|
||||
"DELETE FROM messages WHERE is_channel = 0 AND peer = ?",
|
||||
((peer or "").lower(),),
|
||||
)
|
||||
self._conn.commit()
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
def recent_chat_messages(
|
||||
self,
|
||||
*,
|
||||
|
||||
+98
-53
@@ -33,7 +33,7 @@ from ..services.trace_runner import NameKeyResolver, make_name_key_resolver
|
||||
from ..ui.chat import _MENTION, _split_channel_sender
|
||||
from ..ui.menus import fit_cells
|
||||
from ..ui.theme import name_style
|
||||
from ..ui.tui import Choice, Separator
|
||||
from ..ui.tui import Choice, DeleteRequest, Separator
|
||||
from ..ui.widgets import _NODE_GLYPHS, channel_glyph
|
||||
from .base import Tool, ToolResult, register
|
||||
|
||||
@@ -118,68 +118,113 @@ class ChatTool(Tool):
|
||||
The chosen :class:`~meshterm.core.models.Conversation`, or ``None`` if
|
||||
cancelled.
|
||||
"""
|
||||
# Through the session cache: this picker runs on every Chat open, and its two reads —
|
||||
# the channel-slot probe and the contacts table — are the two slowest round-trips on a
|
||||
# companion. Reading them from the device each time is what made opening Chat stall for
|
||||
# seconds (the cached chat screen behind it never got the chance to help). The cache
|
||||
# holds channels until the channel editor writes a slot and refreshes contacts in the
|
||||
# background (see :class:`~meshterm.services.device_state.DeviceState`).
|
||||
channels = _channels_from_slots(await ctx.devstate.channel_slots())
|
||||
contacts = await ctx.devstate.contacts()
|
||||
# A stable snapshot orders the rows (so the list doesn't reshuffle under the cursor),
|
||||
# while a self-refreshing view feeds each row's live preview (see _LiveLasts).
|
||||
lasts = ctx.repo.last_chat_messages()
|
||||
live = _LiveLasts(ctx, seed=lasts)
|
||||
# Names in previews/mentions resolve back to keys for their hue (the app-wide
|
||||
# colour rule); a name no contact or stored advert carries stays muted.
|
||||
key_of = make_name_key_resolver(contacts, ctx.repo.node_names())
|
||||
while True:
|
||||
# Through the session cache: this picker runs on every Chat open, and its two
|
||||
# reads — the channel-slot probe and the contacts table — are the two slowest
|
||||
# round-trips on a companion. Reading them from the device each time is what made
|
||||
# opening Chat stall for seconds (the cached chat screen behind it never got the
|
||||
# chance to help). The cache holds channels until the channel editor writes a
|
||||
# slot and refreshes contacts in the background (see
|
||||
# :class:`~meshterm.services.device_state.DeviceState`).
|
||||
channels = _channels_from_slots(await ctx.devstate.channel_slots())
|
||||
contacts = await ctx.devstate.contacts()
|
||||
# Only companion nodes are listed — we don't DM repeaters, rooms, or sensors;
|
||||
# a contact whose type was never advertised gets the benefit of the doubt.
|
||||
companions = [c for c in contacts if c.node_type in (NODE_TYPE_CHAT, None)]
|
||||
# A stable snapshot orders the rows (so the list doesn't reshuffle under the
|
||||
# cursor), while a self-refreshing view feeds each row's live preview
|
||||
# (see _LiveLasts).
|
||||
lasts = ctx.repo.last_chat_messages()
|
||||
live = _LiveLasts(ctx, seed=lasts)
|
||||
# Names in previews/mentions resolve back to keys for their hue (the app-wide
|
||||
# colour rule); a name no contact or stored advert carries stays muted.
|
||||
key_of = make_name_key_resolver(contacts, ctx.repo.node_names())
|
||||
|
||||
items: list = [
|
||||
Separator(_picker_header()),
|
||||
Separator("── 📡 Channels ──", style="accent"),
|
||||
]
|
||||
for conversation in channels:
|
||||
items.append(
|
||||
Choice(title=_row_title(ctx, conversation, live, key_of), value=conversation)
|
||||
)
|
||||
|
||||
items.append(Separator("── 👤 Direct ──", style="accent"))
|
||||
if contacts:
|
||||
# List contacts by recency — those with messages first, newest exchange at the
|
||||
# top — then the never-contacted ones alphabetically (see _recency_key).
|
||||
direct = [
|
||||
Conversation(label=c.name, is_channel=False, contact=c) for c in contacts
|
||||
items: list = [
|
||||
Separator(_picker_header()),
|
||||
Separator("── 📡 Channels ──", style="accent"),
|
||||
]
|
||||
direct.sort(key=lambda conv: _recency_key(conv, lasts))
|
||||
for conversation in direct:
|
||||
for conversation in channels:
|
||||
items.append(
|
||||
Choice(
|
||||
title=_row_title(ctx, conversation, live, key_of),
|
||||
value=conversation,
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(Separator(" (no contacts yet — receive an advert first)"))
|
||||
|
||||
items.append(Separator(" "))
|
||||
items.append(Choice(title="Back", value="__back__"))
|
||||
items.append(Separator("── 👤 Direct ──", style="accent"))
|
||||
if companions:
|
||||
# List contacts by recency — those with messages first, newest exchange at
|
||||
# the top — then the never-contacted ones alphabetically (see _recency_key).
|
||||
direct = [
|
||||
Conversation(label=c.name, is_channel=False, contact=c)
|
||||
for c in companions
|
||||
]
|
||||
direct.sort(key=lambda conv: _recency_key(conv, lasts))
|
||||
for conversation in direct:
|
||||
items.append(
|
||||
Choice(
|
||||
title=_row_title(ctx, conversation, live, key_of),
|
||||
value=conversation,
|
||||
# Del offers to delete this thread's stored history —
|
||||
# only where there is history to delete.
|
||||
deletable=lasts.get(conversation.key) is not None,
|
||||
)
|
||||
)
|
||||
else:
|
||||
items.append(Separator(" (no contacts yet — receive an advert first)"))
|
||||
|
||||
default = next(
|
||||
(
|
||||
it.value
|
||||
for it in items
|
||||
if isinstance(it, Choice)
|
||||
and isinstance(it.value, Conversation)
|
||||
and it.value.key == default_key
|
||||
),
|
||||
None,
|
||||
)
|
||||
choice = await ctx.ui.select(
|
||||
"Chat — pick a conversation", items, default=default, wrap=False
|
||||
)
|
||||
if choice in (None, "__back__"):
|
||||
return None
|
||||
return choice
|
||||
items.append(Separator(" "))
|
||||
items.append(Choice(title="Back", value="__back__"))
|
||||
|
||||
default = next(
|
||||
(
|
||||
it.value
|
||||
for it in items
|
||||
if isinstance(it, Choice)
|
||||
and isinstance(it.value, Conversation)
|
||||
and it.value.key == default_key
|
||||
),
|
||||
None,
|
||||
)
|
||||
choice = await ctx.ui.select(
|
||||
"Chat — pick a conversation",
|
||||
items,
|
||||
default=default,
|
||||
wrap=False,
|
||||
delete_hint="Del delete history",
|
||||
)
|
||||
if isinstance(choice, DeleteRequest):
|
||||
conversation = choice.value
|
||||
await self._delete_history(ctx, conversation)
|
||||
# Re-enter with fresh rows: a deleted thread's dot hollows and the row
|
||||
# demotes to the uncontacted (alphabetical) tail; keep the cursor on it.
|
||||
default_key = conversation.key
|
||||
continue
|
||||
if choice in (None, "__back__"):
|
||||
return None
|
||||
return choice
|
||||
|
||||
@staticmethod
|
||||
async def _delete_history(ctx: AppContext, conversation: Conversation) -> None:
|
||||
"""Confirm and delete one direct conversation's stored history.
|
||||
|
||||
Deleting history is irreversible data loss, so the confirm wears the reserved
|
||||
red (``destructive``): Cancel on the left, the committing Delete on the right.
|
||||
On confirm the peer's messages are removed from the database and the thread's
|
||||
unread count is cleared; the contact itself (a device-side record) is untouched.
|
||||
"""
|
||||
if not await ctx.ui.dialog(
|
||||
f"Delete the chat history with {conversation.label}? Every stored "
|
||||
"message in this conversation is removed.",
|
||||
[("Cancel", False), ("Delete", True)],
|
||||
title="Delete history",
|
||||
default=1,
|
||||
destructive=True,
|
||||
):
|
||||
return
|
||||
ctx.repo.delete_chat_history(conversation.peer)
|
||||
ctx.chat.clear_unread(conversation.key)
|
||||
|
||||
# -- CLI --------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -115,12 +115,14 @@ class Ui:
|
||||
default: Any = None,
|
||||
wrap: bool = True,
|
||||
filterable: bool = True,
|
||||
delete_hint: str = "",
|
||||
) -> Any:
|
||||
"""Prompt the user to choose one item; return its value or ``None`` if cancelled.
|
||||
|
||||
``prompt`` draws an instruction inside the popup, above the list. Pass
|
||||
``filterable=False`` for a short, fixed list so a stray key can't narrow (and
|
||||
resize) it.
|
||||
resize) it. ``delete_hint`` (with rows marked deletable) enables the Delete
|
||||
key's remove flow — see :meth:`~meshterm.ui.tui.session.TuiSession.select`.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -394,6 +396,7 @@ class PlainUi(Ui):
|
||||
default: Any = None,
|
||||
wrap: bool = True,
|
||||
filterable: bool = True,
|
||||
delete_hint: str = "",
|
||||
) -> Any:
|
||||
"""Unsupported in scripted CLI mode."""
|
||||
raise self._no_prompt()
|
||||
@@ -639,10 +642,12 @@ class TuiUi(Ui):
|
||||
default: Any = None,
|
||||
wrap: bool = True,
|
||||
filterable: bool = True,
|
||||
delete_hint: str = "",
|
||||
) -> Any:
|
||||
"""Delegate to the session's select screen."""
|
||||
return await self.session.select(
|
||||
title, items, prompt=prompt, default=default, wrap=wrap, filterable=filterable
|
||||
title, items, prompt=prompt, default=default, wrap=wrap,
|
||||
filterable=filterable, delete_hint=delete_hint,
|
||||
)
|
||||
|
||||
async def select_startup(
|
||||
|
||||
@@ -362,15 +362,20 @@ class TuiSession:
|
||||
wrap: bool = True,
|
||||
filterable: bool = True,
|
||||
footer_hint: Optional[str] = None,
|
||||
delete_hint: str = "",
|
||||
) -> Any:
|
||||
"""Show a select screen; return the chosen value or ``None`` if cancelled.
|
||||
|
||||
``prompt`` draws an instruction inside the box above the list; ``filterable`` and
|
||||
``footer_hint`` are forwarded for short, fixed lists (a yes-or-no style choice) that
|
||||
want no type-to-filter and a tailored hint.
|
||||
want no type-to-filter and a tailored hint. ``delete_hint`` (with rows marked
|
||||
:attr:`~meshterm.ui.tui.select.Choice.deletable`) surfaces the Delete key's atom
|
||||
while the highlight sits on such a row; Delete then resolves a
|
||||
:class:`~meshterm.ui.tui.select.DeleteRequest` the caller unwraps.
|
||||
"""
|
||||
kwargs: dict[str, Any] = dict(
|
||||
prompt=prompt, default=default, wrap=wrap, filterable=filterable
|
||||
prompt=prompt, default=default, wrap=wrap, filterable=filterable,
|
||||
delete_hint=delete_hint,
|
||||
)
|
||||
if footer_hint is not None:
|
||||
kwargs["footer_hint"] = footer_hint
|
||||
|
||||
@@ -1303,3 +1303,172 @@ async def test_open_chat_sends_through_real_session(tmp_path: Path) -> None:
|
||||
assert stored[0].outbound is True
|
||||
finally:
|
||||
await ctx.aclose()
|
||||
|
||||
|
||||
# -- the picker's companion filter and history delete -------------------------
|
||||
|
||||
|
||||
def test_delete_chat_history_removes_only_that_peer(repo: Repository) -> None:
|
||||
"""Deleting one direct thread leaves other peers and channel history untouched."""
|
||||
repo.record_chat_message(ChatMessage(text="a", peer="aa"))
|
||||
repo.record_chat_message(ChatMessage(text="b", peer="bb"))
|
||||
repo.record_chat_message(ChatMessage(text="c", is_channel=True, channel_id="c0"))
|
||||
|
||||
assert repo.delete_chat_history("aa") == 1
|
||||
assert repo.recent_chat_messages(is_channel=False, peer="aa") == []
|
||||
assert [m.text for m in repo.recent_chat_messages(is_channel=False, peer="bb")] == ["b"]
|
||||
assert [
|
||||
m.text for m in repo.recent_chat_messages(is_channel=True, channel_id="c0")
|
||||
] == ["c"]
|
||||
|
||||
|
||||
class _PickerDevstate:
|
||||
def __init__(self, contacts) -> None:
|
||||
self._contacts = contacts
|
||||
|
||||
async def channel_slots(self):
|
||||
return []
|
||||
|
||||
async def contacts(self):
|
||||
return list(self._contacts)
|
||||
|
||||
|
||||
class _PickerChat(_FakeChat):
|
||||
def __init__(self, unread=None) -> None:
|
||||
super().__init__(unread or {})
|
||||
self.cleared: list[str] = []
|
||||
|
||||
def clear_unread(self, key: str) -> None:
|
||||
self.cleared.append(key)
|
||||
|
||||
|
||||
async def test_picker_lists_companions_only(repo: Repository) -> None:
|
||||
"""Only companion (and type-unknown) contacts appear in Direct — never repeaters."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from meshterm.core.models import NODE_TYPE_REPEATER
|
||||
from meshterm.tools.chat import ChatTool
|
||||
from meshterm.ui.tui import Choice
|
||||
|
||||
contacts = [
|
||||
Contact(name="Ally", public_key="d4" + "0" * 62, node_type=1),
|
||||
Contact(name="Mystery", public_key="60" + "0" * 62), # type never advertised
|
||||
Contact(name="Tower", public_key="a1" + "0" * 62, node_type=NODE_TYPE_REPEATER),
|
||||
]
|
||||
|
||||
class _Ui:
|
||||
def __init__(self) -> None:
|
||||
self.items = None
|
||||
|
||||
async def select(self, title, items, **kw):
|
||||
self.items = items
|
||||
return None
|
||||
|
||||
ctx = SimpleNamespace(
|
||||
devstate=_PickerDevstate(contacts), repo=repo, ui=_Ui(), chat=_PickerChat()
|
||||
)
|
||||
assert await ChatTool()._pick_conversation(ctx) is None
|
||||
direct = [
|
||||
it.value.label
|
||||
for it in ctx.ui.items
|
||||
if isinstance(it, Choice)
|
||||
and isinstance(it.value, Conversation)
|
||||
and not it.value.is_channel
|
||||
]
|
||||
assert "Ally" in direct and "Mystery" in direct and "Tower" not in direct
|
||||
|
||||
|
||||
async def test_picker_del_deletes_history_after_a_red_confirm(repo: Repository) -> None:
|
||||
"""Del on a contacted thread: red destructive confirm, history gone, unread cleared,
|
||||
and the re-entered picker's row is hollow (no longer deletable)."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from meshterm.tools.chat import ChatTool
|
||||
from meshterm.ui.tui import Choice, DeleteRequest
|
||||
|
||||
ally = Contact(name="Ally", public_key="d4" + "0" * 62, node_type=1)
|
||||
conv = Conversation(label="Ally", is_channel=False, contact=ally)
|
||||
repo.record_chat_message(ChatMessage(text="hi", peer=conv.peer))
|
||||
repo.record_chat_message(ChatMessage(text="chan", is_channel=True, channel_id="c0"))
|
||||
|
||||
class _Ui:
|
||||
def __init__(self) -> None:
|
||||
self.selects = 0
|
||||
self.dialogs: list[dict] = []
|
||||
|
||||
async def select(self, title, items, **kw):
|
||||
self.selects += 1
|
||||
assert kw.get("delete_hint") == "Del delete history"
|
||||
row = next(
|
||||
it
|
||||
for it in items
|
||||
if isinstance(it, Choice)
|
||||
and isinstance(it.value, Conversation)
|
||||
and not it.value.is_channel
|
||||
)
|
||||
if self.selects == 1:
|
||||
assert row.deletable # there is history to delete
|
||||
return DeleteRequest(row.value)
|
||||
assert not row.deletable # history gone: the row demoted to uncontacted
|
||||
return None
|
||||
|
||||
async def dialog(self, prompt, buttons, **kw):
|
||||
self.dialogs.append({"prompt": prompt, "buttons": buttons, **kw})
|
||||
return True # commit the Delete
|
||||
|
||||
chat = _PickerChat()
|
||||
ctx = SimpleNamespace(
|
||||
devstate=_PickerDevstate([ally]), repo=repo, ui=_Ui(), chat=chat
|
||||
)
|
||||
assert await ChatTool()._pick_conversation(ctx) is None
|
||||
assert ctx.ui.selects == 2 # the picker re-entered after the delete
|
||||
|
||||
confirm = ctx.ui.dialogs[0]
|
||||
assert confirm["destructive"] is True # the reserved red, data loss
|
||||
assert confirm["buttons"] == [("Cancel", False), ("Delete", True)]
|
||||
|
||||
assert repo.recent_chat_messages(is_channel=False, peer=conv.peer) == []
|
||||
assert [
|
||||
m.text for m in repo.recent_chat_messages(is_channel=True, channel_id="c0")
|
||||
] == ["chan"] # channel history survives
|
||||
assert chat.cleared == [conv.key]
|
||||
|
||||
|
||||
async def test_picker_del_cancel_keeps_the_history(repo: Repository) -> None:
|
||||
"""Cancelling the confirm leaves the thread's messages untouched."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from meshterm.tools.chat import ChatTool
|
||||
from meshterm.ui.tui import Choice, DeleteRequest
|
||||
|
||||
ally = Contact(name="Ally", public_key="d4" + "0" * 62, node_type=1)
|
||||
conv = Conversation(label="Ally", is_channel=False, contact=ally)
|
||||
repo.record_chat_message(ChatMessage(text="hi", peer=conv.peer))
|
||||
|
||||
class _Ui:
|
||||
def __init__(self) -> None:
|
||||
self.selects = 0
|
||||
|
||||
async def select(self, title, items, **kw):
|
||||
self.selects += 1
|
||||
if self.selects == 1:
|
||||
row = next(
|
||||
it
|
||||
for it in items
|
||||
if isinstance(it, Choice)
|
||||
and isinstance(it.value, Conversation)
|
||||
and not it.value.is_channel
|
||||
)
|
||||
return DeleteRequest(row.value)
|
||||
return None
|
||||
|
||||
async def dialog(self, prompt, buttons, **kw):
|
||||
return False # Cancel backs out
|
||||
|
||||
chat = _PickerChat()
|
||||
ctx = SimpleNamespace(
|
||||
devstate=_PickerDevstate([ally]), repo=repo, ui=_Ui(), chat=chat
|
||||
)
|
||||
assert await ChatTool()._pick_conversation(ctx) is None
|
||||
assert [m.text for m in repo.recent_chat_messages(is_channel=False, peer=conv.peer)] == ["hi"]
|
||||
assert chat.cleared == []
|
||||
Reference in new issue
Block a user