# Copyright 2026, Jiri Eischmann <jiri@eischmann.cz> and the meshy contributors
# SPDX-License-Identifier: GPL-3.0-or-later

"""Channels list and chat view."""

import gettext
import hashlib

ngettext = gettext.ngettext

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
gi.require_version("Shumate", "1.0")
from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk, Pango, Shumate
from meshy.models import Channel, ChannelMessage, NotificationLevel
from meshy.protocol import max_channel_text_bytes
from meshy.utils import extract_emoji, linkify, parse_shared_contact, parse_shared_location
from meshy.views import _add_hover_swap, _format_date_text
from meshy.views.chat_helpers import (
    CONTACT_TYPE_ICONS,
    ensure_loc_map,
    open_location_external,
    share_contact_dialog,
    share_location,
    share_location_map,
    show_location_map,
)
from meshy.views.chat_mixin import ChatScrollMixin

_BATCH_SIZE = 30


class ChannelChatItem(GObject.Object):
    """Model object for one row in the channel chat ListView."""

    KIND_MESSAGE = 0
    KIND_UNREAD_DIVIDER = 1
    KIND_DATE_DIVIDER = 2

    def __init__(self, kind=0, message=None, date_text=""):
        super().__init__()
        self.kind = kind
        self.message = message
        self.date_text = date_text
        self._repeat_count_val = 0
        self._resend_count_val = 0
        self._max_hops_val = -1

    @GObject.Property(type=int)
    def repeat_count(self):
        return self._repeat_count_val

    @repeat_count.setter
    def repeat_count(self, value):
        self._repeat_count_val = value

    @GObject.Property(type=int)
    def resend_count(self):
        return self._resend_count_val

    @resend_count.setter
    def resend_count(self, value):
        self._resend_count_val = value

    @GObject.Property(type=int)
    def max_hops(self):
        return self._max_hops_val

    @max_hops.setter
    def max_hops(self, value):
        self._max_hops_val = value


@Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/channel-chat-widget.ui")
class ChannelChatWidget(ChatScrollMixin, Gtk.Box):
    """Channel chat content widget - shown in the content panel."""

    __gtype_name__ = "MeshyChannelChatWidget"

    _stack = Gtk.Template.Child("stack")
    _scroll = Gtk.Template.Child("chat_scroll")
    _list_view = Gtk.Template.Child("list_view")
    _scroll_down_revealer = Gtk.Template.Child("scroll_down_revealer")
    _unread_btn_revealer = Gtk.Template.Child("unread_btn_revealer")
    _char_label = Gtk.Template.Child("char_label")
    _emoji_btn = Gtk.Template.Child("emoji_btn")
    _text_entry = Gtk.Template.Child("text_entry")
    _send_btn = Gtk.Template.Child("send_btn")

    def __init__(self, window, **kwargs):
        super().__init__(**kwargs)
        self._window = window
        self._current_channel: Channel | None = None
        # Track outgoing items for repeat detection: (channel_idx, timestamp, text) -> item
        self._outgoing_items: dict[tuple, ChannelChatItem] = {}
        # Repeat info per outgoing message key: list of {path_hops, path_len}
        self._repeats: dict[tuple, list] = {}
        # Track incoming message path variants: msg_key -> list of path_info dicts
        self._incoming_paths: dict[tuple, list] = {}
        self._seen_channel_msgs: set[tuple] = set()
        self._incoming_items: dict[tuple, ChannelChatItem] = {}
        # Map resend timestamps to original outgoing message keys
        # so echoes of resent messages can be matched back
        self._resend_ts_map: dict[tuple, tuple] = {}
        self._mention_popover = None
        self._mention_listbox = None

        # Pango attributes for char counter
        attrs = Pango.AttrList()
        attrs.insert(Pango.attr_scale_new(0.85))
        self._char_label.set_attributes(attrs)

        # ListView model and factory
        self._store = Gio.ListStore(item_type=ChannelChatItem)
        selection = Gtk.NoSelection(model=self._store)
        self._factory = Gtk.SignalListItemFactory()
        self._factory.connect("setup", self._on_factory_setup)
        self._factory.connect("bind", self._on_factory_bind)
        self._factory.connect("unbind", self._on_factory_unbind)
        self._list_view.set_model(selection)
        self._list_view.set_factory(self._factory)

        # Context menus (single popover on the ListView, menu switches per item)
        self._context_popover = Gtk.PopoverMenu()
        self._context_popover.set_parent(self._list_view)
        self._context_popover.set_has_arrow(False)
        self._context_popover.connect("closed", self._on_context_popover_closed)
        self._context_menu_open = False
        self._frozen_scroll_value = 0.0
        self._context_item = None

        self._outgoing_menu = Gio.Menu()
        self._outgoing_menu.append(_("Heard Repeats"), "msg.repeats")
        self._outgoing_menu.append(_("Send Again"), "msg.resend")
        self._outgoing_menu.append(_("Delete"), "msg.delete")

        self._incoming_menu = Gio.Menu()
        self._incoming_menu.append(_("Reply"), "msg.reply")
        self._incoming_menu.append(_("Copy Text"), "msg.copy")
        self._incoming_menu.append(_("View Message Paths"), "msg.paths")
        self._incoming_menu.append(_("Delete"), "msg.delete")

        ctx_group = Gio.SimpleActionGroup()
        for name, cb in [
            ("repeats", lambda a, p: self._show_repeats_context()),
            ("resend", lambda a, p: self._resend_context()),
            ("delete", lambda a, p: self._delete_context()),
            ("reply", lambda a, p: self._reply_context()),
            ("copy", lambda a, p: self._copy_context()),
            ("paths", lambda a, p: self._show_paths_context()),
        ]:
            action = Gio.SimpleAction.new(name, None)
            action.connect("activate", cb)
            ctx_group.add_action(action)
        self._list_view.insert_action_group("msg", ctx_group)

        # Gesture controllers
        right_click = Gtk.GestureClick(button=3)
        right_click.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
        right_click.connect("pressed", self._on_list_right_click)
        self._list_view.add_controller(right_click)
        long_press = Gtk.GestureLongPress()
        long_press.set_touch_only(True)
        long_press.set_exclusive(True)
        long_press.connect("pressed", self._on_list_long_press)
        self._list_view.add_controller(long_press)

        self._is_sticky = True
        self._scroll_positions: dict[int, float] = {}
        self._restoring_scroll = False
        self._unread_pos = -1
        self._messages_offset = 0
        self._all_loaded = False
        self._loading_more = False

        # Signal connections
        self._scroll_down_revealer.get_child().connect("clicked", self._on_scroll_to_bottom_clicked)
        self._unread_btn_revealer.get_child().connect("clicked", self._on_scroll_to_unread_clicked)
        self._emoji_btn.connect("clicked", self._on_emoji_clicked)
        self._text_entry.connect("activate", self._on_send)
        self._text_entry.connect("changed", self._on_text_changed)
        mention_key_ctrl = Gtk.EventControllerKey()
        mention_key_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
        mention_key_ctrl.connect("key-pressed", self._on_entry_key_pressed)
        self._text_entry.add_controller(mention_key_ctrl)
        self._send_btn.connect("clicked", self._on_send)

        # Extra context menu on text entry
        extra_menu = Gio.Menu()
        extra_menu.append(_("Share Contact"), "entry.share-contact")
        extra_menu.append(_("Share Location"), "entry.share-location")
        extra_menu.append(_("Share Location from Map"), "entry.share-location-map")
        self._text_entry.set_extra_menu(extra_menu)
        entry_group = Gio.SimpleActionGroup()
        share_a = Gio.SimpleAction.new("share-contact", None)
        share_a.connect("activate", self._on_share_contact)
        entry_group.add_action(share_a)
        loc_a = Gio.SimpleAction.new("share-location", None)
        loc_a.connect("activate", self._on_share_location)
        entry_group.add_action(loc_a)
        loc_map_a = Gio.SimpleAction.new("share-location-map", None)
        loc_map_a.connect("activate", self._on_share_location_map)
        entry_group.add_action(loc_map_a)
        self._text_entry.insert_action_group("entry", entry_group)

        # Scroll state tracking
        adj = self._scroll.get_vadjustment()
        adj.connect("value-changed", self._on_scroll_value_changed)
        adj.connect("notify::upper", self._on_scroll_upper_changed)
        adj.connect("notify::page-size", self._on_scroll_upper_changed)
        self._scroll.connect("edge-overshot", self._on_edge_overshot)

        self._stack.set_visible_child_name("placeholder")

    # ─── Factory callbacks ───────────────────────────────────

    def _on_factory_setup(self, factory, list_item):
        """Create the widget tree for one list item (recycled)."""
        list_item.set_activatable(False)
        list_item.set_selectable(False)
        list_item.set_focusable(False)
        outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)

        # ── Avatar ──
        avatar = Adw.Avatar(size=32, show_initials=True)
        avatar.set_valign(Gtk.Align.START)

        emoji_lbl = Gtk.Label()
        emoji_lbl.add_css_class("emoji-avatar")
        emoji_lbl.set_valign(Gtk.Align.START)

        avatar_stack = Gtk.Stack()
        avatar_stack.set_transition_type(Gtk.StackTransitionType.NONE)
        avatar_stack.add_named(avatar, "initials")
        avatar_stack.add_named(emoji_lbl, "emoji")
        avatar_stack.set_visible(False)

        # ── Message bubble ──
        msg_box = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=2,
            margin_end=12,
            margin_top=2,
            margin_bottom=2,
        )

        content_box = Gtk.Box(
            orientation=Gtk.Orientation.HORIZONTAL,
            spacing=8,
            margin_start=12,
        )
        content_box.append(avatar_stack)
        content_box.append(msg_box)

        # Sender name (incoming only)
        sender_lbl = Gtk.Label(xalign=0)
        sender_lbl.add_css_class("dim-label")
        sender_lbl.add_css_class("caption")
        sender_lbl.set_visible(False)
        msg_box.append(sender_lbl)

        # Card frame with text
        text_lbl = Gtk.Label(
            wrap=True,
            wrap_mode=Pango.WrapMode.WORD_CHAR,
            xalign=0,
            max_width_chars=40,
        )
        text_lbl.set_margin_start(12)
        text_lbl.set_margin_end(12)
        text_lbl.set_margin_top(8)
        text_lbl.set_margin_bottom(8)

        frame = Gtk.Frame()
        frame.add_css_class("card")

        content_stack = Gtk.Stack()
        content_stack.set_transition_type(Gtk.StackTransitionType.NONE)
        content_stack.set_vhomogeneous(False)
        content_stack.set_hhomogeneous(False)
        content_stack.add_named(text_lbl, "text")

        sc_box = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=6,
            margin_start=12,
            margin_end=12,
            margin_top=8,
            margin_bottom=8,
        )
        sc_header_lbl = Gtk.Label(xalign=0, wrap=True)
        sc_header_lbl.add_css_class("dim-label")
        sc_header_lbl.add_css_class("caption")
        sc_box.append(sc_header_lbl)

        sc_name_box = Gtk.Box(spacing=6)
        sc_icon = Gtk.Image.new_from_icon_name("avatar-default-symbolic")
        sc_name_lbl = Gtk.Label(xalign=0)
        attrs = Pango.AttrList()
        attrs.insert(Pango.attr_weight_new(Pango.Weight.BOLD))
        sc_name_lbl.set_attributes(attrs)
        sc_name_box.append(sc_icon)
        sc_name_box.append(sc_name_lbl)
        sc_box.append(sc_name_box)

        sc_key_lbl = Gtk.Label(xalign=0)
        sc_key_lbl.add_css_class("dim-label")
        sc_key_lbl.add_css_class("caption")
        sc_box.append(sc_key_lbl)

        sc_add_btn = Gtk.Button(label=_("Add Contact"))
        sc_add_btn.add_css_class("suggested-action")
        sc_add_btn.set_halign(Gtk.Align.START)
        sc_add_btn.set_margin_top(4)
        sc_box.append(sc_add_btn)

        content_stack.add_named(sc_box, "contact")

        # ── Shared location card ──
        loc_box = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=6,
            margin_start=12,
            margin_end=12,
            margin_top=8,
            margin_bottom=8,
        )
        loc_header_lbl = Gtk.Label(xalign=0, wrap=True)
        loc_header_lbl.add_css_class("dim-label")
        loc_header_lbl.add_css_class("caption")
        loc_box.append(loc_header_lbl)

        loc_coords_lbl = Gtk.Label(xalign=0, selectable=True)
        loc_coords_lbl.add_css_class("caption")
        loc_box.append(loc_coords_lbl)

        loc_map_frame = Gtk.Frame()
        loc_map_frame.add_css_class("card")
        loc_map_frame.set_overflow(Gtk.Overflow.HIDDEN)
        loc_map_frame.set_cursor(Gdk.Cursor.new_from_name("pointer"))
        loc_map_gesture = Gtk.GestureClick()
        loc_map_frame.add_controller(loc_map_gesture)
        loc_box.append(loc_map_frame)

        loc_open_btn = Gtk.Button(label=_("Open in Maps App"), margin_top=4)
        loc_box.append(loc_open_btn)

        content_stack.add_named(loc_box, "location")

        frame.set_child(content_stack)
        msg_box.append(frame)

        # Meta line: time + resend + repeat
        meta_box = Gtk.Box(spacing=4)

        time_lbl = Gtk.Label()
        time_lbl.add_css_class("dim-label")
        time_lbl.add_css_class("caption")
        meta_box.append(time_lbl)

        resend_lbl = Gtk.Label(label="")
        resend_lbl.add_css_class("dim-label")
        resend_lbl.add_css_class("caption")
        resend_lbl.set_visible(False)
        meta_box.append(resend_lbl)

        repeat_lbl = Gtk.Label(label="")
        repeat_lbl.add_css_class("dim-label")
        repeat_lbl.add_css_class("caption")
        repeat_lbl.set_visible(False)
        meta_box.append(repeat_lbl)

        msg_box.append(meta_box)
        outer.append(content_box)

        # ── Divider ──
        div_box = Gtk.Box(
            spacing=8,
            margin_start=12,
            margin_end=12,
            margin_top=8,
            margin_bottom=8,
        )
        line_left = Gtk.Separator(hexpand=True, valign=Gtk.Align.CENTER)
        div_box.append(line_left)
        div_lbl = Gtk.Label()
        div_box.append(div_lbl)
        line_right = Gtk.Separator(hexpand=True, valign=Gtk.Align.CENTER)
        div_box.append(line_right)
        div_box.set_visible(False)
        outer.append(div_box)

        # Store widget references
        outer._content_box = content_box
        outer._avatar = avatar
        outer._emoji_lbl = emoji_lbl
        outer._avatar_stack = avatar_stack
        outer._msg_box = msg_box
        outer._sender_lbl = sender_lbl
        outer._text_lbl = text_lbl
        outer._frame = frame
        outer._time_lbl = time_lbl
        outer._resend_lbl = resend_lbl
        outer._repeat_lbl = repeat_lbl
        outer._meta_box = meta_box
        outer._div_box = div_box
        outer._div_lbl = div_lbl
        outer._div_line_left = line_left
        outer._div_line_right = line_right
        outer._content_stack = content_stack
        outer._sc_header_lbl = sc_header_lbl
        outer._sc_icon = sc_icon
        outer._sc_name_lbl = sc_name_lbl
        outer._sc_key_lbl = sc_key_lbl
        outer._sc_add_btn = sc_add_btn
        outer._sc_add_handler_id = None
        outer._loc_header_lbl = loc_header_lbl
        outer._loc_coords_lbl = loc_coords_lbl
        outer._loc_map_frame = loc_map_frame
        outer._loc_open_btn = loc_open_btn
        outer._loc_map_gesture = loc_map_gesture
        outer._loc_map_click_handler = None
        outer._loc_open_handler = None
        outer._loc_map_widget = None
        outer._loc_viewport = None
        outer._loc_marker = None
        outer._chat_item = None
        outer._handler_ids = []

        # Wrap in per-item Clamp for width limiting
        clamp = Adw.Clamp(maximum_size=600, child=outer)
        clamp._outer = outer
        list_item.set_child(clamp)

    def _on_factory_bind(self, factory, list_item):
        """Populate widgets from the model item."""
        item = list_item.get_item()
        clamp = list_item.get_child()
        outer = clamp._outer
        outer._chat_item = item

        if item.kind == ChannelChatItem.KIND_MESSAGE:
            msg = item.message
            outer._msg_box.set_visible(True)
            outer._div_box.set_visible(False)

            # Alignment
            if msg.is_outgoing:
                outer._content_box.set_halign(Gtk.Align.END)
                outer._meta_box.set_halign(Gtk.Align.END)
                outer._avatar_stack.set_visible(False)
            else:
                outer._content_box.set_halign(Gtk.Align.START)
                outer._meta_box.set_halign(Gtk.Align.START)
                # Avatar
                emoji = extract_emoji(msg.sender_name) if msg.sender_name else None
                if emoji:
                    outer._emoji_lbl.set_label(emoji)
                    outer._avatar_stack.set_visible_child_name("emoji")
                else:
                    outer._avatar.set_text(msg.sender_name or "")
                    outer._avatar_stack.set_visible_child_name("initials")
                outer._avatar_stack.set_visible(True)

            # Sender name
            if not msg.is_outgoing and msg.sender_name:
                outer._sender_lbl.set_label(msg.sender_name)
                outer._sender_lbl.set_visible(True)
            else:
                outer._sender_lbl.set_visible(False)

            # Text / shared contact
            sc = parse_shared_contact(msg.text)
            if sc:
                pub_key_hex, contact_type, contact_name = sc
                outer._content_stack.set_visible_child_name("contact")
                if msg.is_outgoing:
                    outer._sc_header_lbl.set_label(_("You shared a contact:"))
                else:
                    sender = msg.sender_name or "?"
                    outer._sc_header_lbl.set_label(
                        _("{name} shared a contact with you:").format(name=sender)
                    )
                outer._sc_icon.set_from_icon_name(
                    CONTACT_TYPE_ICONS.get(contact_type, "avatar-default-symbolic")
                )
                outer._sc_name_lbl.set_label(contact_name)
                outer._sc_key_lbl.set_label(f"{pub_key_hex[:8]}...{pub_key_hex[-8:]}")
                if msg.is_outgoing:
                    outer._sc_add_btn.set_visible(False)
                else:
                    outer._sc_add_btn.set_visible(True)
                    is_known = self._window.is_contact_known(pub_key_hex)
                    outer._sc_add_btn.set_sensitive(not is_known)
                    outer._sc_add_btn.set_label(
                        _("Already added") if is_known else _("Add Contact")
                    )
                    if outer._sc_add_handler_id is not None:
                        outer._sc_add_btn.disconnect(outer._sc_add_handler_id)
                    outer._sc_add_handler_id = outer._sc_add_btn.connect(
                        "clicked",
                        self._on_shared_contact_add,
                        pub_key_hex,
                        contact_type,
                        contact_name,
                    )
            else:
                loc = parse_shared_location(msg.text)
                if loc:
                    lat, lon = loc
                    outer._content_stack.set_visible_child_name("location")
                    if msg.is_outgoing:
                        outer._loc_header_lbl.set_label(_("You shared a location:"))
                    else:
                        sender = msg.sender_name or "?"
                        outer._loc_header_lbl.set_label(
                            _("{name} shared a location:").format(name=sender)
                        )
                    outer._loc_coords_lbl.set_label(f"{lat:.6f}, {lon:.6f}")
                    ensure_loc_map(outer, lat, lon)
                    outer._loc_map_click_handler = outer._loc_map_gesture.connect(
                        "released",
                        lambda g, n, x, y, la=lat, lo=lon: show_location_map(self._window, la, lo),
                    )
                    outer._loc_open_handler = outer._loc_open_btn.connect(
                        "clicked", open_location_external, lat, lon
                    )
                else:
                    outer._content_stack.set_visible_child_name("text")
                    outer._text_lbl.set_markup(linkify(msg.text))

            # Frame style
            if msg.is_outgoing:
                outer._frame.add_css_class("outgoing-message")
            else:
                outer._frame.remove_css_class("outgoing-message")

            # Timestamp
            outer._time_lbl.set_label(msg.timestamp.strftime("%H:%M"))

            # Outgoing: resend + repeat labels
            if msg.is_outgoing:
                outer._resend_lbl.set_visible(True)
                outer._repeat_lbl.set_visible(True)
                self._update_resend_label_widget(outer, item)
                self._update_repeat_label_widget(outer, item)
                h1 = item.connect(
                    "notify::repeat-count",
                    lambda obj, pspec, w=outer: self._update_repeat_label_widget(w, obj),
                )
                h2 = item.connect(
                    "notify::resend-count",
                    lambda obj, pspec, w=outer: self._update_resend_label_widget(w, obj),
                )
                outer._handler_ids = [h1, h2]
            else:
                outer._resend_lbl.set_visible(False)
                outer._repeat_lbl.set_visible(True)
                self._update_hops_label_widget(outer, item)
                h1 = item.connect(
                    "notify::max-hops",
                    lambda obj, pspec, w=outer: self._update_hops_label_widget(w, obj),
                )
                outer._handler_ids = [h1]

        elif item.kind == ChannelChatItem.KIND_DATE_DIVIDER:
            outer._msg_box.set_visible(False)
            outer._div_box.set_visible(True)
            outer._div_lbl.set_label(item.date_text)
            outer._div_lbl.remove_css_class("unread-divider")
            outer._div_lbl.add_css_class("dim-label")
            outer._div_line_left.remove_css_class("unread-divider-line")
            outer._div_line_left.add_css_class("date-divider-line")
            outer._div_line_right.remove_css_class("unread-divider-line")
            outer._div_line_right.add_css_class("date-divider-line")

        elif item.kind == ChannelChatItem.KIND_UNREAD_DIVIDER:
            outer._msg_box.set_visible(False)
            outer._div_box.set_visible(True)
            outer._div_lbl.set_label(_("New Messages"))
            outer._div_lbl.remove_css_class("dim-label")
            outer._div_lbl.add_css_class("unread-divider")
            outer._div_line_left.remove_css_class("date-divider-line")
            outer._div_line_left.add_css_class("unread-divider-line")
            outer._div_line_right.remove_css_class("date-divider-line")
            outer._div_line_right.add_css_class("unread-divider-line")

    def _on_factory_unbind(self, factory, list_item):
        """Clean up signal connections when the widget is recycled."""
        clamp = list_item.get_child()
        outer = clamp._outer
        for hid in outer._handler_ids:
            if outer._chat_item:
                outer._chat_item.disconnect(hid)
        outer._handler_ids = []
        if outer._sc_add_handler_id is not None:
            outer._sc_add_btn.disconnect(outer._sc_add_handler_id)
            outer._sc_add_handler_id = None
        if outer._loc_map_click_handler is not None:
            outer._loc_map_gesture.disconnect(outer._loc_map_click_handler)
            outer._loc_map_click_handler = None
        if outer._loc_open_handler is not None:
            outer._loc_open_btn.disconnect(outer._loc_open_handler)
            outer._loc_open_handler = None
        outer._chat_item = None

    # ─── Shared contact helpers ─────────────────────────────

    def _on_shared_contact_add(self, btn, pub_key_hex, contact_type, name):
        self._window.add_contact(bytes.fromhex(pub_key_hex), contact_type, name)
        btn.set_sensitive(False)
        btn.set_label(_("Already added"))

    # ─── Label update helpers ────────────────────────────────

    @staticmethod
    def _update_repeat_label_widget(outer, item):
        count = item.repeat_count
        if count > 0:
            outer._repeat_lbl.set_label(
                ngettext("\u00b7 %d repeat", "\u00b7 %d repeats", count) % count
            )
        else:
            outer._repeat_lbl.set_label("")

    @staticmethod
    def _update_hops_label_widget(outer, item):
        hops = item.max_hops
        if hops == 0:
            outer._repeat_lbl.set_label("\u00b7 " + _("direct"))
        elif hops == 255 or hops == -1:
            outer._repeat_lbl.set_label("")
        elif hops > 0:
            outer._repeat_lbl.set_label("\u00b7 " + ngettext("%d hop", "%d hops", hops) % hops)
        else:
            outer._repeat_lbl.set_label("")

    @staticmethod
    def _update_resend_label_widget(outer, item):
        count = item.resend_count
        if count > 0:
            outer._resend_lbl.set_label(f'\u00b7 {count} resend{"s" if count != 1 else ""}')
        else:
            outer._resend_lbl.set_label("")

    # ─── Context menu ───────────────────────────────────────

    def _on_list_right_click(self, gesture, n_press, x, y):
        outer = self._find_outer_at(x, y)
        if not outer:
            return
        item = outer._chat_item
        if item and item.kind == ChannelChatItem.KIND_MESSAGE and item.message:
            if gesture:
                gesture.set_state(Gtk.EventSequenceState.CLAIMED)
            self._context_item = item
            self._context_menu_open = True
            self._is_sticky = False
            self._frozen_scroll_value = self._scroll.get_vadjustment().get_value()
            if item.message.is_outgoing:
                self._context_popover.set_menu_model(self._outgoing_menu)
            else:
                self._context_popover.set_menu_model(self._incoming_menu)
            rect = Gdk.Rectangle()
            rect.x, rect.y, rect.width, rect.height = int(x), int(y), 1, 1
            self._context_popover.set_pointing_to(rect)
            self._context_popover.popup()

    def _show_repeats_context(self):
        item = self._context_item
        if not item or not item.message:
            return
        msg = item.message
        key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text)
        self._show_repeats_dialog(key)

    def _resend_context(self):
        item = self._context_item
        if not item or not item.message:
            return
        self._resend_message(item.message, item)

    def _delete_context(self):
        item = self._context_item
        if not item or not item.message:
            return
        self._delete_message_item(item)

    def _reply_context(self):
        item = self._context_item
        if not item or not item.message:
            return
        self._reply_to_message(item.message)

    def _copy_context(self):
        item = self._context_item
        if not item or not item.message:
            return
        self._copy_message_text(item.message)

    def _show_paths_context(self):
        item = self._context_item
        if not item or not item.message:
            return
        msg = item.message
        key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text)
        self._show_message_paths(key, msg)

    # ─── Emoji ───────────────────────────────────────────────

    def _on_share_contact(self, action, param):
        if not self._current_channel:
            return
        channel_idx = self._current_channel.index

        def _send(c):
            text = f"<{c.public_key_hex}:{c.type}:{c.name}>"
            self._window.send_channel_message(channel_idx, text)

        share_contact_dialog(self._window, self._window.contacts, _send)

    def _on_share_location(self, action, param):
        if not self._current_channel:
            return
        channel_idx = self._current_channel.index
        share_location(
            self._window, lambda text: self._window.send_channel_message(channel_idx, text)
        )

    def _on_share_location_map(self, action, param):
        if not self._current_channel:
            return
        channel_idx = self._current_channel.index
        share_location_map(
            self._window, lambda text: self._window.send_channel_message(channel_idx, text)
        )

    # ─── @Mention autocomplete ─────────────────────────────────

    def _get_channel_senders(self) -> list[str]:
        my_name = self._window.device_info.name or ""
        seen = set()
        result = []
        for i in range(self._store.get_n_items() - 1, -1, -1):
            item = self._store.get_item(i)
            if item.kind == ChannelChatItem.KIND_MESSAGE and item.message:
                name = item.message.sender_name
                if name and name != my_name and name not in seen:
                    seen.add(name)
                    result.append(name)
        return result

    def _update_mention_popover(self, prefix: str, at_char_idx: int):
        senders = self._get_channel_senders()
        filtered = (
            [n for n in senders if n.lower().startswith(prefix.lower())] if prefix else senders
        )

        if not filtered:
            if self._mention_popover:
                self._mention_popover.popdown()
            return

        if not self._mention_popover:
            self._mention_popover = Gtk.Popover()
            self._mention_popover.set_parent(self._text_entry)
            self._mention_popover.set_autohide(False)
            self._mention_popover.set_has_arrow(False)
            self._mention_popover.set_position(Gtk.PositionType.TOP)
            self._mention_popover.set_halign(Gtk.Align.START)
            self._mention_listbox = Gtk.ListBox()
            self._mention_listbox.set_selection_mode(Gtk.SelectionMode.SINGLE)
            self._mention_listbox.connect("row-activated", self._on_mention_selected)
            scrolled = Gtk.ScrolledWindow(
                propagate_natural_height=True,
                max_content_height=280,
                hscrollbar_policy=Gtk.PolicyType.NEVER,
            )
            scrolled.set_child(self._mention_listbox)
            self._mention_popover.set_child(scrolled)

        try:
            delegate = self._text_entry.get_delegate()
            layout = delegate.get_layout()
            index_bytes = len(self._text_entry.get_text()[:at_char_idx].encode("utf-8"))
            strong_pos, _ = layout.get_cursor_pos(index_bytes)
            layout_x, _ = delegate.get_layout_offsets()
            cursor_x = layout_x + strong_pos.x // Pango.SCALE
        except Exception:
            cursor_x = 0

        rect = Gdk.Rectangle()
        rect.x = cursor_x
        rect.y = 0
        rect.width = 1
        rect.height = self._text_entry.get_height()
        self._mention_popover.set_pointing_to(rect)

        while row := self._mention_listbox.get_first_child():
            self._mention_listbox.remove(row)
        for name in filtered[:20]:
            label = Gtk.Label(
                label=name, xalign=0, margin_start=8, margin_end=8, margin_top=4, margin_bottom=4
            )
            self._mention_listbox.append(label)

        self._mention_popover.popup()

    def _check_mention_trigger(self, text: str, cursor_pos: int):
        text_before = text[:cursor_pos]
        at_idx = text_before.rfind("@")
        if at_idx == -1:
            if self._mention_popover:
                self._mention_popover.popdown()
            return
        if at_idx > 0 and text_before[at_idx - 1] not in (" ", "\t"):
            if self._mention_popover:
                self._mention_popover.popdown()
            return
        after_at = text_before[at_idx + 1 :]
        if "[" in after_at:
            if self._mention_popover:
                self._mention_popover.popdown()
            return
        self._update_mention_popover(after_at, at_idx)

    def _on_mention_selected(self, listbox, row):
        label = row.get_child()
        name = label.get_label()
        text = self._text_entry.get_text()
        cursor_pos = self._text_entry.get_position()
        text_before = text[:cursor_pos]
        at_idx = text_before.rfind("@")
        if at_idx == -1:
            return
        mention = f"@[{name}] "
        new_text = text[:at_idx] + mention + text[cursor_pos:]
        self._text_entry.set_text(new_text)
        self._text_entry.set_position(at_idx + len(mention))
        self._mention_popover.popdown()

    def _on_entry_key_pressed(self, ctrl, keyval, keycode, state):
        if not self._mention_popover or not self._mention_popover.get_visible():
            return False
        if keyval == Gdk.KEY_Escape:
            self._mention_popover.popdown()
            return True
        if keyval in (Gdk.KEY_Down, Gdk.KEY_Up):
            selected = self._mention_listbox.get_selected_row()
            if keyval == Gdk.KEY_Down:
                if selected:
                    next_row = selected.get_next_sibling()
                    if next_row:
                        self._mention_listbox.select_row(next_row)
                else:
                    first = self._mention_listbox.get_first_child()
                    if first:
                        self._mention_listbox.select_row(first)
            elif keyval == Gdk.KEY_Up and selected:
                prev_row = selected.get_prev_sibling()
                if prev_row:
                    self._mention_listbox.select_row(prev_row)
            return True
        if keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_Tab):
            selected = self._mention_listbox.get_selected_row()
            if selected:
                self._on_mention_selected(self._mention_listbox, selected)
                return True
        return False

    # ─── Channel & messages ──────────────────────────────────

    def get_last_message_time(self, channel_index: int) -> float | None:
        try:
            if not self._window.storage:
                return None
            return self._window.storage.get_max_channel_message_timestamp(channel_index)
        except Exception:
            return None

    def set_channel(self, channel: Channel):
        if self._mention_popover:
            self._mention_popover.popdown()
        if self._current_channel is not None:
            adj = self._scroll.get_vadjustment()
            self._scroll_positions[self._current_channel.index] = adj.get_value()
        self._current_channel = channel
        self._stack.set_visible_child_name("chat")
        self._load_messages(channel)

    def _load_messages(self, channel: Channel):
        self._outgoing_items.clear()
        self._repeats.clear()
        self._incoming_paths.clear()
        self._seen_channel_msgs.clear()
        self._incoming_items.clear()
        self._resend_ts_map.clear()
        self._unread_pos = -1
        self._messages_offset = 0
        self._all_loaded = False
        self._loading_more = False

        messages = self._window.get_channel_messages(channel.index, limit=_BATCH_SIZE)
        self._messages_offset = len(messages)
        self._all_loaded = len(messages) < _BATCH_SIZE
        last_read_at = self._window.get_channel_last_read_at(channel.index)

        self._preload_paths(messages)

        new_items = []
        divider_inserted = False
        last_date = None
        for msg in messages:
            msg_date = msg.timestamp.date()
            if last_date is not None and msg_date != last_date:
                new_items.append(
                    ChannelChatItem(
                        kind=ChannelChatItem.KIND_DATE_DIVIDER,
                        date_text=_format_date_text(msg.timestamp),
                    )
                )
            last_date = msg_date
            if (
                not divider_inserted
                and last_read_at is not None
                and not msg.is_outgoing
                and msg.timestamp.timestamp() > last_read_at
            ):
                new_items.append(ChannelChatItem(kind=ChannelChatItem.KIND_UNREAD_DIVIDER))
                self._unread_pos = len(new_items) - 1
                divider_inserted = True
            new_items.append(self._create_message_item(msg))

        saved_pos = self._scroll_positions.get(channel.index)
        if saved_pos is not None:
            self._is_sticky = False
            self._restoring_scroll = True

        # Replace all items in a single atomic operation
        self._store.splice(0, self._store.get_n_items(), new_items)

        if saved_pos is not None:
            GLib.idle_add(lambda pos=saved_pos: self._restore_scroll_position(pos))
        else:
            GLib.idle_add(self._scroll_to_bottom)
        if self._unread_pos >= 0:
            GLib.idle_add(lambda: self._update_unread_btn_visibility() or False)

        # Mark as read now
        self._window.mark_channel_read(channel.index)

    def _preload_paths(self, messages):
        if not self._window.storage:
            return
        for msg in messages:
            key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text)
            if msg.is_outgoing:
                paths = self._window.storage.get_channel_message_paths(
                    msg.channel_index,
                    int(msg.timestamp.timestamp()),
                    msg.text,
                )
                if paths:
                    self._repeats[key] = [
                        {
                            "path_hops": self._split_path_hops(
                                p.get("path_bytes", ""),
                                p.get("path_len", 0),
                            ),
                            "path_len": p.get("path_len", 0),
                        }
                        for p in paths
                    ]
            else:
                if key not in self._incoming_paths:
                    paths = self._window.storage.get_channel_message_paths(
                        msg.channel_index,
                        int(msg.timestamp.timestamp()),
                        msg.text,
                    )
                    if paths:
                        self._incoming_paths[key] = paths

    def _load_older_messages(self):
        if self._all_loaded or self._loading_more or not self._current_channel:
            return
        self._loading_more = True

        messages = self._window.get_channel_messages(
            self._current_channel.index, limit=_BATCH_SIZE, offset=self._messages_offset
        )
        if not messages:
            self._all_loaded = True
            self._loading_more = False
            return

        self._preload_paths(messages)

        older_items = []
        last_date = None
        for msg in messages:
            msg_date = msg.timestamp.date()
            if last_date is not None and msg_date != last_date:
                older_items.append(
                    ChannelChatItem(
                        kind=ChannelChatItem.KIND_DATE_DIVIDER,
                        date_text=_format_date_text(msg.timestamp),
                    )
                )
            last_date = msg_date
            older_items.append(self._create_message_item(msg))

        # Add boundary date divider if the dates differ
        if last_date is not None:
            for i in range(self._store.get_n_items()):
                first = self._store.get_item(i)
                if first.kind == ChannelChatItem.KIND_MESSAGE:
                    if first.message.timestamp.date() != last_date:
                        older_items.append(
                            ChannelChatItem(
                                kind=ChannelChatItem.KIND_DATE_DIVIDER,
                                date_text=_format_date_text(first.message.timestamp),
                            )
                        )
                    break

        self._store.splice(0, 0, older_items)
        if self._unread_pos >= 0:
            self._unread_pos += len(older_items)
        self._messages_offset += len(messages)
        self._all_loaded = len(messages) < _BATCH_SIZE
        self._loading_more = False

    @staticmethod
    def _split_path_hops(path_bytes_hex: str, hop_count: int) -> list[str]:
        """Split concatenated path hex into individual hop prefixes.

        Supports 1-byte (2 hex), 2-byte (4 hex), and 3-byte (6 hex) hash sizes.
        The hash size is derived from total bytes / hop count.
        """
        if not path_bytes_hex or hop_count <= 0:
            return []
        total_bytes = len(path_bytes_hex) // 2
        if total_bytes % hop_count == 0:
            hash_size = total_bytes // hop_count
        else:
            hash_size = 1  # fallback
        chars_per_hop = hash_size * 2
        return [
            path_bytes_hex[i : i + chars_per_hop].upper()
            for i in range(0, len(path_bytes_hex), chars_per_hop)
        ]

    def _make_msg_key(self, channel_index, timestamp, text):
        """Create a key for matching outgoing messages to repeats."""
        ts = int(timestamp.timestamp()) if hasattr(timestamp, "timestamp") else int(timestamp)
        return (channel_index, ts, text)

    def _create_message_item(self, msg: ChannelMessage) -> ChannelChatItem:
        """Create a ChannelChatItem and register it in tracking dicts."""
        item = ChannelChatItem(kind=ChannelChatItem.KIND_MESSAGE, message=msg)
        key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text)

        if msg.is_outgoing:
            self._outgoing_items[key] = item
            if key not in self._repeats:
                self._repeats[key] = []
            # Set initial repeat count from persisted data
            item._repeat_count_val = len(self._repeats.get(key, []))
        else:
            self._incoming_items[key] = item
            paths = self._incoming_paths.get(key, [])
            if paths:
                item._max_hops_val = max(p.get("path_len", -1) for p in paths)

        return item

    def _add_message_item(self, msg: ChannelMessage):
        """Create a ChannelChatItem and add it to the store."""
        item = self._create_message_item(msg)
        self._store.append(item)

    # ─── Dialogs (unchanged) ─────────────────────────────────

    def _show_message_paths(self, key, msg):
        """Show dialog with message path variants and repeater hops."""
        paths = self._incoming_paths.get(key, [])
        # Fallback to storage if no in-memory data
        if not paths and self._window.storage:
            paths = self._window.storage.get_channel_message_paths(
                msg.channel_index,
                int(msg.timestamp.timestamp()),
                msg.text,
            )
            if paths:
                self._incoming_paths[key] = paths

        if not paths:
            dialog = Adw.AlertDialog(
                heading=_("Message Paths"),
                body=_(
                    "No path information available. Path data is only "
                    "captured for messages received while connected."
                ),
            )
            dialog.add_response("ok", _("OK"))
            dialog.present(self._window)
            return

        dialog = Adw.Dialog()
        dialog.set_title(_("Message Paths"))
        dialog.set_content_width(420)
        dialog.set_content_height(480)

        toolbar_view = Adw.ToolbarView()
        header = Adw.HeaderBar()
        toolbar_view.add_top_bar(header)

        scroll = Gtk.ScrolledWindow(vexpand=True)
        content = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=8,
            margin_start=12,
            margin_end=12,
            margin_top=12,
            margin_bottom=12,
        )

        info_label = Gtk.Label(
            label=f'Message from {msg.sender_name or "unknown"} '
            f'received via {len(paths)} path{"s" if len(paths) != 1 else ""}:',
            wrap=True,
            xalign=0,
        )
        info_label.add_css_class("dim-label")
        content.append(info_label)

        for i, p in enumerate(paths, 1):
            snr = p.get("snr")
            path_len = p.get("path_len", -1)
            path_bytes_hex = p.get("path_bytes", "")

            if path_bytes_hex and path_len > 0:
                hops = self._split_path_hops(path_bytes_hex, path_len)
                num_hops = len(hops) if hops else path_len
                hops_str = f'{num_hops} hop{"s" if num_hops != 1 else ""}'
            elif path_len == 255 or path_len == -1:
                hops_str = "flood"
            elif path_len == 0:
                hops_str = "direct"
            else:
                hops_str = f'{path_len} hop{"s" if path_len != 1 else ""}'

            snr_str = f"SNR: {snr:.1f} dB" if snr is not None else ""
            title_parts = [f"Path {i}: {hops_str}"]
            if snr_str:
                title_parts.append(snr_str)

            path_group = Adw.PreferencesGroup(
                title=" \u00b7 ".join(title_parts),
            )

            if path_bytes_hex and path_len > 0:
                hops = self._split_path_hops(path_bytes_hex, path_len)
                hop_contacts = self._window.resolve_hop_prefixes(hops)
                for hop_idx, prefix in enumerate(hops):
                    contact = hop_contacts[hop_idx]
                    name = contact.name if contact else _("Unknown repeater")
                    subtitle = f"Hop {hop_idx + 1} \u00b7 prefix {prefix}"

                    hop_row = Adw.ActionRow(
                        title=name,
                        subtitle=subtitle,
                    )
                    hop_row.add_prefix(Gtk.Image.new_from_icon_name("network-server-symbolic"))
                    path_group.add(hop_row)

                if any(c and c.has_location for c in hop_contacts):
                    map_btn = Gtk.Button(icon_name="map-symbolic", valign=Gtk.Align.CENTER)
                    map_btn.add_css_class("flat")
                    map_btn.set_tooltip_text(_("Show on Map"))
                    map_btn.connect(
                        "clicked",
                        lambda b, hc=hop_contacts, hp=hops: self._show_channel_path_on_map(hc, hp),
                    )
                    path_group.set_header_suffix(map_btn)
            elif path_len == 0:
                direct_row = Adw.ActionRow(
                    title=_("Direct message"),
                    subtitle=_("No repeaters were used"),
                    sensitive=False,
                )
                direct_row.add_prefix(Gtk.Image.new_from_icon_name("network-wireless-symbolic"))
                path_group.add(direct_row)
            else:
                no_path_row = Adw.ActionRow(
                    title=_("No repeater details"),
                    subtitle=_("Path data is only captured via LOG_DATA while connected"),
                    sensitive=False,
                )
                path_group.add(no_path_row)

            content.append(path_group)

        scroll.set_child(content)
        toolbar_view.set_content(scroll)
        dialog.set_child(toolbar_view)
        dialog.present(self._window)

    def _show_channel_path_on_map(self, hop_contacts, hop_prefixes=None):
        """Show a channel message path on the map."""
        import gi
        from meshy.views.map_view import (
            _MARKER_COLORS,
            _MARKER_ICONS,
            _SELF_COLOR,
            _UNKNOWN_COLOR,
            MapView,
        )

        gi.require_version("Shumate", "1.0")

        device = self._window.device_info
        self_lat = getattr(device, "latitude", None)
        self_lon = getattr(device, "longitude", None)
        has_self = self_lat and self_lon and (abs(self_lat) > 1e-6 or abs(self_lon) > 1e-6)

        # Build full hop list including unknowns
        raw_hops = []
        for i, c in enumerate(hop_contacts):
            prefix = hop_prefixes[i] if hop_prefixes and i < len(hop_prefixes) else None
            if c and c.has_location:
                raw_hops.append((c.latitude, c.longitude, c, prefix))
            else:
                raw_hops.append((None, None, c, prefix))

        if has_self:
            raw_hops.append((self_lat, self_lon, None, None))

        # Interpolate unknown positions
        interp = []
        for lat, lon, contact, prefix in raw_hops:
            interp.append((lat, lon, contact, prefix, None))
        interp = MapView.interpolate_unknown_hops(interp)

        if not any(h[0] is not None for h in interp):
            return

        dialog, map_widget, marker_layer, viewport = MapView.open_map_dialog(
            self._window, title=_("Message Path")
        )

        map_widget.get_map().remove_layer(marker_layer)
        path_layer = MapView.create_dashed_path_layer(viewport)
        map_widget.get_map().add_layer(path_layer)
        map_widget.get_map().add_layer(marker_layer)

        lats, lons = [], []

        hop_num = 0
        for lat, lon, contact, prefix, _snr in interp:
            if lat is None:
                continue
            node = Shumate.Marker()
            node.set_location(lat, lon)
            path_layer.add_node(node)

            if contact is None and prefix is None:
                # Self/device marker
                r, g, b = _SELF_COLOR
                icon_name = "network-wireless-symbolic"
                name = device.name or _("My Device")
                type_label = None
                badge = ""
            elif contact is None or not contact.has_location:
                # Unknown/interpolated hop
                r, g, b = _UNKNOWN_COLOR
                icon_name = "network-server-symbolic"
                name = prefix or _("Unknown")
                type_label = _("Unknown repeater")
                hop_num += 1
                badge = str(hop_num)
            else:
                r, g, b = _MARKER_COLORS.get(contact.type, (0.5, 0.5, 0.5))
                icon_name = _MARKER_ICONS.get(contact.type, "avatar-default-symbolic")
                name = contact.name
                type_label = contact.type_label
                hop_num += 1
                badge = str(hop_num)

            is_interpolated = (contact is None or not contact.has_location) and prefix is not None
            detail_cb = (lambda c=contact: self._window.show_contact_detail(c)) if contact else None
            widget = MapView._build_marker_widget(r, g, b, icon_name, "", badge_text=badge)
            widget.set_tooltip_text(name)
            click = Gtk.GestureClick()
            click.connect(
                "pressed",
                lambda g,
                n,
                x,
                y,
                w=widget,
                nm=name,
                la=lat,
                lo=lon,
                t=type_label,
                ip=is_interpolated,
                od=detail_cb: MapView.show_marker_popover(
                    w, nm, la, lo, contact_type=t, interpolated=ip, on_detail=od
                ),
            )
            widget.add_controller(click)

            hop_marker = Shumate.Marker()
            hop_marker.set_child(widget)
            hop_marker.set_location(lat, lon)
            marker_layer.add_marker(hop_marker)

            lats.append(lat)
            lons.append(lon)

        MapView.fit_viewport(viewport, lats, lons)
        dialog.present(self._window)

    def _reply_to_message(self, msg):
        """Insert @[sender_name] into the text entry."""
        mention = f"@[{msg.sender_name}] " if msg.sender_name else ""
        self._text_entry.set_text(mention)
        self._text_entry.set_position(len(mention))
        self._text_entry.grab_focus()

    def _copy_message_text(self, msg):
        """Copy message text to clipboard."""
        clipboard = self._window.get_clipboard()
        clipboard.set(msg.text)

    def _show_repeats_dialog(self, key):
        repeats = self._repeats.get(key, [])
        if not repeats:
            dialog = Adw.AlertDialog(
                heading=_("Heard Repeats"),
                body=_("No repeats heard yet."),
            )
            dialog.add_response("ok", _("OK"))
            dialog.present(self._window)
            return

        dialog = Adw.AlertDialog(heading=_("Heard Repeats"))
        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        for i, rep in enumerate(repeats, 1):
            hops = rep.get("path_hops", [])
            if hops:
                hop_names = []
                for prefix in hops:
                    contact = self._window.find_contact_by_key_prefix(prefix.lower())
                    hop_names.append(contact.name if contact else prefix)
                path_str = " \u2192 ".join(hop_names)
            else:
                path_len = rep.get("path_len", 0)
                path_str = (
                    f'{path_len} hop{"s" if path_len != 1 else ""}' if path_len > 0 else "direct"
                )
            label = Gtk.Label(label=f"{i}. {path_str}", xalign=0, wrap=True)
            content.append(label)
        dialog.set_extra_child(content)
        dialog.add_response("ok", _("OK"))
        dialog.present(self._window)

    # ─── Message actions ─────────────────────────────────────

    def _resend_message(self, msg, item):
        if self._current_channel:
            new_ts = self._window.resend_channel_message(self._current_channel.index, msg.text)
            original_key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text)
            resend_key = (msg.channel_index, new_ts, msg.text)
            self._resend_ts_map[resend_key] = original_key
            item.props.resend_count = item.resend_count + 1

    def _delete_message_item(self, item):
        msg = item.message
        # Find and remove from store
        for i in range(self._store.get_n_items()):
            if self._store.get_item(i) is item:
                self._store.remove(i)
                break
        # Clean up tracking dicts
        key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text)
        self._outgoing_items.pop(key, None)
        self._incoming_items.pop(key, None)
        self._repeats.pop(key, None)
        self._incoming_paths.pop(key, None)
        self._seen_channel_msgs.discard(key)
        # Delete from storage
        self._window.delete_channel_message(
            msg.channel_index,
            msg.timestamp.timestamp(),
            msg.text,
        )

    # ─── Input ───────────────────────────────────────────────

    def _get_channel_limit(self) -> int:
        sender_name = self._window.device_info.name or ""
        return max_channel_text_bytes(sender_name)

    def _on_text_changed(self, entry):
        text = entry.get_text()
        used = len(text.encode("utf-8"))
        limit = self._get_channel_limit()
        if used == 0:
            self._char_label.set_label("")
            self._send_btn.set_sensitive(True)
            self._char_label.remove_css_class("error")
        else:
            self._char_label.set_label(f"{used}/{limit}")
            over = used > limit
            self._send_btn.set_sensitive(not over)
            if over:
                self._char_label.add_css_class("error")
            else:
                self._char_label.remove_css_class("error")
        self._check_mention_trigger(text, entry.get_position())

    def _on_send(self, *args):
        if not self._current_channel:
            return
        text = self._text_entry.get_text().strip()
        if not text:
            return
        if len(text.encode("utf-8")) > self._get_channel_limit():
            return
        self._text_entry.set_text("")
        self._window.send_channel_message(self._current_channel.index, text)

    # ─── External callbacks ──────────────────────────────────

    def on_channel_message_sent(self, msg: ChannelMessage, sender_ts: int):
        """Called when a channel message is sent by us."""
        if (
            self._current_channel
            and msg.channel_index == self._current_channel.index
            and self._stack.get_visible_child_name() == "chat"
        ):
            # Force sticky so notify::upper handler scrolls to the new message
            self._is_sticky = True
            self._add_message_item(msg)

    def _update_item_max_hops(self, key, path_info):
        item = self._incoming_items.get(key)
        if item and path_info:
            new_len = path_info.get("path_len", -1)
            if new_len > item.max_hops:
                item.props.max_hops = new_len

    def on_path_variant_received(self, msg, path_info=None):
        """Check if this is a duplicate message via a different path.
        Returns True if it's a duplicate (already seen), False if new."""
        key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text)
        if key in self._seen_channel_msgs:
            if path_info:
                self._incoming_paths.setdefault(key, []).append(path_info)
                self._update_item_max_hops(key, path_info)
            return True
        self._seen_channel_msgs.add(key)
        if path_info:
            self._incoming_paths.setdefault(key, []).append(path_info)
        return False

    def add_path_info(self, msg, path_info):
        """Append path_info to an existing message without affecting dedup."""
        key = self._make_msg_key(msg.channel_index, msg.timestamp, msg.text)
        if key in self._incoming_paths:
            self._incoming_paths[key].append(path_info)
        else:
            self._incoming_paths[key] = [path_info]
        self._update_item_max_hops(key, path_info)

    def on_channel_message_received(self, msg: ChannelMessage, path_info=None):
        if (
            self._current_channel
            and msg.channel_index == self._current_channel.index
            and self._stack.get_visible_child_name() == "chat"
        ):
            # If sticky, the notify::upper handler will auto-scroll
            self._add_message_item(msg)

    def on_repeat_received(self, channel_idx, sender_ts, text, path_hops):
        """Called when an echo/repeat of our outgoing message is received.

        Returns the original message key (channel_idx, ts, text) if matched,
        so the caller can persist path info under the original timestamp.
        """
        repeat_entry = {
            "path_hops": path_hops or [],
            "path_len": len(path_hops) if path_hops else 0,
        }

        # Check if this echo matches a resend timestamp
        resend_key = (channel_idx, sender_ts, text)
        matched_key = self._resend_ts_map.pop(resend_key, None)

        if matched_key is None:
            # Fall back to heuristic: same channel + timestamp within 30s + matching text
            for k in self._outgoing_items:
                if k[0] != channel_idx:
                    continue
                ts_diff = abs(k[1] - sender_ts)
                if ts_diff > 30:
                    continue
                if k[2] == text or text.startswith(k[2]) or k[2].startswith(text):
                    matched_key = k
                    break

        if matched_key is None:
            key = (channel_idx, sender_ts, text)
            if key not in self._repeats:
                self._repeats[key] = []
            self._repeats[key].append(repeat_entry)
            return None

        if matched_key not in self._repeats:
            self._repeats[matched_key] = []
        self._repeats[matched_key].append(repeat_entry)
        item = self._outgoing_items.get(matched_key)
        if item:
            item.props.repeat_count = len(self._repeats[matched_key])
        return matched_key


