view libervia/tui/contact_list.py @ 4202:b26339343076

core: use a user specific directory for PID file: default location of pid file is now specific to logged user, this allow to run several instances of Libervia by different users on the same machine without PID conflicts.
author Goffi <goffi@goffi.org>
date Sun, 14 Jan 2024 17:48:02 +0100
parents b47f21f2b8fa
children 0d7bb4df2343
line wrap: on
line source

#!/usr/bin/env python3


# Libervia TUI
# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

from libervia.backend.core.i18n import _
import urwid
from urwid_satext import sat_widgets
from libervia.frontends.quick_frontend.quick_contact_list import QuickContactList
from libervia.tui.status import StatusBar
from libervia.tui.constants import Const as C
from libervia.tui.keys import action_key_map as a_key
from libervia.tui.widget import LiberviaTUIWidget
from libervia.frontends.tools import jid
from libervia.backend.core import log as logging

log = logging.getLogger(__name__)
from libervia.frontends.quick_frontend import quick_widgets


class ContactList(LiberviaTUIWidget, QuickContactList):
    PROFILES_MULTIPLE = False
    PROFILES_ALLOW_NONE = False
    signals = ["click", "change"]
    # FIXME: Only single profile is managed so far

    def __init__(
        self, host, target, on_click=None, on_change=None, user_data=None, profiles=None
    ):
        QuickContactList.__init__(self, host, profiles)
        self.contact_list = self.host.contact_lists[self.profile]

        # we now build the widget
        self.status_bar = StatusBar(host)
        self.frame = sat_widgets.FocusFrame(self._build_list(), None, self.status_bar)
        LiberviaTUIWidget.__init__(self, self.frame, _("Contacts"))
        if on_click:
            urwid.connect_signal(self, "click", on_click, user_data)
        if on_change:
            urwid.connect_signal(self, "change", on_change, user_data)
        self.host.addListener("notification", self.on_notification, [self.profile])
        self.host.addListener("notificationsClear", self.on_notification, [self.profile])
        self.post_init()

    def update(self, entities=None, type_=None, profile=None):
        """Update display, keep focus"""
        # FIXME: full update is done each time, must handle entities, type_ and profile
        widget, position = self.frame.body.get_focus()
        self.frame.body = self._build_list()
        if position:
            try:
                self.frame.body.focus_position = position
            except IndexError:
                pass
        self._invalidate()
        self.host.redraw()  # FIXME: check if can be avoided

    def keypress(self, size, key):
        # FIXME: we have a temporary behaviour here: FOCUS_SWITCH change focus globally in the parent,
        #        and FOCUS_UP/DOWN is transwmitter to parent if we are respectively on the first or last element
        if key in sat_widgets.FOCUS_KEYS:
            if (
                key == a_key["FOCUS_SWITCH"]
                or (key == a_key["FOCUS_UP"] and self.frame.focus_position == "body")
                or (key == a_key["FOCUS_DOWN"] and self.frame.focus_position == "footer")
            ):
                return key
        if key == a_key["STATUS_HIDE"]:  # user wants to (un)hide contacts' statuses
            self.contact_list.show_status = not self.contact_list.show_status
            self.update()
        elif (
            key == a_key["DISCONNECTED_HIDE"]
        ):  # user wants to (un)hide disconnected contacts
            self.host.bridge.param_set(
                C.SHOW_OFFLINE_CONTACTS,
                C.bool_const(not self.contact_list.show_disconnected),
                "General",
                profile_key=self.profile,
            )
        elif key == a_key["RESOURCES_HIDE"]:  # user wants to (un)hide contacts resources
            self.contact_list.show_resources = not self.contact_list.show_resources
            self.update()
        return super(ContactList, self).keypress(size, key)

    # QuickWidget methods

    @staticmethod
    def get_widget_hash(target, profiles):
        profiles = sorted(profiles)
        return tuple(profiles)

    # modify the contact list

    def set_focus(self, text, select=False):
        """give focus to the first element that matches the given text. You can also
        pass in text a libervia.frontends.tools.jid.JID (it's a subclass of unicode).

        @param text: contact group name, contact or muc userhost, muc private dialog jid
        @param select: if True, the element is also clicked
        """
        idx = 0
        for widget in self.frame.body.body:
            try:
                if isinstance(widget, sat_widgets.ClickableText):
                    # contact group
                    value = widget.get_value()
                elif isinstance(widget, sat_widgets.SelectableText):
                    # contact or muc
                    value = widget.data
                else:
                    # Divider instance
                    continue
                # there's sometimes a leading space
                if text.strip() == value.strip():
                    self.frame.body.focus_position = idx
                    if select:
                        self._contact_clicked(False, widget, True)
                    return
            except AttributeError:
                pass
            idx += 1

        log.debug("Not element found for {} in set_focus".format(text))

    # events

    def _group_clicked(self, group_wid):
        group = group_wid.get_value()
        data = self.contact_list.get_group_data(group)
        data[C.GROUP_DATA_FOLDED] = not data.setdefault(C.GROUP_DATA_FOLDED, False)
        self.set_focus(group)
        self.update()

    def _contact_clicked(self, use_bare_jid, contact_wid, selected):
        """Method called when a contact is clicked

        @param use_bare_jid: True if use_bare_jid is set in self._build_entity_widget.
        @param contact_wid: widget of the contact, must have the entity set in data attribute
        @param selected: boolean returned by the widget, telling if it is selected
        """
        entity = contact_wid.data
        self.host.mode_hint(C.MODE_INSERTION)
        self._emit("click", entity)

    def on_notification(self, entity, notif, profile):
        notifs = list(self.host.get_notifs(C.ENTITY_ALL, profile=self.profile))
        if notifs:
            self.title_dynamic = "({})".format(len(notifs))
        else:
            self.title_dynamic = None
        self.host.redraw()  # FIXME: should not be necessary

    # Methods to build the widget

    def _build_entity_widget(
        self,
        entity,
        keys=None,
        use_bare_jid=False,
        with_notifs=True,
        with_show_attr=True,
        markup_prepend=None,
        markup_append=None,
        special=False,
    ):
        """Build one contact markup data

        @param entity (jid.JID): entity to build
        @param keys (iterable): value to markup, in preferred order.
            The first available key will be used.
            If key starts with "cache_", it will be checked in cache,
            else, getattr will be done on entity with the key (e.g. getattr(entity, 'node')).
            If nothing full or keys is None, full entity is used.
        @param use_bare_jid (bool): if True, use bare jid for selected comparisons
        @param with_notifs (bool): if True, show notification count
        @param with_show_attr (bool): if True, show color corresponding to presence status
        @param markup_prepend (list): markup to prepend to the generated one before building the widget
        @param markup_append (list): markup to append to the generated one before building the widget
        @param special (bool): True if entity is a special one
        @return (list): markup data are expected by Urwid text widgets
        """
        markup = []
        if use_bare_jid:
            selected = {entity.bare for entity in self.contact_list._selected}
        else:
            selected = self.contact_list._selected
        if keys is None:
            entity_txt = entity
        else:
            cache = self.contact_list.getCache(entity)
            for key in keys:
                if key.startswith("cache_"):
                    entity_txt = cache.get(key[6:])
                else:
                    entity_txt = getattr(entity, key)
                if entity_txt:
                    break
            if not entity_txt:
                entity_txt = entity

        if with_show_attr:
            show = self.contact_list.getCache(entity, C.PRESENCE_SHOW, default=None)
            if show is None:
                show = C.PRESENCE_UNAVAILABLE
            show_icon, entity_attr = C.PRESENCE.get(show, ("", "default"))
            markup.insert(0, "{} ".format(show_icon))
        else:
            entity_attr = "default"

        notifs = list(
            self.host.get_notifs(entity, exact_jid=special, profile=self.profile)
        )
        mentions = list(
                self.host.get_notifs(entity.bare, C.NOTIFY_MENTION, profile=self.profile)
            )
        if notifs or mentions:
            attr = 'cl_mention' if mentions else 'cl_notifs'
            header = [(attr, "({})".format(len(notifs) + len(mentions))), " "]
        else:
            header = ""

        markup.append((entity_attr, entity_txt))
        if markup_prepend:
            markup.insert(0, markup_prepend)
        if markup_append:
            markup.extend(markup_append)

        widget = sat_widgets.SelectableText(
            markup, selected=entity in selected, header=header
        )
        widget.data = entity
        widget.comp = entity_txt.lower()  # value to use for sorting
        urwid.connect_signal(
            widget, "change", self._contact_clicked, user_args=[use_bare_jid]
        )
        return widget

    def _build_entities(self, content, entities):
        """Add entity representation in widget list

        @param content: widget list, e.g. SimpleListWalker
        @param entities (iterable): iterable of JID to display
        """
        if not entities:
            return
        widgets = []  # list of built widgets

        for entity in entities:
            if (
                entity in self.contact_list._specials
                or not self.contact_list.entity_visible(entity)
            ):
                continue
            markup_extra = []
            if self.contact_list.show_resources:
                for resource in self.contact_list.getCache(entity, C.CONTACT_RESOURCES):
                    resource_disp = (
                        "resource_main"
                        if resource
                        == self.contact_list.getCache(entity, C.CONTACT_MAIN_RESOURCE)
                        else "resource",
                        "\n  " + resource,
                    )
                    markup_extra.append(resource_disp)
                    if self.contact_list.show_status:
                        status = self.contact_list.getCache(
                            jid.JID("%s/%s" % (entity, resource)), "status", default=None
                        )
                        status_disp = ("status", "\n    " + status) if status else ""
                        markup_extra.append(status_disp)

            else:
                if self.contact_list.show_status:
                    status = self.contact_list.getCache(entity, "status", default=None)
                    status_disp = ("status", "\n  " + status) if status else ""
                    markup_extra.append(status_disp)
            widget = self._build_entity_widget(
                entity,
                ("cache_nick", "cache_name", "node"),
                use_bare_jid=True,
                markup_append=markup_extra,
            )
            widgets.append(widget)

        widgets.sort(key=lambda widget: widget.comp)

        for widget in widgets:
            content.append(widget)

    def _build_specials(self, content):
        """Build the special entities"""
        specials = sorted(self.contact_list.get_specials())
        current = None
        for entity in specials:
            if current is not None and current.bare == entity.bare:
                # nested entity (e.g. MUC private conversations)
                widget = self._build_entity_widget(
                    entity, ("resource",), markup_prepend="  ", special=True
                )
            else:
                # the special widgets
                if entity.resource:
                    widget = self._build_entity_widget(entity, ("resource",), special=True)
                else:
                    widget = self._build_entity_widget(
                        entity,
                        ("cache_nick", "cache_name", "node"),
                        with_show_attr=False,
                        special=True,
                    )
            content.append(widget)

    def _build_list(self):
        """Build the main contact list widget"""
        content = urwid.SimpleListWalker([])

        self._build_specials(content)
        if self.contact_list._specials:
            content.append(urwid.Divider("="))

        groups = list(self.contact_list._groups)
        groups.sort(key=lambda x: x.lower() if x else '')
        for group in groups:
            data = self.contact_list.get_group_data(group)
            folded = data.get(C.GROUP_DATA_FOLDED, False)
            jids = list(data["jids"])
            if group is not None and (
                self.contact_list.any_entity_visible(jids)
                or self.contact_list.show_empty_groups
            ):
                header = "[-]" if not folded else "[+]"
                widget = sat_widgets.ClickableText(group, header=header + " ")
                content.append(widget)
                urwid.connect_signal(widget, "click", self._group_clicked)
            if not folded:
                self._build_entities(content, jids)
        not_in_roster = (
            set(self.contact_list._cache)
            .difference(self.contact_list._roster)
            .difference(self.contact_list._specials)
            .difference((self.contact_list.whoami.bare,))
        )
        if not_in_roster:
            content.append(urwid.Divider("-"))
            self._build_entities(content, not_in_roster)

        return urwid.ListBox(content)


quick_widgets.register(QuickContactList, ContactList)