diff --git a/meshterm/persistence/repository.py b/meshterm/persistence/repository.py index b862df1..46aa4be 100644 --- a/meshterm/persistence/repository.py +++ b/meshterm/persistence/repository.py @@ -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, *, diff --git a/meshterm/tools/chat.py b/meshterm/tools/chat.py index b6fbc54..3df05e7 100644 --- a/meshterm/tools/chat.py +++ b/meshterm/tools/chat.py @@ -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 -------------------------------------------------------------------- diff --git a/meshterm/ui/surface.py b/meshterm/ui/surface.py index 8a34406..e29077d 100644 --- a/meshterm/ui/surface.py +++ b/meshterm/ui/surface.py @@ -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( diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 01088dd..3aa4b0d 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -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 diff --git a/tests/test_chat.py b/tests/test_chat.py index be093e2..3c77cc8 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -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 == []