@Gtk.Template(resource_path="/page/codeberg/sesivany/Meshy/ui/channels-view.ui")
class ChannelsView(Gtk.Box):
    """Channels list - shown in the sidebar."""

    __gtype_name__ = "MeshyChannelsView"

    _channel_list = Gtk.Template.Child("channel_list")
    _search_bar = Gtk.Template.Child("search_bar")
    _search_entry = Gtk.Template.Child("search_entry")
    _status_label = Gtk.Template.Child("status_label")

    FILTER_ALL = "all"
    FILTER_PUBLIC = "public"
    FILTER_HASHTAG = "hashtag"
    FILTER_PRIVATE = "private"

    SORT_AZ = "az"
    SORT_MESSAGES = "messages"

    def __init__(self, window, **kwargs):
        super().__init__(**kwargs)
        self._window = window
        self._chat_widget = ChannelChatWidget(window)
        self._unread_indices: set[int] = set()
        self._unread_counts: dict[int, int] = {}
        self._unread_dots: dict[int, Gtk.Label] = {}
        self._current_filter = self.FILTER_ALL
        settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy")
        saved_sort = settings.get_string("channel-sort")
        self._current_sort = (
            saved_sort if saved_sort in (self.SORT_AZ, self.SORT_MESSAGES) else self.SORT_MESSAGES
        )
        self._total_channels = 0

        self._channel_list.connect("row-activated", self._on_row_selected)
        self._search_bar.connect_entry(self._search_entry)
        self._search_bar.connect("notify::search-mode-enabled", self._on_search_mode_changed)
        self._search_entry.connect("search-changed", self._on_search_changed)
        self._channel_list.set_filter_func(self._filter_func)

        # Actions for the add channel menu and filter/sort
        action_group = Gio.SimpleActionGroup()
        action = Gio.SimpleAction.new("create-private", None)
        action.connect("activate", self._on_create_private_channel)
        action_group.add_action(action)

        action = Gio.SimpleAction.new("join-private", None)
        action.connect("activate", self._on_join_private_channel)
        action_group.add_action(action)

        action = Gio.SimpleAction.new("join-public", None)
        action.connect("activate", self._on_join_public_channel)
        action_group.add_action(action)

        action = Gio.SimpleAction.new("join-hashtag", None)
        action.connect("activate", self._on_join_hashtag_channel)
        action_group.add_action(action)

        filter_action = Gio.SimpleAction.new_stateful(
            "filter", GLib.VariantType.new("s"), GLib.Variant.new_string(self._current_filter)
        )
        filter_action.connect("activate", self._on_filter_changed)
        action_group.add_action(filter_action)
        sort_action = Gio.SimpleAction.new_stateful(
            "sort", GLib.VariantType.new("s"), GLib.Variant.new_string(self._current_sort)
        )
        sort_action.connect("activate", self._on_sort_changed)
        action_group.add_action(sort_action)

        self._action_group = action_group
        self.insert_action_group("channel", action_group)
        self._search_btn = None
        self._search_binding = None

        self.update_channels(self._window.channels)

    def get_chat_widget(self) -> ChannelChatWidget:
        return self._chat_widget

    def set_search_button(self, btn):
        if self._search_binding:
            self._search_binding.unbind()
        self._search_btn = btn
        self._search_binding = btn.bind_property(
            "active",
            self._search_bar,
            "search-mode-enabled",
            GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE,
        )

    def build_filter_menu(self):
        menu = Gio.Menu()
        for filter_id, label in (
            ("all", _("All")),
            ("public", _("Public")),
            ("hashtag", _("Hashtag")),
            ("private", _("Private")),
        ):
            item = Gio.MenuItem.new(label, None)
            item.set_action_and_target_value("channel.filter", GLib.Variant.new_string(filter_id))
            menu.append_item(item)
        return menu

    def build_sort_menu(self):
        menu = Gio.Menu()
        for sort_id, label in (
            ("az", _("A–Z")),
            ("messages", _("Latest Messages")),
        ):
            item = Gio.MenuItem.new(label, None)
            item.set_action_and_target_value("channel.sort", GLib.Variant.new_string(sort_id))
            menu.append_item(item)
        return menu

    def _filter_func(self, row) -> bool:
        if not hasattr(row, "_channel"):
            return True
        channel = row._channel
        f = self._current_filter
        if (
            f == self.FILTER_PUBLIC
            and not channel.is_public_channel
            or f == self.FILTER_HASHTAG
            and not channel.is_hashtag_channel
            or f == self.FILTER_PRIVATE
            and (channel.is_public_channel or channel.is_hashtag_channel)
        ):
            return False
        search = self._search_entry.get_text().lower()
        return not (search and search not in (channel.name or "").lower())

    def _update_status_count(self):
        total = self._total_channels
        if self._current_filter != self.FILTER_ALL or self._search_entry.get_text():
            visible = 0
            row = self._channel_list.get_first_child()
            while row:
                if hasattr(row, "_channel") and self._filter_func(row):
                    visible += 1
                row = row.get_next_sibling()
            self._status_label.set_label(
                _("{visible}/{total} channels").format(visible=visible, total=total)
            )
        else:
            self._status_label.set_label(
                _("{total} channels").format(total=total)
                if total != 1
                else _("{total} channel").format(total=total)
            )

    def _on_search_mode_changed(self, search_bar, pspec):
        if not search_bar.get_search_mode():
            self._search_entry.set_text("")

    def _on_search_changed(self, entry):
        self._channel_list.invalidate_filter()
        GLib.idle_add(self._update_status_count)

    def _on_filter_changed(self, action, param):
        self._current_filter = param.get_string()
        action.set_state(param)
        self._channel_list.invalidate_filter()
        GLib.idle_add(self._update_status_count)

    def _on_sort_changed(self, action, param):
        self._current_sort = param.get_string()
        action.set_state(param)
        settings = Gio.Settings(schema_id="page.codeberg.sesivany.Meshy")
        settings.set_string("channel-sort", self._current_sort)
        self.update_channels(self._window.channels)

    def _channel_sort_key(self, channel: Channel):
        type_order = 0 if channel.is_public_channel else (1 if channel.is_hashtag_channel else 2)
        if self._current_sort == self.SORT_AZ:
            return (type_order, (channel.name or "").lower())
        else:
            last_msg = self._chat_widget.get_last_message_time(channel.index)
            return (type_order, -(last_msg or 0))

    def _on_row_selected(self, listbox, row):
        if row and hasattr(row, "_channel"):
            self._chat_widget.set_channel(row._channel)
            # Navigate to content panel (matters in collapsed/phone mode)
            title = row._channel.name or _("Channel {}").format(row._channel.index)
            if row._channel.region:
                title = _("{name} ({region})").format(name=title, region=row._channel.region)
            self._window.content_page.set_title(title)
            self._window.split_view.set_show_content(True)
            self.mark_unread(row._channel.index, False)

    def update_channels(self, channels: list[Channel]):
        child = self._channel_list.get_first_child()
        while child:
            next_child = child.get_next_sibling()
            self._channel_list.remove(child)
            child = next_child

        non_empty = [c for c in channels if not c.is_empty]
        self._total_channels = len(non_empty)
        sorted_channels = sorted(non_empty, key=self._channel_sort_key)

        for channel in sorted_channels:
            row = Adw.ActionRow(
                title=channel.name or _("Channel {}").format(channel.index),
                subtitle=channel.region,
            )
            if channel.is_hashtag_channel:
                icon_name = "hashtag-symbolic"
            elif channel.is_public_channel:
                icon_name = "system-users-symbolic"
            else:
                icon_name = "padlock2-symbolic"
            row.add_prefix(Gtk.Image.new_from_icon_name(icon_name))
            row.set_activatable(True)
            row._channel = channel

            # Unread badge — visible by default when count > 0, hidden on hover
            count = self._unread_counts.get(channel.index, 0)
            unread_badge = Gtk.Label(label=str(count) if count else "")
            unread_badge.add_css_class("unread-badge")
            unread_badge.set_valign(Gtk.Align.CENTER)
            unread_badge.set_visible(count > 0)
            row.add_suffix(unread_badge)
            self._unread_dots[channel.index] = unread_badge

            # "More" button + hover behavior + long press
            info_btn = Gtk.Button(icon_name="view-more-symbolic", valign=Gtk.Align.CENTER)
            info_btn.add_css_class("flat")
            info_btn.set_visible(False)
            on_detail = lambda *_, ch=channel: self._show_channel_detail(ch)
            info_btn.connect("clicked", on_detail)
            row.add_suffix(info_btn)
            _add_hover_swap(
                row,
                info_btn,
                unread_badge,
                lambda _idx=channel.index: self._unread_counts.get(_idx, 0),
                on_detail,
            )

            self._channel_list.append(row)

        GLib.idle_add(self._update_status_count)

    def _get_visible_rows(self):
        """Return visible rows in display order."""
        rows = []
        row = self._channel_list.get_first_child()
        while row:
            if row.get_visible() and hasattr(row, "_channel"):
                rows.append(row)
            row = row.get_next_sibling()
        return rows

    def navigate(self, direction: int, unread_only: bool = False):
        """Move selection up (-1) or down (+1). If unread_only, skip to next unread."""
        rows = self._get_visible_rows()
        if not rows:
            return
        if unread_only:
            rows = [r for r in rows if r._channel.index in self._unread_indices]
            if not rows:
                return
        selected = self._channel_list.get_selected_row()
        if selected and selected in rows:
            idx = rows.index(selected)
            idx = (idx + direction) % len(rows)
        else:
            idx = 0 if direction > 0 else len(rows) - 1
        self._channel_list.select_row(rows[idx])
        rows[idx].grab_focus()

    def on_path_variant_received(self, msg, path_info=None):
        return self._chat_widget.on_path_variant_received(msg, path_info)

    def add_path_info(self, msg, path_info):
        self._chat_widget.add_path_info(msg, path_info)

    def on_channel_message_received(self, msg: ChannelMessage, path_info=None):
        self._chat_widget.on_channel_message_received(msg, path_info)

    def on_channel_message_sent(self, msg: ChannelMessage, sender_ts: int):
        self._chat_widget.on_channel_message_sent(msg, sender_ts)

    def on_repeat_received(self, channel_idx, sender_ts, text, path_hops):
        return self._chat_widget.on_repeat_received(channel_idx, sender_ts, text, path_hops)

    def mark_unread(self, channel_index: int, unread: bool):
        """Show or hide the unread badge for a channel."""
        if unread:
            self._unread_indices.add(channel_index)
            self._unread_counts[channel_index] = self._unread_counts.get(channel_index, 0) + 1
        else:
            self._unread_indices.discard(channel_index)
            self._unread_counts[channel_index] = 0
        if channel_index in self._unread_dots:
            badge = self._unread_dots[channel_index]
            count = self._unread_counts.get(channel_index, 0)
            badge.set_label(str(count) if count else "")
            badge.set_visible(count > 0)
        self._window.update_nav_unread_channels()

    def _show_channel_detail(self, channel: Channel):
        """Show channel detail dialog with rename, PSK, notifications, and remove."""
        dialog = Adw.Dialog()
        dialog.set_title(channel.name or _("Channel {}").format(channel.index))
        dialog.set_content_width(420)
        dialog.set_content_height(480)

        toolbar_view = Adw.ToolbarView()
        header = Adw.HeaderBar()
        toolbar_view.add_top_bar(header)

        scroll = Gtk.ScrolledWindow(vexpand=True)
        clamp = Adw.Clamp(maximum_size=420)
        content = Gtk.Box(
            orientation=Gtk.Orientation.VERTICAL,
            spacing=12,
            margin_start=12,
            margin_end=12,
            margin_top=12,
            margin_bottom=12,
        )

        # Info group
        info_group = Adw.PreferencesGroup(title=_("Channel Info"))

        if channel.is_public_channel:
            name_entry = Adw.EntryRow(title=_("Name"))
            name_entry.set_text(channel.name)
            name_entry.set_show_apply_button(True)

            def _on_rename_apply(*args):
                new_name = name_entry.get_text().strip()
                if new_name:
                    self._window.set_channel(channel.index, new_name, channel.psk)

            name_entry.connect("apply", _on_rename_apply)
            info_group.add(name_entry)
        else:
            info_group.add(
                Adw.ActionRow(
                    title=_("Name"),
                    subtitle=channel.name or _("Channel {}").format(channel.index),
                )
            )

        info_group.add(
            Adw.ActionRow(
                title=_("Type"),
                subtitle=channel.channel_type,
            )
        )

        # Private Key row with copy button (only for private channels)
        if not channel.is_public_channel and not channel.is_hashtag_channel:
            psk_row = Adw.ActionRow(
                title=_("Private Key"),
                subtitle=channel.psk_hex,
            )
            psk_row.set_subtitle_selectable(True)
            copy_btn = Gtk.Button(icon_name="edit-copy-symbolic", valign=Gtk.Align.CENTER)
            copy_btn.add_css_class("flat")
            copy_btn.set_tooltip_text("Copy private key to clipboard")

            def _on_copy_psk(*args):
                clipboard = self._window.get_clipboard()
                clipboard.set(channel.psk_hex)
                self._window.show_toast(_("Private key copied to clipboard"), timeout=2)

            copy_btn.connect("clicked", _on_copy_psk)
            psk_row.add_suffix(copy_btn)
            info_group.add(psk_row)

        content.append(info_group)

        # Notifications group
        notif_group = Adw.PreferencesGroup(title=_("Notifications"))

        notif_row = Adw.ComboRow(title=_("Notification Level"))
        notif_list = Gtk.StringList()
        for label in [_("Default"), _("All Messages"), _("Mentions Only"), _("None")]:
            notif_list.append(label)
        notif_row.set_model(notif_list)

        level_to_index = {
            NotificationLevel.DEFAULT: 0,
            NotificationLevel.ALL: 1,
            NotificationLevel.MENTIONS: 2,
            NotificationLevel.NONE: 3,
        }
        notif_row.set_selected(level_to_index.get(channel.notification_level, 0))

        def _on_notif_changed(*args):
            selected = notif_row.get_selected()
            level = [
                NotificationLevel.DEFAULT,
                NotificationLevel.ALL,
                NotificationLevel.MENTIONS,
                NotificationLevel.NONE,
            ][selected]
            self._window.set_channel_notification_level(channel, level)

        notif_row.connect("notify::selected", _on_notif_changed)
        notif_group.add(notif_row)

        content.append(notif_group)

        # Region group
        available_regions = self._window.get_regions()
        region_group = Adw.PreferencesGroup(
            title=_("Region"),
            description=_("Limit flood messages to a specific region"),
        )

        if available_regions:
            region_row = Adw.ComboRow(title=_("Region"))
            region_list = Gtk.StringList()
            region_list.append(_("None"))
            for r in available_regions:
                region_list.append(r)
            region_row.set_model(region_list)

            if channel.region in available_regions:
                region_row.set_selected(available_regions.index(channel.region) + 1)
            else:
                region_row.set_selected(0)

            def _on_region_changed(*args):
                selected = region_row.get_selected()
                new_region = available_regions[selected - 1] if selected > 0 else ""
                channel.region = new_region
                if self._window.storage:
                    self._window.storage.set_channel_region(channel.index, new_region)
                self.update_channels(self._window._channels)

            region_row.connect("notify::selected", _on_region_changed)
            region_group.add(region_row)
        else:
            hint_row = Adw.ActionRow(
                title=_("No regions defined"),
                subtitle=_("Add regions in Settings to assign them to channels"),
            )
            hint_row.add_suffix(Gtk.Image(icon_name="go-next-symbolic"))
            hint_row.set_activatable(True)

            def _on_hint_activated(*args):
                dialog.connect(
                    "closed", lambda *a: self._window._nav_buttons["settings"].set_active(True)
                )
                dialog.close()

            hint_row.connect("activated", _on_hint_activated)
            region_group.add(hint_row)

        content.append(region_group)

        # Actions group
        actions_group = Adw.PreferencesGroup(title=_("Actions"))

        remove_row = Adw.ActionRow(title=_("Remove Channel"))
        remove_btn = Gtk.Button(icon_name="user-trash-symbolic", valign=Gtk.Align.CENTER)
        remove_btn.add_css_class("destructive-action")

        def _on_remove(*args):
            confirm = Adw.AlertDialog(
                heading=_("Remove Channel?"),
                body=_('Remove "{}" and all its messages?').format(
                    channel.name or _("Channel {}").format(channel.index)
                ),
            )
            confirm.add_response("cancel", _("Cancel"))
            confirm.add_response("remove", _("Remove"))
            confirm.set_response_appearance("remove", Adw.ResponseAppearance.DESTRUCTIVE)
            confirm.connect(
                "response",
                lambda d, r: (self._window.remove_channel(channel), dialog.close())
                if r == "remove"
                else None,
            )
            confirm.present(self._window)

        remove_btn.connect("clicked", _on_remove)
        remove_row.add_suffix(remove_btn)
        remove_row.set_activatable_widget(remove_btn)
        actions_group.add(remove_row)

        content.append(actions_group)

        clamp.set_child(content)
        scroll.set_child(clamp)
        toolbar_view.set_content(scroll)
        dialog.set_child(toolbar_view)
        dialog.present(self._window)

    def _find_next_empty_index(self) -> int | None:
        """Find the next available channel index (0-39)."""
        used = {ch.index for ch in self._window.channels if not ch.is_empty}
        for i in range(40):
            if i not in used:
                return i
        return None

    def _show_no_slots_dialog(self):
        dialog = Adw.AlertDialog(
            heading=_("No Slots Available"),
            body=_("All 8 channel slots are in use."),
        )
        dialog.add_response("ok", _("OK"))
        dialog.present(self._window)

    def _on_create_private_channel(self, *args):
        idx = self._find_next_empty_index()
        if idx is None:
            self._show_no_slots_dialog()
            return

        dialog = Adw.AlertDialog(
            heading=_("Create a Private Channel"),
            body=_("A random PSK will be generated. Share it with others so they can join."),
        )
        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        name_row = Adw.EntryRow(title=_("Channel Name"))
        fields_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
        fields_list.add_css_class("boxed-list")
        fields_list.append(name_row)
        content.append(fields_list)
        dialog.set_extra_child(content)
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("create", _("Create"))
        dialog.set_response_appearance("create", Adw.ResponseAppearance.SUGGESTED)

        def _on_response(d, response):
            if response == "create":
                import os

                name = name_row.get_text().strip()
                if not name:
                    return
                psk = os.urandom(16)
                self._window.set_channel(idx, name, psk)

        dialog.connect("response", _on_response)
        dialog.present(self._window)

    def _on_join_private_channel(self, *args):
        idx = self._find_next_empty_index()
        if idx is None:
            self._show_no_slots_dialog()
            return

        dialog = Adw.AlertDialog(
            heading=_("Join a Private Channel"),
            body=_("Enter the channel name and private key shared with you."),
        )
        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        name_row = Adw.EntryRow(title=_("Channel Name"))
        psk_row = Adw.EntryRow(title=_("Private Key (32 hex characters)"))
        fields_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
        fields_list.add_css_class("boxed-list")
        fields_list.append(name_row)
        fields_list.append(psk_row)
        content.append(fields_list)
        dialog.set_extra_child(content)
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("join", _("Join"))
        dialog.set_response_appearance("join", Adw.ResponseAppearance.SUGGESTED)

        def _on_response(d, response):
            if response == "join":
                try:
                    name = name_row.get_text().strip()
                    psk = bytes.fromhex(psk_row.get_text().strip())
                    if len(psk) == 16 and name:
                        self._window.set_channel(idx, name, psk)
                except ValueError:
                    pass

        dialog.connect("response", _on_response)
        dialog.present(self._window)

    def _on_join_public_channel(self, *args):
        # Check if already on the public channel
        for ch in self._window.channels:
            if ch.is_public_channel:
                dialog = Adw.AlertDialog(
                    heading=_("Already Joined"), body=_("You are already on the public channel.")
                )
                dialog.add_response("ok", _("OK"))
                dialog.present(self._window)
                return

        idx = self._find_next_empty_index()
        if idx is None:
            self._show_no_slots_dialog()
            return

        psk = bytes.fromhex(Channel.PUBLIC_CHANNEL_PSK)
        self._window.set_channel(idx, _("Public"), psk)

    def _on_join_hashtag_channel(self, *args):
        idx = self._find_next_empty_index()
        if idx is None:
            self._show_no_slots_dialog()
            return

        dialog = Adw.AlertDialog(
            heading=_("Join a Hashtag Channel"),
            body=_("Enter a hashtag name. Anyone using the same hashtag can communicate."),
        )
        content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        hashtag_row = Adw.EntryRow(title=_("Hashtag (e.g. #general)"))
        fields_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
        fields_list.add_css_class("boxed-list")
        fields_list.append(hashtag_row)
        content.append(fields_list)
        dialog.set_extra_child(content)
        dialog.add_response("cancel", _("Cancel"))
        dialog.add_response("join", _("Join"))
        dialog.set_response_appearance("join", Adw.ResponseAppearance.SUGGESTED)

        def _on_response(d, response):
            if response == "join":
                tag = hashtag_row.get_text().strip()
                if not tag:
                    return
                if not tag.startswith("#"):
                    tag = f"#{tag}"
                h = hashlib.sha256(tag.encode()).digest()
                psk = h[:16]
                name = tag
                self._window.set_channel(idx, name, psk)

        dialog.connect("response", _on_response)
        dialog.present(self._window)
