# Copyright (C) 2009-2011 AG Projects. See LICENSE for details.

from AppKit import (NSAccessibilityUnignoredDescendant,
                    NSAccessibilityDescriptionAttribute,
                    NSAccessibilityChildrenAttribute,
                    NSAccessibilityRoleDescriptionAttribute,
                    NSAlertAlternateReturn,
                    NSAlertDefaultReturn,
                    NSApp,
                    NSCompositeSourceOver,
                    NSDragOperationMove,
                    NSDragOperationCopy,
                    NSDragOperationNone,
                    NSDragOperationAll,
                    NSFilenamesPboardType,
                    NSFloatingWindowLevel,
                    NSFontAttributeName,
                    NSForegroundColorAttributeName,
                    NSGetInformationalAlertPanel,
                    NSKeyDown,
                    NSLeftMouseUp,
                    NSLineBreakByTruncatingTail,
                    NSModalPanelRunLoopMode,
                    NSNormalWindowLevel,
                    NSOnState,
                    NSOffState,
                    NSRightMouseUp,
                    NSOutlineViewSelectionDidChangeNotification,
                    NSParagraphStyleAttributeName,
                    NSPNGFileType,
                    NSReleaseAlertPanel,
                    NSRunAlertPanel,
                    NSRunContinuesResponse,
                    NSStringPboardType,
                    NSSplitViewDidResizeSubviewsNotification,
                    NSTableViewSelectionDidChangeNotification,
                    NSTableViewDropAbove,
                    NSTableViewStylePlain,
                    NSFocusRingTypeNone,
                    NSVariableStatusItemLength,
                    NSView,
                    NSViewHeightSizable,
                    NSViewWidthSizable,
                    NSStatusBar)

# Which side a drawer hangs off. The constant moved between PyObjC's AppKit
# and Foundation over the years, so it is looked up rather than imported and
# falls back to the NSRectEdge values themselves, which have never changed.
try:
    from AppKit import NSMinXEdge, NSMaxXEdge
except ImportError:  # pragma: no cover -- older PyObjC
    try:
        from Foundation import NSMinXEdge, NSMaxXEdge
    except ImportError:
        NSMinXEdge, NSMaxXEdge = 0, 2

from Foundation import (NSArray,
                        NSAttributedString,
                        NSBezierPath,
                        NSBundle,
                        NSBitmapImageRep,
                        NSColor,
                        NSDate,
                        NSDefaultRunLoopMode,
                        NSDictionary,
                        NSEvent,
                        NSFont,
                        NSHeight,
                        NSImage,
                        NSImageView,
                        NSIndexSet,
                        NSMakeSize,
                        NSMenu,
                        NSMenuItem,
                        NSMaxX,
                        NSMinX,
                        NSMinY,
                        NSMutableAttributedString,
                        NSMakeRange,
                        NSMakeRect,
                        NSMutableIndexSet,
                        NSNotFound,
                        NSNotificationCenter,
                        NSParagraphStyle,
                        NSPasteboard,
                        NSRunLoop,
                        NSScreen,
                        NSSpeechSynthesizer,
                        NSString,
                        NSLocalizedString,
                        NSTimer,
                        NSURL,
                        NSUserDefaults,
                        NSWindowController,
                        NSWorkspace,
                        NSZeroRect)
import objc
import Contacts

from ClamshellMonitor import ClamshellMonitor
from AVFoundation import AVCaptureDevice

import pickle
import datetime
import hashlib
import os
import re
import random

try:
    import ldap
except ImportError:
    from application.python import Null
    ldap = Null

import shutil
import string
import sys

import uuid
import time


from collections import deque
from dateutil.tz import tzlocal
from itertools import chain

from application.notification import NotificationCenter, IObserver, NotificationData
from application.python import Null
from application.system import unlink, makedirs, host

from sipsimple.account import AccountManager, Account, BonjourAccount
from sipsimple.addressbook import AddressbookManager, Contact, ContactURI, Policy, unique_id
from sipsimple.application import SIPApplication
from sipsimple.audio import AudioConference, WavePlayer
from sipsimple.configuration.settings import SIPSimpleSettings
from sipsimple.core import SIPURI, SIPCoreError
from sipsimple.util import ISOTimestamp
from sipsimple.threading import run_in_thread
from sipsimple.threading.green import run_in_green_thread
from operator import attrgetter
from twisted.internet import reactor, task
from zope.interface import implementer

from LaunchServices import LSFindApplicationForInfo, kLSUnknownCreator

import ContactOutlineView  # this is used from the UI
import ListView            # this is used from the UI
import SMSWindowManager
import KeyEscrow

from AccountSettings import AccountSettings
from AlertPanel import AlertPanel
from AudioSession import AudioSession
from BlinkLogger import BlinkLogger
from Avatars import NO_PHOTO_AVATAR, draw_avatar
from HistoryManager import SessionHistory
from HistoryViewer import HistoryViewer
from ContactCell import ContactCell  # this is used from the UI
from ContactListModel import presence_status_for_contact, BlinkContact, BlinkBlockedPresenceContact, BonjourBlinkContact, BlinkConferenceContact, BlinkPresenceContact, BlinkGroup, AllContactsBlinkGroupBlinkPresenceContact
from ContactListModel import BlinkPendingWatcher, LdapSearchResultContact, HistoryBlinkContact, VoicemailBlinkContact, SearchResultContact, SystemAddressBookBlinkContact, Avatar
from ContactListModel import DefaultUserAvatar, DefaultMultiUserAvatar, ICON_SIZE, HistoryBlinkGroup, MissedCallsBlinkGroup, IncomingCallsBlinkGroup, OutgoingCallsBlinkGroup, OnlineGroup
from ContactListModel import bonjour_offline_uri
from MediaStream import STREAM_CONNECTED, STREAM_RINGING, STREAM_PROPOSING
from EnrollmentController import EnrollmentController
from FileTransferWindowController import openFileTransferSelectionDialog, FileTransferWindowController
from ConferenceController import random_room, default_conference_server, JoinConferenceWindowController, AddParticipantsWindowController
from PresenceInfoController import PresenceInfoController
from SessionController import SessionControllersManager
from SIPManager import MWIData
from PhotoPicker import PhotoPicker
from PresencePublisher import PresencePublisher, PresenceActivityList, on_the_phone_activity
from OfflineNoteController import OfflineNoteController
from MyVideoWindowController import MyVideoWindowController
from VideoMirrorController import VideoMirrorController
from configuration.datatypes import UserIcon
from resources import ApplicationData, Resources
from util import allocate_autorelease_pool, format_date, format_identity_to_string, format_uri_type, is_anonymous, is_sip_aor_format, normalize_sip_uri_for_outgoing_session
from util import pstn_uri_spellings_for_accounts
from HistoryManager import block_contacts, unblock_contacts, _blocked_group
from util import log_gui_exception, run_in_gui_thread, sip_prefix_pattern, sipuri_components_from_string, translate_alpha2digit, AccountInfo, utc_to_local


PARTICIPANTS_MENU_ADD_CONFERENCE_CONTACT = 314
PARTICIPANTS_MENU_ADD_CONTACT = 301
PARTICIPANTS_MENU_REMOVE_FROM_CONFERENCE = 310
PARTICIPANTS_MENU_MUTE = 315
PARTICIPANTS_MENU_INVITE_TO_CONFERENCE = 312
PARTICIPANTS_MENU_GOTO_CONFERENCE_WEBSITE = 313
PARTICIPANTS_MENU_START_AUDIO_SESSION = 320
PARTICIPANTS_MENU_START_CHAT_SESSION = 321
PARTICIPANTS_MENU_START_VIDEO_SESSION = 322
PARTICIPANTS_MENU_SEND_FILES = 323

normal_font_color = NSDictionary.dictionaryWithObjectsAndKeys_(NSFont.systemFontOfSize_(NSFont.systemFontSize()), NSFontAttributeName)

gray_font_color = NSDictionary.dictionaryWithObjectsAndKeys_(NSFont.systemFontOfSize_(10), NSFontAttributeName, NSColor.grayColor(), NSForegroundColorAttributeName)
red_font_color = NSDictionary.dictionaryWithObjectsAndKeys_(NSFont.systemFontOfSize_(10), NSFontAttributeName, NSColor.redColor(), NSForegroundColorAttributeName)
mini_blue = NSDictionary.dictionaryWithObjectsAndKeys_(NSFont.systemFontOfSize_(10), NSFontAttributeName, NSColor.alternateSelectedControlColor(), NSForegroundColorAttributeName)

session_status_localized = {
    'missed':    NSLocalizedString("missed", "Label"),
    'completed': NSLocalizedString("completed", "Label"),
    'failed':    NSLocalizedString("failed", "Label"),
    'cancelled': NSLocalizedString("cancelled", "Label")
}


REGISTER_TRANSIENT_CODES = frozenset((408, 423, 503))


class PhotoView(NSImageView):
    """My own avatar, at the top of the main window.

    A circle, the same one the contact list draws for everybody else -- it
    used to be a rounded rectangle, and the picture was stretched to fill it
    rather than cropped, so a photograph that was not square came out
    squashed. With no picture set it shows my initials on a colour of their
    own instead of the grey person glyph, again like every other row.
    """

    entered = False
    callback = None
    # Whether the image is a photograph or the stand-in, and the name the
    # initials come from while there is no photograph. Both are set by the
    # window controller, which is the only thing that knows either.
    hasPhoto = False
    avatarName = ''

    def mouseDown_(self, event):
        self.callback(self)

    def mouseEntered_(self, event):
        self.entered = True
        self.setNeedsDisplay_(True)

    def mouseExited_(self, event):
        self.entered = False
        self.setNeedsDisplay_(True)

    def updateTrackingAreas(self):
        rect = NSZeroRect
        rect.size = self.frame().size
        self.addTrackingRect_owner_userData_assumeInside_(rect, self, None, False)

    def drawRect_(self, rect):
        try:
            bounds = self.bounds()
            side = min(bounds.size.width, bounds.size.height)
            square = NSMakeRect(bounds.origin.x + (bounds.size.width - side) / 2.0,
                                bounds.origin.y + (bounds.size.height - side) / 2.0,
                                side, side)
            circle = NSBezierPath.bezierPathWithOvalInRect_(square)
            image = self.image() if self.hasPhoto else None
            if image is not None:
                # White behind the photograph, as before: a PNG with
                # transparency in it should not show the window through.
                NSColor.whiteColor().set()
                circle.fill()
            if image is None and not self.avatarName:
                # Before the first account has loaded there is neither a
                # picture nor a name: an empty disc, not a circle with a
                # question mark in it.
                NSColor.grayColor().colorWithAlphaComponent_(0.15).set()
                circle.fill()
            else:
                draw_avatar(square, image, self.avatarName)
            if self.entered:
                # The same darkening on hover that said "this is a button",
                # following the new shape.
                NSColor.blackColor().colorWithAlphaComponent_(0.35).set()
                circle.fill()
        except Exception as e:
            BlinkLogger().log_error('Cannot draw my own avatar: %s' % e)


class _DeletedGroupMenuPolicy(object):
    # Stands in for the Deleted group where the row menu asks what the group
    # allows: Delete, Delete duplicates and Remove From Group do not apply
    # there -- Restore and Delete Permanently are offered instead.
    delete_contact_allowed = False
    remove_contact_allowed = False


@implementer(IObserver)
class ContactWindowController(NSWindowController):

    accounts = []
    model = objc.IBOutlet()
    backend = None
    loggerModel = None
    participants = []
    pendingAttachments = None
    pendingConversationOpen = None
    conversationOpenRetries = None

    # Notification-storm dampers. Bonjour neighbour adds, account
    # registrations and presence updates can each post BlinkContactsHaveChanged
    # within a few ms of each other; without coalescing the GUI thread runs
    # refreshContactsList (full NSOutlineView reloadData) per notification,
    # which adds ~50–130 ms each and was driving startup beachballs.
    # _contacts_refresh_timer is the pending NSTimer (None when no flush
    # is queued); _contacts_dirty_senders accumulates the union of senders
    # so the flush callback knows whether to do a full reload or a single
    # subtree reload.
    _contacts_refresh_timer = None
    _contacts_dirty_senders = None

    searchResultsModel = objc.IBOutlet()
    fileTransfersWindow = None

    loaded = False
    collapsedState = False
    originalSize = None
    originalWindowPosition = None
    accountSettingsPanels = {}

    authFailPopupShown = False

    alertPanel = None

    presenceActivityBeforeOnThePhone = None
    disbandingConference = False

    toolTipView = objc.IBOutlet()
    contactsScrollView = objc.IBOutlet()
    drawer = objc.IBOutlet()
    mainTabView = objc.IBOutlet()
    drawerSplitView = objc.IBOutlet()
    dialPadView = objc.IBOutlet()
    participantsView = objc.IBOutlet()
    participantsTableView = objc.IBOutlet()
    participantMenu = objc.IBOutlet()
    sessionsView = objc.IBOutlet()
    audioSessionsListView = objc.IBOutlet()
    messagesDrawerView = objc.IBOutlet()
    drawerBottomBar = objc.IBOutlet()
    drawerSplitterPosition = None
    # The messages pane lives in a split view inside the main window, not in
    # the drawer. An NSDrawer cannot be wider than the window it hangs off,
    # so a wide transcript dragged the contact list wide with it; a split
    # view lets the transcript be any width while the list stays narrow.
    CONTACT_LIST_MIN_WIDTH = 274.0
    messagePaneSplitView = None
    messagePaneListSide = None
    _message_pane_visible = False
    messagePaneController = None
    # The drawer is drawn OUTSIDE the window, so the room it needs is screen
    # space beside the window rather than part of it. With the transcript
    # open the window can already reach the screen edge, and then the drawer
    # has nowhere to go. Set while the window is being fitted for it, so the
    # narrowing done to make room is not mistaken for the user resizing the
    # transcript and remembered as their preferred width.
    _fitting_window_for_drawer = False
    _width_before_audio_drawer = None

    searchBox = objc.IBOutlet()
    accountPopUp = objc.IBOutlet()
    contactOutline = objc.IBOutlet()
    groupMenu = objc.IBOutlet()
    actionButtons = objc.IBOutlet()
    actionButtonsNoVideo = objc.IBOutlet()
    addContactButton = objc.IBOutlet()
    groupButton = objc.IBOutlet()
    addContactButtonSearch = objc.IBOutlet()
    addContactButtonDialPad = objc.IBOutlet()
    conferenceButton = objc.IBOutlet()

    contactContextMenu = objc.IBOutlet()

    photoImage = objc.IBOutlet()
    presenceActivityPopUp = objc.IBOutlet()
    presenceNoteText = objc.IBOutlet()
    nameText = objc.IBOutlet()

    muteButton = objc.IBOutlet()
    silentButton = objc.IBOutlet()

    searchOutline = objc.IBOutlet()
    notFoundText = objc.IBOutlet()
    notFoundTextOffset = None
    searchOutlineTopOffset = None

    addContactToConferenceDialPad = objc.IBOutlet()

    blinkMenu = objc.IBOutlet()
    historyMenu = objc.IBOutlet()
    recordingsSubMenu = objc.IBOutlet()
    recordingsMenu = objc.IBOutlet()
    contactsMenu = objc.IBOutlet()
    devicesMenu = objc.IBOutlet()
    callMenu = objc.IBOutlet()
    presenceMenu = objc.IBOutlet()
    presenceWatchersMenu = objc.IBOutlet()
    presencePopUpMenu = objc.IBOutlet()
    windowMenu = objc.IBOutlet()
    restoreContactsMenu = objc.IBOutlet()
    alwaysOnTopMenuItem = objc.IBOutlet()
    useSpeechRecognitionMenuItem = objc.IBOutlet()
    useSpeechSynthesisMenuItem = objc.IBOutlet()
    myvideoMenuItem = objc.IBOutlet()
    videoView = objc.IBOutlet()

    chatMenu = objc.IBOutlet()
    screenShareMenu = objc.IBOutlet()

    historyViewer = None

    searchInfoAttrs = NSDictionary.dictionaryWithObjectsAndKeys_(
                    NSFont.systemFontOfSize_(NSFont.labelFontSize()), NSFontAttributeName,
                    NSColor.grayColor(), NSForegroundColorAttributeName)

    conference = None
    joinConferenceWindow = None
    addParticipantsWindow = None

    silence_player = None
    ldap_directory = None
    ldap_search = None
    ldap_found_contacts = []
    local_found_contacts = []
    sessionControllersManager = None
    presence_notes_history = deque(maxlen=6)
    first_run = False
    presencePublisher = None
    white = None
    presenceInfoPanel = None
    tellMeWhenContactBecomesAvailableList = set()

    statusbar = NSStatusBar.systemStatusBar()
    statusBarMenu = objc.IBOutlet()
    speech_synthesizer = None
    speech_synthesizer_active = False
    scheduled_conferences = set()
    my_device_is_active = True
    sync_presence_at_start = False
    new_audio_sample_rate = None
    last_status_per_device = {}
    created_accounts = set()
    purge_presence_timer = None
    full_screen_in_progress = False
    myvideo = None
    refresh_drawer_counter = 1
    last_failure_reason = None
    ready = False

    @property
    def has_audio(self):
        has_audio = False
        for v in self.audioSessionsListView.subviews():
            if v.delegate is not None and v.delegate.sessionController is not None and v.delegate.sessionController.session is not None and v.delegate.sessionController.session.state in ('terminating', 'terminated'):
                continue
            else:
                has_audio = True
                break
        return has_audio

    def __del__(self):
        NSNotificationCenter.defaultCenter().removeObserver_(self)
    
    def awakeFromNib(self):
        BlinkLogger().log_debug('Starting Contact Manager')

        # The drawer is the audio call list and nothing else now. The
        # messages view left in MainWindow.xib is dead weight -- it stays
        # in the nib only so the six localised copies need no surgery -- so
        # make sure it never covers the audio stack.
        if self.messagesDrawerView is not None:
            self.messagesDrawerView.setHidden_(True)

        # journal download / import and background decryption, at the top of the contact list
        from JournalProgressStrip import JournalProgressStrip
        self.progressStrip = JournalProgressStrip.alloc().initWithScrollView_(self.contactsScrollView)

        # Before anything is on screen. The frame is autosaved and comes back
        # at the two-pane width, so narrowing it any later means the user
        # watches the window appear wide and then jump narrower.
        self.restoreContactListWidthAtLaunch()

        # Now that the list shares a window with the transcript, focus moves
        # between them and AppKit draws its blue ring around whichever holds
        # it. In a two-pane layout that ring is noise, not information.
        for view in (self.contactOutline, self.searchOutline):
            if view is None:
                continue
            try:
                view.setFocusRingType_(NSFocusRingTypeNone)
                scroll = view.enclosingScrollView()
                if scroll is not None:
                    scroll.setFocusRingType_(NSFocusRingTypeNone)
            except Exception as e:
                BlinkLogger().log_error('Cannot hide the contact list focus ring: %s' % e)

        # Shift extends the selection in the contact list. The model, as the
        # outline's delegate, keeps a multiple selection inside one group.
        if self.contactOutline is not None:
            self.contactOutline.setAllowsMultipleSelection_(True)

        def check_camera_permission():
            # Get camera permission status
            status = AVCaptureDevice.authorizationStatusForMediaType_("vide")

            # AVAuthorizationStatus: 0 NotDetermined, 1 Restricted, 2 Denied, 3 Authorized
            if status == 0:  # Not determined
                BlinkLogger().log_info("Camera access is not yet determined. Requesting access...")
                # Request camera access
                AVCaptureDevice.requestAccessForMediaType_completionHandler_("vide", lambda granted: BlinkLogger().log_info("Camera access granted!" if granted else "Camera access denied"))
            elif status == 1:  # Restricted (parental controls / MDM, user cannot grant)
                BlinkLogger().log_info("Camera access is restricted.")
            elif status == 2:  # Denied
                BlinkLogger().log_info("Camera access denied. Please enable it in System Settings.")
            elif status == 3:  # Authorized
                BlinkLogger().log_info("Camera access is granted.")
            else:
                BlinkLogger().log_info("Unknown camera access status.")
        
        def request_access_to_contacts():
            def handler(granted, error):
                if granted:
                    BlinkLogger().log_info("Access granted to Contacts")
                else:
                    BlinkLogger().log_info(f"Access denied: {error}")

            store.requestAccessForEntityType_completionHandler_(Contacts.CNEntityTypeContacts, handler)

            
        # Initialize the CNContactStore properly
        store = Contacts.CNContactStore.alloc().init()
        request_access_to_contacts()
        
        check_camera_permission()

        # check how much space there is left for the search Outline, so we can restore it after
        # minimizing
        self.searchOutlineTopOffset = NSHeight(self.searchOutline.enclosingScrollView().superview().frame()) - NSHeight(self.searchOutline.enclosingScrollView().frame())

        self.contactOutline.setRowHeight_(40)
        # macOS 11+ SDK defaults NSOutlineView to NSTableViewStyleAutomatic, which
        # renders any view implementing isGroupItem: as a source list and overrides
        # outlineView:heightOfRowByItem: for group rows (making group tiles full height).
        # Force the plain style so our 22px group title-bar height is honored again.
        if self.contactOutline.respondsToSelector_("setStyle:"):
            self.contactOutline.setStyle_(NSTableViewStylePlain)
        if self.searchOutline.respondsToSelector_("setStyle:"):
            self.searchOutline.setStyle_(NSTableViewStylePlain)
        self.contactOutline.setTarget_(self)
        self.contactOutline.setDoubleAction_("actionButtonClicked:")
        self.contactOutline.setDraggingSourceOperationMask_forLocal_(NSDragOperationMove, True)
        self.contactOutline.registerForDraggedTypes_(NSArray.arrayWithObjects_("dragged-contact", "x-blink-audio-session", NSFilenamesPboardType))

        self.searchOutline.setTarget_(self)
        self.searchOutline.setDoubleAction_("actionButtonClicked:")
        self.contactOutline.setDraggingSourceOperationMask_forLocal_(NSDragOperationCopy, True)
        self.searchOutline.registerForDraggedTypes_(NSArray.arrayWithObjects_("dragged-contact", "x-blink-audio-session", NSFilenamesPboardType))

        # work around for Lion that resizes the contact cell width bigger than its parent view
        self.contactOutline.setAutoresizesOutlineColumn_(False)
        self.searchOutline.setAutoresizesOutlineColumn_(False)

        self.chatMenu.setAutoenablesItems_(False)

        # save the position of this view, because when the window is collapsed
        # the position gets messed
        f = self.notFoundText.frame()
        self.notFoundTextOffset = NSHeight(self.notFoundText.superview().frame()) - NSMinY(f)

        self.audioSessionsListView.setSpacing_(0)

        self.participantsTableView.registerForDraggedTypes_(NSArray.arrayWithObject_("x-blink-sip-uri"))
        self.participantsTableView.setTarget_(self)
        self.participantsTableView.setDoubleAction_("doubleClickReceived:")

        nc = NotificationCenter()
        nc.add_observer(self, name="AudioDevicesDidChange")
        nc.add_observer(self, name="ActiveAudioSessionChanged")
        nc.add_observer(self, name="BlinkChatWindowClosed")
        nc.add_observer(self, name="BlinkVideoWindowClosed")
        nc.add_observer(self, name="BlinkConferenceGotUpdate")
        nc.add_observer(self, name="BlinkDidRenegotiateStreams")
        nc.add_observer(self, name="BlinkContactsHaveChanged")
        nc.add_observer(self, name="BlinkMuteChangedState")
        nc.add_observer(self, name="BlinkShouldTerminate")
        nc.add_observer(self, name="BlinkSessionChangedState")
        nc.add_observer(self, name="BlinkContactBecameAvailable")
        nc.add_observer(self, name="BlinkStreamHandlersChanged")
        nc.add_observer(self, name="BlinkProposalDidFail")
        nc.add_observer(self, name="SIPAccountGotSelfPresenceState")
        nc.add_observer(self, name="BonjourAccountWillRegister")
        nc.add_observer(self, name="BonjourAccountRegistrationDidSucceed")
        nc.add_observer(self, name="BonjourAccountRegistrationDidFail")
        nc.add_observer(self, name="BonjourAccountRegistrationDidEnd")
        nc.add_observer(self, name="CFGSettingsObjectDidChange")
        nc.add_observer(self, name="DefaultAudioDeviceDidChange")
        nc.add_observer(self, name="BlinkBuiltinMicrophoneAvailabilityDidChange")
        nc.add_observer(self, name="LDAPDirectorySearchFoundContact")
        nc.add_observer(self, name="HistoryEntriesVisibilityChanged")
        nc.add_observer(self, name="MediaStreamDidInitialize")
        nc.add_observer(self, name="SIPApplicationWillStart")
        nc.add_observer(self, name="SIPApplicationWillEnd")
        nc.add_observer(self, name="SIPApplicationDidStart")
        nc.add_observer(self, name="SIPAccountDidActivate")
        nc.add_observer(self, name="SIPAccountDidDeactivate")
        nc.add_observer(self, name="SIPAccountGotPresenceState")
        nc.add_observer(self, name="SIPAccountWillRegister")
        nc.add_observer(self, name="SystemWillSleep")
        nc.add_observer(self, name="SystemDidWakeUpFromSleep")
        nc.add_observer(self, name="SystemIPAddressDidChange")
        nc.add_observer(self, name="SIPAccountRegistrationDidSucceed")
        nc.add_observer(self, name="SIPAccountRegistrationDidFail")
        nc.add_observer(self, name="SIPAccountRegistrationGotAnswer")
        nc.add_observer(self, name="SIPAccountRegistrationDidEnd")
        nc.add_observer(self, name="AddressbookGroupWasActivated")
        nc.add_observer(self, name="AddressbookGroupWasDeleted")
        nc.add_observer(self, name="AddressbookGroupDidChange")
        nc.add_observer(self, name="BonjourAccountDidAddNeighbour")
        nc.add_observer(self, name="BonjourGroupWasActivated")
        nc.add_observer(self, name="BonjourGroupWasDeactivated")
        nc.add_observer(self, name="VirtualGroupWasActivated")
        nc.add_observer(self, name="VirtualGroupWasDeleted")
        nc.add_observer(self, name="VirtualGroupDidChange")
        nc.add_observer(self, name="SIPSessionLoggedToHistory")
        nc.add_observer(self, name="SIPSessionLoggedToHistory")
        nc.add_observer(self, name="PresenceSubscriptionDidFail")
        nc.add_observer(self, name="PresenceSubscriptionDidEnd")

        nc.add_observer(self, sender=AccountManager())

        ns_nc = NSNotificationCenter.defaultCenter()
        ns_nc.addObserver_selector_name_object_(self, "contactSelectionChanged:", NSOutlineViewSelectionDidChangeNotification, self.contactOutline)
        # the search results list never had one, so selecting a contact while
        # searching updated nothing
        ns_nc.addObserver_selector_name_object_(self, "contactSelectionChanged:", NSOutlineViewSelectionDidChangeNotification, self.searchOutline)
        ns_nc.addObserver_selector_name_object_(self, "participantSelectionChanged:", NSTableViewSelectionDidChangeNotification, self.participantsTableView)
        ns_nc.addObserver_selector_name_object_(self, "drawerSplitViewDidResize:", NSSplitViewDidResizeSubviewsNotification, self.drawerSplitView)
        ns_nc.addObserver_selector_name_object_(self, "userDefaultsDidChange:", "NSUserDefaultsDidChangeNotification", NSUserDefaults.standardUserDefaults())

        self.sessionControllersManager = SessionControllersManager()

        # never show debug window when application launches
        NSUserDefaults.standardUserDefaults().setInteger_forKey_(0, "ShowDebugWindow")

        self.photoImage.callback = self.photoClicked

        self.window().makeFirstResponder_(self.contactOutline)

        self.contactsMenu.itemWithTag_(42).setEnabled_(True)  # Dialpad
        self.addKeyEscrowMenuItem()

        if not NSApp.delegate().answering_machine_enabled:
            # Answering machine
            item = self.statusBarMenu.itemWithTag_(50)
            item.setEnabled_(False)
            item.setHidden_(True)

        if not NSApp.delegate().history_enabled:
            # History menu
            item = self.windowMenu.itemWithTag_(3)
            item.setHidden_(True)
            item = self.historyMenu.itemWithTag_(1)
            item.setHidden_(True)

        self.window().setTitle_(NSApp.delegate().applicationNamePrint)

        segmentChildren = NSAccessibilityUnignoredDescendant(self.actionButtons).accessibilityAttributeValue_(NSAccessibilityChildrenAttribute)
        segmentChildren.objectAtIndex_(0).accessibilitySetOverrideValue_forAttribute_(NSString.stringWithString_('Start Audio Call'), NSAccessibilityDescriptionAttribute)
        segmentChildren.objectAtIndex_(1).accessibilitySetOverrideValue_forAttribute_(NSString.stringWithString_('Start Video Call'), NSAccessibilityDescriptionAttribute)
        segmentChildren.objectAtIndex_(2).accessibilitySetOverrideValue_forAttribute_(NSString.stringWithString_('Start Text Chat'), NSAccessibilityDescriptionAttribute)
        segmentChildren.objectAtIndex_(3).accessibilitySetOverrideValue_forAttribute_(NSString.stringWithString_('Screen Sharing'), NSAccessibilityDescriptionAttribute)
        segmentChildren.objectAtIndex_(0).accessibilitySetOverrideValue_forAttribute_(NSString.stringWithString_('Push button'), NSAccessibilityRoleDescriptionAttribute)
        segmentChildren.objectAtIndex_(1).accessibilitySetOverrideValue_forAttribute_(NSString.stringWithString_('Push button'), NSAccessibilityRoleDescriptionAttribute)
        segmentChildren.objectAtIndex_(2).accessibilitySetOverrideValue_forAttribute_(NSString.stringWithString_('Push button'), NSAccessibilityRoleDescriptionAttribute)
        segmentChildren.objectAtIndex_(3).accessibilitySetOverrideValue_forAttribute_(NSString.stringWithString_('Push button'), NSAccessibilityRoleDescriptionAttribute)

        segmentChildren2 = NSAccessibilityUnignoredDescendant(self.actionButtons).accessibilityAttributeValue_(NSAccessibilityChildrenAttribute)
        segmentChildren2.objectAtIndex_(0).accessibilitySetOverrideValue_forAttribute_(NSString.stringWithString_('Start Audio Call'), NSAccessibilityDescriptionAttribute)
        segmentChildren2.objectAtIndex_(1).accessibilitySetOverrideValue_forAttribute_(NSString.stringWithString_('Start Text Chat'), NSAccessibilityDescriptionAttribute)
        segmentChildren2.objectAtIndex_(2).accessibilitySetOverrideValue_forAttribute_(NSString.stringWithString_('Screen Sharing'), NSAccessibilityDescriptionAttribute)
        segmentChildren2.objectAtIndex_(0).accessibilitySetOverrideValue_forAttribute_(NSString.stringWithString_('Push button'), NSAccessibilityRoleDescriptionAttribute)
        segmentChildren2.objectAtIndex_(1).accessibilitySetOverrideValue_forAttribute_(NSString.stringWithString_('Push button'), NSAccessibilityRoleDescriptionAttribute)
        segmentChildren2.objectAtIndex_(2).accessibilitySetOverrideValue_forAttribute_(NSString.stringWithString_('Push button'), NSAccessibilityRoleDescriptionAttribute)

        if not NSApp.delegate().screen_sharing_ui_enabled:
            # Screen sharing is the last segment of both call button bars.
            for control in (self.actionButtons, self.actionButtonsNoVideo):
                count = control.segmentCount()
                if count < 2:
                    continue
                frame = control.frame()
                removed = frame.size.width / count
                control.setSegmentCount_(count - 1)
                frame.size.width -= removed
                frame.origin.x += removed / 2
                control.setFrame_(frame)

        self.setAlwaysOnTop()

        path = ApplicationData.get('presence')
        makedirs(path)

        try:
            with open(ApplicationData.get('presence_notes_history.pickle'), 'rb'):
                pass
        except IOError:
            pass
        else:
            src = ApplicationData.get('presence_notes_history.pickle')
            dst = ApplicationData.get('presence/presence_notes_history.pickle')
            try:
                shutil.move(src, dst)
            except shutil.Error:
                pass

        try:
            with open(ApplicationData.get('presence_offline_note.pickle'), 'rb'):
                pass
        except IOError:
            pass
        else:
            unlink(ApplicationData.get('presence_offline_note.pickle'))

        # TODO3
        try:
            with open(ApplicationData.get('presence/presence_notes_history.pickle'), 'rb') as f:
                self.presence_notes_history.extend(pickle.load(f))
        except (TypeError, EOFError):
            # data is corrupted, reset it
            self.deletePresenceHistory_(None)
        except (IOError, pickle.UnpicklingError):
            pass

        self.presencePublisher = PresencePublisher(self)

        self.statusBarItem = self.statusbar.statusItemWithLength_(NSVariableStatusItemLength)
        self.setStatusBarIcon()
        self.statusBarItem.setHighlightMode_(1)
        self.statusBarItem.setToolTip_(NSApp.delegate().applicationName)
        self.statusBarItem.setMenu_(self.statusBarMenu)

        self.last_calls_submenu = NSMenu.alloc().init()
        self.last_calls_submenu.setAutoenablesItems_(False)

        dotPath = NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(NSMakeRect(0, 1, 5, 12), 2.0, 2.0)
        self.presence_dots = {}
        for i, color in [("available", NSColor.greenColor()),
                         ("away", NSColor.yellowColor()),
                         ("busy", NSColor.redColor()),
                         ("invisible", NSColor.grayColor()),
                         ("offline", NSColor.whiteColor())]:
            dot = NSImage.alloc().initWithSize_(NSMakeSize(14, 14))
            dot.lockFocus()
            color.set()
            dotPath.fill()
            dot.unlockFocus()
            self.presence_dots[i] = dot

        self.speech_synthesizer = NSSpeechSynthesizer.alloc().init() or Null
        self.speech_synthesizer.setDelegate_(self)

        self.rotateCameraTimer = NSTimer.timerWithTimeInterval_target_selector_userInfo_repeats_(30, self, "rotateCamera:", None, True)
        NSRunLoop.currentRunLoop().addTimer_forMode_(self.rotateCameraTimer, NSModalPanelRunLoopMode)
        NSRunLoop.currentRunLoop().addTimer_forMode_(self.rotateCameraTimer, NSDefaultRunLoopMode)

        self.conference_timer = NSTimer.timerWithTimeInterval_target_selector_userInfo_repeats_(30, self, "startConferenceTimer:", None, True)
        NSRunLoop.currentRunLoop().addTimer_forMode_(self.conference_timer, NSModalPanelRunLoopMode)
        NSRunLoop.currentRunLoop().addTimer_forMode_(self.conference_timer, NSDefaultRunLoopMode)

        self.purge_presence_timer = NSTimer.timerWithTimeInterval_target_selector_userInfo_repeats_(10, self, "purgePresenceTimer:", None, True)
        NSRunLoop.currentRunLoop().addTimer_forMode_(self.purge_presence_timer, NSModalPanelRunLoopMode)
        NSRunLoop.currentRunLoop().addTimer_forMode_(self.purge_presence_timer, NSDefaultRunLoopMode)

        if host.default_ip:
            t = NSTimer.timerWithTimeInterval_target_selector_userInfo_repeats_(10, self, "showUnsentMessages:", None, False)
            NSRunLoop.currentRunLoop().addTimer_forMode_(t, NSModalPanelRunLoopMode)
            NSRunLoop.currentRunLoop().addTimer_forMode_(t, NSDefaultRunLoopMode)

        self.loaded = True

    @objc.python_method
    def initFileTransfersWindow(self):
        if not self.fileTransfersWindow:
            self.fileTransfersWindow = FileTransferWindowController()

    @objc.python_method
    def setCollapsed(self, flag):
        if self.loaded:
            self.collapsedState = flag
            self.updateParticipantsView()
        if flag:
            self.contactOutline.deselectAll_(None)

    @objc.python_method
    def init_aspect_ratio(self, width, height):
        self.refresh_drawer_counter += 1

    @objc.IBAction
    def refreshDevices_(self, sender):
        #BlinkLogger().log_info('Refresh audio devices')
        SIPApplication().engine._ua.refresh_sound_devices()
        
        settings = SIPSimpleSettings()
        in_devices = list(self.backend._app.engine.input_devices) + ['system_default', None]
        out_devices = list(self.backend._app.engine.output_devices) + ['system_default', None]
        if settings.audio.input_device not in in_devices:
            BlinkLogger().log_info('Changing input to system_default')
            settings.audio.input_device = 'system_default'
        if settings.audio.output_device not in out_devices:
            BlinkLogger().log_info('Changing output to system_default')
            settings.audio.output_device = 'system_default'
        
        settings.save()

    @objc.python_method
    @run_in_gui_thread
    def refreshAccountList(self):
        # BlinkLogger().log_info('startup: refreshAccountList enter')
        if not self.sessionControllersManager.isMediaTypeSupported('video'):
            self.actionButtonsNoVideo.setHidden_(False)
            self.actionButtons.setHidden_(True)
        else:
            self.actionButtonsNoVideo.setHidden_(True)
            self.actionButtons.setHidden_(False)

        style = NSParagraphStyle.defaultParagraphStyle().mutableCopy()
        style.setLineBreakMode_(NSLineBreakByTruncatingTail)
        grayAttrs = NSDictionary.dictionaryWithObjectsAndKeys_(NSColor.disabledControlTextColor(), NSForegroundColorAttributeName, style, NSParagraphStyleAttributeName)
        redAttrs = NSDictionary.dictionaryWithObjectsAndKeys_(NSColor.redColor(), NSForegroundColorAttributeName, style, NSParagraphStyleAttributeName)
        self.accountPopUp.removeAllItems()
        self.accounts.sort(key=attrgetter('order'))

        account_manager = AccountManager()

        for account_info in (account_info for account_info in self.accounts if account_info.account.enabled):
            from ContactMangler import mangled_account_label
            # My own address is in every screenshot, whatever the contact
            # list is showing.
            label = mangled_account_label(account_info.account.gui.account_label or account_info.name)
            self.accountPopUp.addItemWithTitle_(label)
            item = self.accountPopUp.lastItem()
            item.setRepresentedObject_(account_info.account)
            if isinstance(account_info.account, BonjourAccount):
                image = NSImage.imageNamed_("NSBonjour")
                image.setScalesWhenResized_(True)
                image.setSize_(NSMakeSize(12, 12))
                item.setImage_(image)
                if account_info.account.enabled and not account_info.register_state == 'succeeded':
                    if account_info.register_terminal_reason:
                        name = '%s (%s)' % (label, account_info.register_terminal_reason)
                    else:
                        name = label
                    title = NSAttributedString.alloc().initWithString_attributes_(name, grayAttrs)
                    item.setAttributedTitle_(title)
            else:
                if not account_info.register_state == 'succeeded':
                    if account_info.account.sip.register:
                        # only terminal failures (final SIP responses needing user action), see _NH_SIPAccountRegistrationGotAnswer
                        if account_info.register_terminal_reason:
                            name = '%s (%s)' % (label, account_info.register_terminal_reason)
                        else:
                            name = label
                    else:
                        name = label
                    title = NSAttributedString.alloc().initWithString_attributes_(name, grayAttrs)
                    item.setAttributedTitle_(title)
                    item.setImage_(None)
                else:
                    if account_info.account.audio.do_not_disturb:
                        title = NSAttributedString.alloc().initWithString_attributes_(label, redAttrs)
                        item.setAttributedTitle_(title)
                        image = NSImage.imageNamed_("blocked")
                        image.setScalesWhenResized_(True)
                        image.setSize_(NSMakeSize(12, 12))
                        item.setImage_(image)
                    else:
                        if account_info.registrar is not None:
                            if account_info.registrar.startswith("tls"):
                                image = NSImage.imageNamed_("locked-green")
                            else:
                                image = NSImage.imageNamed_("unlocked-darkgray")
                            image.setScalesWhenResized_(True)
                            image.setSize_(NSMakeSize(16, 16))
                            item.setImage_(image)
                        else:
                            item.setImage_(None)

            if account_info.account is account_manager.default_account:
                self.accountPopUp.selectItem_(item)

        if self.accountPopUp.numberOfItems() == 0:
            self.accountPopUp.addItemWithTitle_(NSLocalizedString("No Accounts", "Account popup menu item"))
            self.accountPopUp.lastItem().setEnabled_(False)

        if self.backend.validateAddAccountAction():
            self.accountPopUp.menu().addItem_(NSMenuItem.separatorItem())
            self.accountPopUp.addItemWithTitle_(NSLocalizedString("Add Account...", "Account popup menu item"))

        if account_manager.default_account is not None:
            self.updateNameLabel(account_manager.default_account.display_name or account_manager.default_account.id)
        else:
            self.updateNameLabel('')
        # BlinkLogger().log_info('startup: refreshAccountList exit')

    @objc.python_method
    def activeAccount(self):
        return self.accountPopUp.selectedItem().representedObject()

    @objc.python_method
    def updateNameLabel(self, name):
        from ContactMangler import mangled_account_label
        self.nameText.setStringValue_(mangled_account_label(name))
        # My initials are my display name: renaming myself has to reach the
        # avatar beside it.
        try:
            self.updateOwnAvatarName()
        except Exception:
            pass

    @objc.python_method
    def refreshContactsList(self, sender=None):
        # BlinkLogger().log_info('startup: refreshContactsList enter')
        if sender is None:
            sender = self.model
        # An outline view keeps the selected ROW across a reload, not the
        # selected ITEM. That was harmless while every group was ordered
        # alphabetically and changed only when the user changed it; it is
        # not harmless now that Calls re-sorts itself by timestamp and
        # gains contacts on its own. A call landing while a conversation is
        # open shifts the rows under a fixed index, and whichever contact
        # slides into that index becomes the selection -- the message pane
        # switches to someone the user never clicked on. Remember the item,
        # put it back.
        selected = []
        try:
            rows = self.contactOutline.selectedRowIndexes()
            row = rows.firstIndex()
            while row != NSNotFound:
                item = self.contactOutline.itemAtRow_(row)
                if item is not None:
                    selected.append(item)
                row = rows.indexGreaterThanIndex_(row)
        except Exception:
            selected = []

        if sender is self.model:
            self.contactOutline.reloadData()
            for group in self.model.groupsList:
                if group.group is not None and group.group.expanded:
                    self.contactOutline.expandItem_expandChildren_(group, False)
        else:
            self.model.refreshDeletedMembership()
            self.contactOutline.reloadItem_reloadChildren_(sender, True)

        if selected:
            try:
                indexes = NSMutableIndexSet.indexSet()
                for item in selected:
                    row = self.contactOutline.rowForItem_(item)
                    if row >= 0:
                        indexes.addIndex_(row)
                if indexes.count() and not indexes.isEqualToIndexSet_(self.contactOutline.selectedRowIndexes()):
                    self.contactOutline.selectRowIndexes_byExtendingSelection_(indexes, False)
            except Exception:
                pass                # the items are gone; leave the selection alone
        # BlinkLogger().log_info('startup: refreshContactsList exit')

    @objc.python_method
    def getSelectedContacts(self, includeGroups=False):
        contacts = []
        if self.mainTabView.selectedTabViewItem().identifier() == "contacts":
            outline = self.contactOutline
        elif self.mainTabView.selectedTabViewItem().identifier() == "search":
            outline = self.searchOutline

            if outline.selectedRowIndexes().count() == 0:
                text = self.searchBox.stringValue()
                if not text:
                    return []
                contact = BlinkContact(text, name=text)
                return [contact]
        else:
            return []

        selection = outline.selectedRowIndexes()
        item = selection.firstIndex()
        while item != NSNotFound:
            object = outline.itemAtRow_(item)
            if isinstance(object, BlinkContact):
                contacts.append(object)
            elif includeGroups and isinstance(object, BlinkGroup):
                contacts.append(object)
            item = selection.indexGreaterThanIndex_(item)

        return contacts

    @objc.python_method
    @run_in_gui_thread
    def renderLastCallsEntriesForContact(self, results, contact):
        while self.last_calls_submenu.numberOfItems() > 0:
            self.last_calls_submenu.removeItemAtIndex_(0)

        if results:
            for result in reversed(list(results)):
                if 'video' in result.media_types.lower():
                    label = 'Video'
                elif 'screen' in result.media_types.lower():
                    label = 'Screen Sharing'
                elif 'audio' in result.media_types.lower():
                    label = 'Audio'
                else:
                    label = result.media_types.title()
                label += NSLocalizedString(" from ", "Menu item") if result.direction == 'incoming' else NSLocalizedString(" to ", "Menu item")
                label += contact.name
                duration = result.end_time - result.start_time
                if result.duration == 0:
                    status = session_status_localized[result.status]
                else:
                    status = ''
                    if duration.days > 0 or duration.seconds > 60 * 60:
                        status = NSLocalizedString("%i hours, ", "Menu item") % (duration.days * 60 * 60 * 24 + int(duration.seconds/(60 * 60)))
                    s = duration.seconds % (60 * 60)
                    status += "%02i:%02i" % divmod(s, 60)
                title = '%s %s (%s)' % (label, format_date(utc_to_local(result.start_time)), status)
                r_item = self.last_calls_submenu.insertItemWithTitle_action_keyEquivalent_atIndex_(title, "", "", 0)
                image = None
                if 'screen' in result.media_types:
                    image = 'display_16'
                elif 'audio' in result.media_types:
                    image = 'hangup_16' if result.status == 'missed' else 'audio_16'
                elif result.media_types == 'chat':
                    image = 'pencil'
                elif result.media_types == 'file-transfer':
                    image = 'outgoing_file' if result.direction == 'outgoing' else 'incoming_file'

                if image:
                    icon = NSImage.imageNamed_(image)
                    icon.setScalesWhenResized_(True)
                    icon.setSize_(NSMakeSize(14, 14))
                    r_item.setImage_(icon)

    @objc.python_method
    @run_in_gui_thread
    def renderHistoryEntriesInStatusBarMenu(self, entries):
        menu = self.statusBarMenu
        for i in range(10):
            missed_call_item = menu.itemWithTag_(1001+i)
            if missed_call_item:
                self.statusBarMenu.removeItem_(missed_call_item)
            else:
                break

        index = menu.indexOfItem_(menu.itemWithTag_(1000))
        tag = 1001
        for item in entries['missed']:
            lastItem = menu.insertItemWithTitle_action_keyEquivalent_atIndex_("%(remote_party)s  %(start_time)s" % item, "historyClicked:", "", index+1)
            lastItem.setAttributedTitle_(self.format_history_menu_item(item))
            lastItem.setIndentationLevel_(1)
            lastItem.setTarget_(self)
            lastItem.setTag_(tag)
            lastItem.setRepresentedObject_(item)
            tag += 1
            index += 1

    @objc.python_method
    @run_in_gui_thread
    def renderHistoryEntriesInHistoryMenu(self, entries):
        def get_icon_history_result(media_types, direction, status):
            image = None
            if 'screen' in media_types:
                image = 'display_16'
            elif 'audio' in media_types:
                image = 'audio_16' if status == 'completed' else 'hangup_16'
            elif 'chat' in media_types:
                image = 'pencil'
            elif 'file-transfer' in media_types:
                image = 'outgoing_file' if direction == 'outgoing' else 'incoming_file'
            return image

        menu = self.historyMenu

        from ContactMangler import mangled_text
        i = 3 if not NSApp.delegate().history_enabled else 4
        while menu.numberOfItems() > i:
            menu.removeItemAtIndex_(i)

        lastItem = menu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Missed Calls", "Menu item"), "", "")
        lastItem.setEnabled_(False)
        for item in entries['missed']:
            lastItem = menu.addItemWithTitle_action_keyEquivalent_("%s  %s" % (mangled_text(item['remote_party']), item['start_time']), "historyClicked:", "")
            lastItem.setAttributedTitle_(self.format_history_menu_item(item))
            lastItem.setIndentationLevel_(1)
            lastItem.setTarget_(self)
            lastItem.setRepresentedObject_(item)
            image = get_icon_history_result(item['streams'], 'incoming', item['status'])
            if image:
                icon = NSImage.imageNamed_(image)
                icon.setScalesWhenResized_(True)
                icon.setSize_(NSMakeSize(14, 14))
                lastItem.setImage_(icon)

        menu.addItem_(NSMenuItem.separatorItem())
        lastItem = menu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Incoming Calls", "Menu item"), "", "")
        lastItem.setEnabled_(False)
        for item in entries['incoming']:
            lastItem = menu.addItemWithTitle_action_keyEquivalent_("%s  %s" % (mangled_text(item['remote_party']), item['start_time']), "historyClicked:", "")
            lastItem.setAttributedTitle_(self.format_history_menu_item(item))
            lastItem.setIndentationLevel_(1)
            lastItem.setTarget_(self)
            lastItem.setRepresentedObject_(item)
            image = get_icon_history_result(item['streams'], 'incoming', item['status'])
            if image:
                icon = NSImage.imageNamed_(image)
                icon.setScalesWhenResized_(True)
                icon.setSize_(NSMakeSize(14, 14))
                lastItem.setImage_(icon)

        menu.addItem_(NSMenuItem.separatorItem())
        lastItem = menu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Outgoing Calls", "Menu item"), "", "")
        lastItem.setEnabled_(False)
        for item in entries['outgoing']:
            lastItem = menu.addItemWithTitle_action_keyEquivalent_("%s  %s" % (mangled_text(item['remote_party']), item['start_time']), "historyClicked:", "")
            lastItem.setAttributedTitle_(self.format_history_menu_item(item))
            lastItem.setIndentationLevel_(1)
            lastItem.setTarget_(self)
            lastItem.setRepresentedObject_(item)
            image = get_icon_history_result(item['streams'], 'outgoing', item['status'])
            if image:
                icon = NSImage.imageNamed_(image)
                icon.setScalesWhenResized_(True)
                icon.setSize_(NSMakeSize(14, 14))
                lastItem.setImage_(icon)

        menu.addItem_(NSMenuItem.separatorItem())
        # Where the video window's Screenshot button saves. Rebuilt with the
        # rest of the dynamic part of this menu, so it is always the last
        # item before Clear History.
        lastItem = menu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Screenshots Folder", "Menu item"), "openScreenshotsFolder:", "")
        lastItem.setTarget_(self)
        lastItem.setEnabled_(True)
        lastItem = menu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Clear History", "Menu item"), "historyClicked:", "")
        lastItem.setEnabled_(True if entries['incoming'] or entries['outgoing'] or entries['missed'] else False)
        lastItem.setTag_(444)
        lastItem.setTarget_(self)

    @objc.IBAction
    def openScreenshotsFolder_(self, sender):
        # Created if there is none yet, with the same permissions the video
        # window uses: opening an empty folder is an answer, a Finder error
        # about a missing one is not.
        folder = ApplicationData.get('screenshots')
        try:
            if not os.path.exists(folder):
                os.mkdir(folder, 0o700)
        except OSError as e:
            BlinkLogger().log_error('Cannot create the screenshots folder %s: %s' % (folder, e))
            return
        NSWorkspace.sharedWorkspace().openURL_(NSURL.fileURLWithPath_(folder))

    @objc.python_method
    def showHelp(self, append_url=''):
        NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(NSApp.delegate().help_url+append_url))

    @objc.python_method
    def updateBlinkMenu(self):
        settings = SIPSimpleSettings()

        self.blinkMenu.itemWithTag_(1).setTitle_(NSLocalizedString("About %s", "Menu item") % NSApp.delegate().applicationNamePrint)
        self.blinkMenu.itemWithTag_(10).setTitle_(NSLocalizedString("Hide", "Menu item"))
        find_sylkserver = LSFindApplicationForInfo(kLSUnknownCreator, 'com.agprojects.SylkServer', None, None, None)
        # sylkserver_exists = find_sylkserver[2] is not None
        sylkserver_exists = True

        self.blinkMenu.itemWithTag_(2).setHidden_(bool(NSApp.delegate().updater is None))
        # Donate (3) and Get Blink Pro (7) are never shown; the separator (8) only goes with Get SylkServer (9)
        self.blinkMenu.itemWithTag_(3).setHidden_(True)
        self.blinkMenu.itemWithTag_(7).setHidden_(True)
        if NSApp.delegate().applicationName == 'SIP2SIP':
            self.blinkMenu.itemWithTag_(8).setHidden_(sylkserver_exists)
            self.blinkMenu.itemWithTag_(9).setHidden_(sylkserver_exists)
        else:
            self.blinkMenu.itemWithTag_(8).setHidden_(True)
            self.blinkMenu.itemWithTag_(9).setHidden_(True)

        if settings.service_provider.name:
            if settings.service_provider.about_url or settings.service_provider.help_url:
                self.blinkMenu.itemWithTag_(4).setHidden_(False)
            if settings.service_provider.about_url:
                title = NSLocalizedString("About %s...", "Menu item") % settings.service_provider.name
                self.blinkMenu.itemWithTag_(5).setTitle_(title)
                self.blinkMenu.itemWithTag_(5).setHidden_(False)
            if settings.service_provider.help_url:
                title = NSLocalizedString("%s Support Page...", "Menu item") % settings.service_provider.name
                self.blinkMenu.itemWithTag_(6).setTitle_(title)
                self.blinkMenu.itemWithTag_(6).setHidden_(False)
        else:
            self.blinkMenu.itemWithTag_(4).setHidden_(True)
            self.blinkMenu.itemWithTag_(5).setHidden_(True)
            self.blinkMenu.itemWithTag_(6).setHidden_(True)

    @objc.python_method
    def updateCallMenu(self):
        menu = self.callMenu

        item = menu.itemWithTag_(300)  # mute
        item.setState_(NSOnState if self.backend.is_muted() else NSOffState)

        item = menu.itemWithTag_(301)  # silent
        settings = SIPSimpleSettings()
        item.setState_(NSOnState if settings.audio.silent else NSOffState)

        item = menu.itemWithTag_(302)  # dnd
        account = AccountManager().default_account
        item.setState_(NSOnState if account is not None and account.audio.do_not_disturb else NSOffState)
        item.setEnabled_(True)

        settings = SIPSimpleSettings()
        self.useSpeechRecognitionMenuItem.setState_(NSOnState if settings.sounds.use_speech_recognition else NSOffState)

        while menu.numberOfItems() > 9:
            menu.removeItemAtIndex_(9)

        account = self.activeAccount()
        if account is None:
            return

        item = menu.itemWithTag_(44)  # Join Conference
        # Capability check, not isPeerToPeerChatEnabled(): a conference room is
        # not a one-to-one chat, so it stays reachable with MSRP chat off.
        item.setEnabled_(self.sessionControllersManager.isMediaTypeSupported('chat'))

        # outbound proxy
        if not isinstance(account, BonjourAccount) and (account.sip.primary_proxy or account.sip.alternative_proxy):
            menu.addItem_(NSMenuItem.separatorItem())
            lastItem = menu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Outbound Proxy", "Menu item"), "", "")
            lastItem.setEnabled_(False)

            lastItem = menu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("None", "Menu Item"), "selectOutboundProxyClicked:", "")
            lastItem.setIndentationLevel_(2)
            lastItem.setState_(NSOffState if account.sip.always_use_my_proxy else NSOnState)
            lastItem.setTag_(700)
            lastItem.setTarget_(self)
            lastItem.setRepresentedObject_(account)

            if account.sip.primary_proxy is None:
                title = NSLocalizedString("Discovered using DNS Lookup", "Menu item")
            else:
                title = str(account.sip.primary_proxy)
            lastItem = menu.addItemWithTitle_action_keyEquivalent_(title, "selectOutboundProxyClicked:", "")
            lastItem.setIndentationLevel_(2)
            lastItem.setState_(NSOnState if not account.sip.selected_proxy and account.sip.always_use_my_proxy else NSOffState)
            lastItem.setTag_(701)
            lastItem.setTarget_(self)
            lastItem.setRepresentedObject_(account)

            if account.sip.alternative_proxy:
                lastItem = menu.addItemWithTitle_action_keyEquivalent_(str(account.sip.alternative_proxy), "selectOutboundProxyClicked:", "")
                lastItem.setIndentationLevel_(2)
                lastItem.setState_(NSOnState if account.sip.selected_proxy and account.sip.always_use_my_proxy else NSOffState)
                lastItem.setTag_(702)
                lastItem.setTarget_(self)
                lastItem.setRepresentedObject_(account)

        # voicemail
        def format_account_item(account, mwi_data, mwi_format_new, mwi_format_no_new):
            from ContactMangler import mangled_account_label
            a = NSMutableAttributedString.alloc().init()
            n = NSAttributedString.alloc().initWithString_attributes_(
                "%s    " % mangled_account_label(str(account.id)), normal_font_color)
            a.appendAttributedString_(n)
            if mwi_data.get('messages_waiting') and mwi_data.get('new_messages') != 0:
                text = "%d new messages" % mwi_data['new_messages']
                t = NSAttributedString.alloc().initWithString_attributes_(text, mwi_format_new)
            else:
                text = "No new messages"
                t = NSAttributedString.alloc().initWithString_attributes_(text, mwi_format_no_new)
            a.appendAttributedString_(t)
            return a

        menu.addItem_(NSMenuItem.separatorItem())
        lastItem = menu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Voicemail", "Menu item"), "", "")
        lastItem.setEnabled_(False)

        if any(account.message_summary.enabled for account in (account for account in AccountManager().iter_accounts() if not isinstance(account, BonjourAccount) and account.enabled)):
            for account in (account for account in AccountManager().iter_accounts() if not isinstance(account, BonjourAccount) and account.enabled and account.message_summary.enabled):
                from ContactMangler import mangled_account_label
                lastItem = menu.addItemWithTitle_action_keyEquivalent_(mangled_account_label(str(account.id)), "historyClicked:", "")
                mwi_data = MWIData.get(account.id)
                lastItem.setEnabled_(account.voicemail_uri is not None)
                lastItem.setAttributedTitle_(format_account_item(account, mwi_data or {}, red_font_color, mini_blue))
                lastItem.setIndentationLevel_(1)
                lastItem.setTag_(555)
                lastItem.setTarget_(self)
                lastItem.setRepresentedObject_(account)

    @objc.python_method
    def updateRecordingsMenu(self):
        if not NSApp.delegate().recording_enabled:
            return

        def format_item(name, when):
            a = NSMutableAttributedString.alloc().init()
            n = NSAttributedString.alloc().initWithString_attributes_("%s   " % name, normal_font_color)
            a.appendAttributedString_(n)
            t = NSAttributedString.alloc().initWithString_attributes_(when, mini_blue)
            a.appendAttributedString_(t)
            return a

        while not self.recordingsMenu.itemAtIndex_(0).isSeparatorItem():
            self.recordingsMenu.removeItemAtIndex_(0)
        self.recordingsMenu.itemAtIndex_(1).setRepresentedObject_(self.backend.get_recordings_directory())

        recordings = self.backend.get_recordings()[-20:]
        if not recordings:
            item = self.recordingsMenu.insertItemWithTitle_action_keyEquivalent_atIndex_(NSLocalizedString("No recordings available", None), "", "", 0)
            item.setEnabled_(False)

        for timestamp, remote_party, file, recording_type in recordings:
            title = "%s %s (%s)" % (remote_party, timestamp, recording_type)
            item = self.recordingsMenu.insertItemWithTitle_action_keyEquivalent_atIndex_(title, "recordingClicked:", "", 0)
            item.setTarget_(self)
            item.setRepresentedObject_(file)
            icon = NSImage.imageNamed_("video" if recording_type == "video" else "speaker")
            icon.setScalesWhenResized_(True)
            icon.setSize_(NSMakeSize(14, 14))
            item.setImage_(icon)
            item.setAttributedTitle_(format_item(remote_party, timestamp))

    @objc.python_method
    def updateRestoreContactsMenu(self):
        while not self.restoreContactsMenu.itemAtIndex_(0).isSeparatorItem():
            self.restoreContactsMenu.removeItemAtIndex_(0)
        self.restoreContactsMenu.itemAtIndex_(1).setRepresentedObject_(self.backend.get_contacts_backup_directory())

        contact_backups = self.backend.get_contact_backups()[-10:]
        if not contact_backups:
            item = self.restoreContactsMenu.insertItemWithTitle_action_keyEquivalent_atIndex_(NSLocalizedString("No Backups Available", None), "", "", 0)
            item.setEnabled_(False)

        for timestamp, file in contact_backups:
            title = NSLocalizedString("From Backup Taken at %s...", "Menu item") % timestamp
            item = self.restoreContactsMenu.insertItemWithTitle_action_keyEquivalent_atIndex_(title, "restoreContactsClicked:", "", 0)
            item.setTarget_(self)
            item.setRepresentedObject_((file, timestamp))

    @objc.python_method
    def updateWindowMenu(self):
        account = self.activeAccount()
        item = self.windowMenu.itemWithTag_(40)  # Settings on SIP server
        if account:
            item.setEnabled_(bool(not isinstance(account, BonjourAccount) and account.server.settings_url))
        else:
            item.setEnabled_(False)

        # Chat Conversations opens the MSRP chat window, so it has no business
        # being there when MSRP chat is switched off. Found by action rather
        # than by tag: it shares tag 5 with the Messages item above it, and
        # itemWithTag_ would hand back whichever comes first.
        index = self.windowMenu.indexOfItemWithTarget_andAction_(None, "showChatWindow:")
        if index >= 0:
            # The same window shows conference chat, so it must remain
            # reachable while one is open even with MSRP chat switched off.
            enabled = SIPSimpleSettings().chat.enable_msrp_chat or self.hasOpenChatTabs()
            item = self.windowMenu.itemAtIndex_(index)
            item.setHidden_(not enabled)
            # A hidden item's key equivalent is inert, but Cmd-4 should not
            # quietly do nothing either way.
            item.setEnabled_(enabled)

    @objc.python_method
    def updateChatMenu(self):
        while self.chatMenu.numberOfItems() > 0:
            self.chatMenu.removeItemAtIndex_(0)

        try:
            contact = self.getSelectedContacts()[0]
        except IndexError:
            pass
        else:
            # Chat menu option only for contacts without a full SIP URI
            no_contact_selected = self.contactOutline.selectedRow() == -1 and self.searchOutline.selectedRow() == -1
            # SMS option disabled when using Bonjour Account
            item = self.chatMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Send Short Message...", "Menu item"), "sendMessageToSelected:", "")
            item.setEnabled_(self.sessionControllersManager.isMediaTypeSupported('sms') and self.contactSupportsMedia("sms", contact))

            item = self.chatMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Invite to Chat...", "Menu item"), "startChatToSelected:", "")
            if not self.sessionControllersManager.isPeerToPeerChatEnabled():
                item.setEnabled_(False)
            elif isinstance(contact, BonjourBlinkContact):
                item.setEnabled_(True)
            elif isinstance(contact, BlinkPresenceContact):
                item.setEnabled_(self.contactSupportsMedia("chat", item, contact.uri))  # don't require presence to initiate chat
            else:
                item.setEnabled_((is_sip_aor_format(contact.uri) or no_contact_selected) and self.sessionControllersManager.isMediaTypeSupported('chat'))

    @objc.python_method
    def updateGroupMenu(self):
        while self.groupMenu.numberOfItems() > 0:
            self.groupMenu.removeItemAtIndex_(0)

        item = self.groupMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Add Contact...", "Menu item"), "addContact:", "")
        item = self.groupMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Add Group...", "Menu item"), "addGroup:", "")
        self.groupMenu.addItem_(NSMenuItem.separatorItem())

        item = self.groupMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Scroll to:", "Menu item"), "", "")
        item.setEnabled_(False)

        row = self.contactOutline.selectedRow()
        selected_group = None
        if row >= 0:
            item = self.contactOutline.itemAtRow_(row)
            selected_group = self.contactOutline.parentForItem_(item) if isinstance(item, BlinkContact) else item

        for group in self.model.visibleGroupsList:
            item = self.groupMenu.addItemWithTitle_action_keyEquivalent_(group.name, "goToGroup:", "")
            item.setIndentationLevel_(1)
            item.setRepresentedObject_(group)
            item.setState_(NSOnState if group == selected_group else NSOffState)

        self.groupMenu.addItem_(NSMenuItem.separatorItem())
        item = self.groupMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Special Groups", "Menu item"), "", "")
        item.setEnabled_(False)

        settings = SIPSimpleSettings()
        if NSApp.delegate().history_enabled:
            item = self.groupMenu.addItemWithTitle_action_keyEquivalent_(self.model.missed_calls_group.name, "toggleGroupVisibility:", "")
            item.setIndentationLevel_(1)
            item.setRepresentedObject_(self.model.missed_calls_group)
            item.setState_(NSOnState if settings.contacts.enable_missed_calls_group else NSOffState)

            item = self.groupMenu.addItemWithTitle_action_keyEquivalent_(self.model.incoming_calls_group.name, "toggleGroupVisibility:", "")
            item.setIndentationLevel_(1)
            item.setRepresentedObject_(self.model.incoming_calls_group)
            item.setState_(NSOnState if settings.contacts.enable_incoming_calls_group else NSOffState)

            item = self.groupMenu.addItemWithTitle_action_keyEquivalent_(self.model.outgoing_calls_group.name, "toggleGroupVisibility:", "")
            item.setIndentationLevel_(1)
            item.setRepresentedObject_(self.model.outgoing_calls_group)
            item.setState_(NSOnState if settings.contacts.enable_outgoing_calls_group else NSOffState)

        item = self.groupMenu.addItemWithTitle_action_keyEquivalent_(self.model.addressbook_group.name, "toggleGroupVisibility:", "")
        item.setIndentationLevel_(1)
        item.setRepresentedObject_(self.model.addressbook_group)
        item.setState_(NSOnState if settings.contacts.enable_address_book else NSOffState)

        item = self.groupMenu.addItemWithTitle_action_keyEquivalent_(self.model.online_contacts_group.name, "toggleGroupVisibility:", "")
        item.setIndentationLevel_(1)
        item.setRepresentedObject_(self.model.online_contacts_group)
        item.setState_(NSOnState if settings.contacts.enable_online_group else NSOffState)

        item = self.groupMenu.addItemWithTitle_action_keyEquivalent_(self.model.no_group.name, "toggleGroupVisibility:", "")
        item.setIndentationLevel_(1)
        item.setRepresentedObject_(self.model.no_group)
        item.setState_(NSOnState if settings.contacts.enable_no_group else NSOffState)

    @objc.python_method
    @run_in_gui_thread
    def updateHistoryMenu(self):
        if NSApp.delegate().recording_enabled:
            idx = self.historyMenu.indexOfItem_(self.recordingsSubMenu)
            if idx < 0:
                self.historyMenu.addItem_(self.recordingsSubMenu)
                self.historyMenu.addItem_(NSMenuItem.separatorItem())

        settings = SIPSimpleSettings()
        chat_privacy = self.historyMenu.itemWithTag_(101)
        chat_privacy.setState_(NSOnState if settings.chat.disable_history else NSOffState)

        self.get_session_history_entries(20)

    @objc.python_method
    def getAccountWitDialPlan(self, uri):
        try:
            account = next(account for account in AccountManager().iter_accounts() if not isinstance(account, BonjourAccount) and account.enabled and account.pstn.dial_plan and any(prefix for prefix in account.pstn.dial_plan.split(" ") if uri.startswith(prefix)))
            BlinkLogger().log_info("Auto-selecting account %s based on dial-plan match for %s" % (account.id, uri))
        except StopIteration:
            account = AccountManager().default_account
        return account

    @objc.python_method
    def callPendingURIs(self):
        NSApp.delegate().ready = True
        if NSApp.delegate().urisToOpen:
            for uri, media_type, participants in NSApp.delegate().urisToOpen:
                self.joinConference(uri, media_type, participants)
            NSApp.delegate().urisToOpen = []

        # A share that launched Blink has been waiting for a contact list
        # to pick from. It exists now.
        if NSApp.delegate().sharesToOpen:
            shares = NSApp.delegate().sharesToOpen
            NSApp.delegate().sharesToOpen = []
            for url in shares:
                NSApp.delegate().handleShareURL(url)

    @objc.python_method
    def sendMessageToURI(self, uri=None):
        account = self.activeAccount()
        if not account:
            NSRunAlertPanel(NSLocalizedString("Cannot Send Message", "Window title"), NSLocalizedString("There are currently no active accounts", "Label"), NSLocalizedString("OK", "Button title"), None, None)
            return

        instance_id = None
        contact = None
        display_name = ''

        try:
            contact = self.getSelectedContacts()[0]
        except IndexError:
            target = str(self.searchBox.stringValue()).strip()
            display_name = ''
        else:
            display_name = contact.name
            if contact in self.model.bonjour_group.contacts:
                target = contact.uri
                account = BonjourAccount()
                instance_id = contact.id
            elif uri:
                # One address picked from the contact's Send Message submenu.
                # It used to be ignored and the message went to the default
                # address; now it is used, and remembered as the address this
                # conversation runs on, the same as a pick in the pane header.
                target = uri
                SMSWindowManager.SMSWindowManager().pickMessageURIForContact(contact, uri)
            else:
                target = SMSWindowManager.SMSWindowManager().messageURIForContact(contact) or contact.uri

        if instance_id is None:
            account = self.messagingAccountFor(account, contact)
            if account is None:
                self.reportNoMessagingAccount(display_name or target)
                return

        target = normalize_sip_uri_for_outgoing_session(target, account)
        if not target:
            return

        try:
            NSApp.activateIgnoringOtherApps_(True)
            SMSWindowManager.SMSWindowManager().getWindow(target, display_name, account, focusTab=True, instance_id=instance_id, selected_contact=contact)
        except Exception:
            import traceback
            traceback.print_exc()

    @objc.IBAction
    def generateKeyButtonClicked_(self, sender):
        SMSWindowManager.SMSWindowManager().showGeneratePrivateKeyPanel(AccountManager().default_account)

    @objc.IBAction
    def exportButtonClicked_(self, sender):
        SMSWindowManager.SMSWindowManager().showExportPrivateKeyPanel(AccountManager().default_account)

    @objc.IBAction
    def escrowKeyClicked_(self, sender):
        # Acts on the account selected in the toolbar, which is the one the
        # menu item's title names -- not the default account, which may be a
        # different one entirely.
        SMSWindowManager.SMSWindowManager().escrowPrivateKey(self.activeAccount(),
                                                             force=sender.tag() == 70)

    @objc.python_method
    def addKeyEscrowMenuItem(self):
        """Put "Save PGP private key to server" next to the export item.

        Inserted at runtime rather than added to MainWindow.xib because that
        nib is localized five times over, and this is one menu item whose
        position is fully described by "after tag 67".
        """
        try:
            export_item = self.contactsMenu.itemWithTag_(67)   # Export PGP private key...
            if export_item is None:
                return
            menu = export_item.menu()
            if menu.itemWithTag_(69) is not None:              # already inserted
                return
            index = menu.indexOfItem_(export_item) + 1
            item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
                NSLocalizedString("Save PGP private key to server", "Menu item"),
                "escrowKeyClicked:", "")
            item.setTag_(69)
            item.setTarget_(self)
            menu.insertItem_atIndex_(item, index)

            # Shown only while Option is held. A re-escrow of a key the server
            # already has is what a password change needs, but it must not sit
            # in the menu as an ordinary command, where it reads as something
            # worth clicking when the plain one is greyed out.
            forced = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
                NSLocalizedString("Save PGP private key to server again", "Menu item"),
                "escrowKeyClicked:", "")
            forced.setTag_(70)
            forced.setTarget_(self)
            forced.setAlternate_(True)
            forced.setKeyEquivalentModifierMask_(1 << 19)   # NSEventModifierFlagOption
            menu.insertItem_atIndex_(forced, index + 1)
        except Exception as e:
            BlinkLogger().log_error("Could not add the key escrow menu item: %s" % e)

    @objc.python_method
    def updateKeyEscrowMenuItem(self):
        """Enable the escrow commands only where they would do something.

        Both items name the selected account, because this acts on whichever
        account is chosen in the toolbar and there is no way to tell from
        "Save PGP private key to server" alone which key would leave the
        machine. When an item is disabled, its tooltip says why.
        """
        item = self.contactsMenu.itemWithTag_(69)
        forced = self.contactsMenu.itemWithTag_(70)
        if item is None:
            return

        account = self.activeAccount()
        if account is None or account is BonjourAccount():
            for entry in (item, forced):
                if entry is not None:
                    entry.setEnabled_(False)
                    entry.setToolTip_(NSLocalizedString("Bonjour accounts have no server to save a key to", "Tooltip"))
            return

        if not SMSWindowManager.pgp_messaging_possible(account):
            for entry in (item, forced):
                if entry is not None:
                    entry.setEnabled_(False)
                    entry.setToolTip_(NSLocalizedString("PGP messaging needs a SylkServer, and the domain of this account does not run one", "Tooltip"))
            return

        from ContactMangler import mangled_account_label
        item.setTitle_(NSLocalizedString("Save PGP private key of %s to server", "Menu item") % mangled_account_label(str(account.id)))
        if forced is not None:
            forced.setTitle_(NSLocalizedString("Save PGP private key of %s to server again", "Menu item") % mangled_account_label(str(account.id)))

        try:
            enabled, reason = KeyEscrow.escrow_write_action(account)
            item.setEnabled_(bool(enabled))
            item.setToolTip_(reason or "")
            if forced is not None:
                # Forcing skips the "already saved" test but not the blockers:
                # nothing lets the user overwrite another account's escrow.
                forced_enabled, forced_reason = KeyEscrow.escrow_write_action(account, force=True)
                forced.setEnabled_(bool(forced_enabled))
                forced.setToolTip_(forced_reason or "")
        except Exception as e:
            BlinkLogger().log_error("Could not evaluate the key escrow menu item: %s" % e)
            item.setEnabled_(False)

    @objc.python_method
    def addParticipants(self):
        session = self.getSelectedAudioSession()
        if session:
            if session.remote_focus:
                participants = self.showAddParticipantsWindow(target=self.getConferenceTitle(), default_domain=session.account.id.domain)
                self.addParticipantsWindow = None
                if participants is not None:
                    remote_uri = format_identity_to_string(session.remoteIdentity)
                    # prevent loops
                    if remote_uri in participants:
                        participants.remove(remote_uri)
                    for uri in participants:
                        if uri and "@" not in uri:
                            uri = '%s@%s' % (uri, session.account.id.domain)

                        try:
                            SIPURI.parse('sip:%s' % uri if not uri.startswith("sip:") else uri)
                        except SIPCoreError:
                            session.log_info("Error inviting to conference: invalid URI %s" % uri)
                            continue

                        presence_contact = self.getFirstContactFromAllContactsGroupMatchingURI(uri)
                        if presence_contact:
                            contact = BlinkConferenceContact(uri, name=presence_contact.name, icon=presence_contact.icon, presence_contact=presence_contact)
                        else:
                            contact = BlinkConferenceContact(uri=uri, name=uri)
                        contact.detail = NSLocalizedString("Invitation sent...", "Contact detail")
                        session.invited_participants.append(contact)
                        session.participants_log.add(uri)
                        session.log_info("Invite %s to conference" % uri)
                        session.session.conference.add_participant(uri)

    @objc.python_method
    def removeParticipant(self, uri):
        session = self.getSelectedAudioSession()
        if session:
            # remove uri from invited participants
            try:
                contact = next(contact for contact in session.invited_participants if contact.uri == uri)
            except StopIteration:
                pass
            else:
                try:
                    session.invited_participants.remove(contact)
                except ValueError:
                    pass
                else:
                    contact.destroy()

            if session.remote_focus and self.isConferenceParticipant(uri):
                session.log_info("Request server for removal of %s from conference" % uri)
                session.pending_removal_participants.add(uri)
                session.session.conference.remove_participant(uri)

            self.participantsTableView.deselectAll_(self)

    @objc.python_method
    def isConferenceParticipant(self, uri):
        session = self.getSelectedAudioSession()
        if session and hasattr(session.conference_info, "users"):
            for user in session.conference_info.users:
                participant = sip_prefix_pattern.sub("", user.entity)
                if participant == uri:
                    return True
        return False

    @objc.python_method
    def isInvitedParticipant(self, uri):
        session = self.getSelectedAudioSession()
        try:
            return uri in (contact.uri for contact in session.invited_participants)
        except AttributeError:
            return False

    @objc.python_method
    def canGoToConferenceWebsite(self):
        session = self.getSelectedAudioSession()
        if session.conference_info and session.conference_info.host_info and session.conference_info.host_info.web_page:
            return True
        return False

    @objc.python_method
    def canBeRemovedFromConference(self, uri):
        session = self.getSelectedAudioSession()
        own_uri = '%s@%s' % (session.account.id.username, session.account.id.domain)
        return session and (self.isConferenceParticipant(uri) or self.isInvitedParticipant(uri)) and own_uri != uri

    @objc.python_method
    def getConferenceTitle(self):
        title = None
        session = self.getSelectedAudioSession()
        if session:
            if session.conference_info is not None:
                conf_desc = session.conference_info.conference_description
                title = "%s <%s>" % (conf_desc.display_text, session.displayTitleLong) if conf_desc.display_text else "%s" % session.displayTitleLong
            else:
                title = "%s" % session.displayTitleShort if isinstance(session.account, BonjourAccount) else "%s" % session.displayTitleLong
        return title

    @objc.python_method
    def updateParticipantsView(self):
        session = self.getSelectedAudioSession()

        participants, self.participants = self.participants, []
        for item in set(participants).difference(session.invited_participants if session else []):
            item.destroy()
        del participants

        if session and session.conference_info is not None:
            self.participantMenu.itemWithTag_(PARTICIPANTS_MENU_GOTO_CONFERENCE_WEBSITE).setEnabled_(True if self.canGoToConferenceWebsite() else False)

            if session.account is BonjourAccount():
                own_uri = '%s@%s' % (session.account.uri.user.decode(), session.account.uri.host.decode())
            else:
                own_uri = '%s@%s' % (session.account.id.username, session.account.id.domain)

            path = self.iconPathForSelf()
            own_icon = NSImage.alloc().initWithContentsOfFile_(path) if path else None

            for user in session.conference_info.users:
                uri = sip_prefix_pattern.sub("", user.entity)
                active_media = []

                chat_endpoints = [endpoint for endpoint in user if any(media.media_type == 'message' for media in endpoint)]
                if chat_endpoints:
                    active_media.append('message')

                audio_endpoints = [endpoint for endpoint in user if any(media.media_type == 'audio' for media in endpoint)]
                user_on_hold = all(endpoint.status == 'on-hold' for endpoint in audio_endpoints)
                if audio_endpoints and not user_on_hold:
                    active_media.append('audio')
                elif audio_endpoints and user_on_hold:
                    active_media.append('audio-onhold')

                presence_contact = self.getFirstContactFromAllContactsGroupMatchingURI(uri)
                if presence_contact:
                    display_name = user.display_text.value if user.display_text is not None and user.display_text.value else presence_contact.name
                    contact = BlinkConferenceContact(uri, name=display_name, icon=presence_contact.icon, presence_contact=presence_contact)
                else:
                    display_name = user.display_text.value if user.display_text is not None and user.display_text.value else uri
                    contact = BlinkConferenceContact(uri, name=display_name)

                contact.active_media = active_media

                # detail will be reset on receiving of the next conference-info update
                if uri in session.pending_removal_participants:
                    contact.detail = NSLocalizedString("Removal requested...", "Participants contextual menu item")

                if own_uri and own_icon and contact.uri == own_uri:
                    contact.avatar = Avatar(own_icon)

                if contact not in self.participants:
                    self.participants.append(contact)

            self.participants.sort(key=attrgetter('name'))

            # Add invited participants if any
            if session.invited_participants:
                for contact in session.invited_participants:
                    self.participants.append(contact)

        self.participantsTableView.reloadData()
        self.recalculateDrawerSplitter()

    @objc.python_method
    def detachVideo(self, sessionController):
        session = self.getSelectedAudioSession()
        if session == sessionController:
            self.videoView.aspect_ratio = None
            self.setVideoProducer(None)

    @objc.python_method
    def setVideoProducer(self, producer=None):
        if self.videoView.setProducer(producer):
            self.recalculateDrawerSplitter()

    @objc.python_method
    def groupForContact(self, item):
        """The group a contact row belongs to, whichever list it came from.

        The contacts outline knows this structurally, through parentForItem_.
        The search outline does not: searchContacts builds a FLAT list, so a row
        there has no parent at all and parentForItem_ answers None -- for the
        contacts outline as well, because the row was never in it.

        That asymmetry is why Delete never appeared on a search result. The menu
        asked the contacts outline about a row it had never seen, got None, and
        silently dropped every item that depends on knowing the group. Nothing
        logged it, and the row looked undeletable rather than mislocated.

        The search list holds the SAME contact objects as the groups it was
        built from (searchContacts iterates model.groupsList), so a scan finds
        the real group and the Delete that follows acts on the real thing.
        """
        if item is None or isinstance(item, BlinkGroup):
            return None
        group = self.contactOutline.parentForItem_(item)
        if group is not None:
            return group
        for group in self.model.groupsList:
            try:
                if item in group.contacts:
                    return group
            except Exception:
                continue
        return None

    @objc.python_method
    def isRowInDeletedGroup(self, item):
        group = self.groupForContact(item)
        return bool(group is not None and getattr(group, "isDeletedGroup", None) and group.isDeletedGroup())

    @objc.python_method
    def _deletedGroupActionContacts(self, sender):
        item = sender.representedObject() if sender.respondsToSelector_("representedObject") else None
        if isinstance(item, BlinkContact) and self.contactOutline.numberOfSelectedRows() <= 1:
            return [item]
        contacts, group = self.selectedContactsForGroupAction()
        if group is None or not (getattr(group, "isDeletedGroup", None) and group.isDeletedGroup()):
            return []
        return contacts

    @objc.IBAction
    def restoreDeletedContacts_(self, sender):
        contacts = self._deletedGroupActionContacts(sender)
        if contacts:
            self.model.restoreDeletedContacts(contacts)
            self.refreshContactsList()
            self.searchContacts()

    @objc.IBAction
    def expungeDeletedContacts_(self, sender):
        contacts = self._deletedGroupActionContacts(sender)
        if contacts:
            self.model.expungeContacts(contacts)
            self.refreshContactsList()
            self.searchContacts()

    @objc.IBAction
    def restoreDeletedGroup_(self, sender):
        group = sender.representedObject()
        if group is not None and group.contacts:
            self.model.restoreDeletedContacts(list(group.contacts))
            self.refreshContactsList()
            self.searchContacts()

    @objc.IBAction
    def emptyDeletedGroup_(self, sender):
        group = sender.representedObject()
        if group is not None and group.contacts:
            self.model.expungeContacts(list(group.contacts))
            self.refreshContactsList()
            self.searchContacts()

    @objc.python_method
    def selectedContactsForGroupAction(self):
        # The contacts of a multiple selection in the contact list, with the
        # group they were selected in. The selection is confined to one group.
        contacts = self.getSelectedContacts()
        group = None
        if contacts:
            group = self.contactOutline.parentForItem_(contacts[0])
        return contacts, group

    @objc.python_method
    def updateMultipleContactsContextMenu(self):
        while self.contactContextMenu.numberOfItems() > 0:
            self.contactContextMenu.removeItemAtIndex_(0)
        contacts, group = self.selectedContactsForGroupAction()

        if group is not None and getattr(group, "isDeletedGroup", None) and group.isDeletedGroup():
            mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Restore", "Menu item"), "restoreDeletedContacts:", "")
            mitem.setEnabled_(bool(contacts))
            self.contactContextMenu.addItem_(NSMenuItem.separatorItem())
            mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Delete Permanently...", "Menu item"), "expungeDeletedContacts:", "")
            mitem.setEnabled_(any(getattr(c, "deletable", False) for c in contacts))
            return

        if contacts and len(self.blockedContactsForItems(contacts)) == len(contacts):
            self.addBlockedContactItems(contacts)
            return

        mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Start Conference", "Menu item"), "startConferenceWithSelectedContacts:", "")
        mitem.setEnabled_(bool(contacts) and self.activeAccount() is not None)

        # The first selected contact is the one the others are merged into.
        mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Merge Contacts...", "Menu item"), "mergeSelectedContacts:", "")
        mitem.setEnabled_(len(contacts) > 1 and self.model.canMergeContacts(contacts[0], contacts[1:]))

        self.contactContextMenu.addItem_(NSMenuItem.separatorItem())

        delete_allowed = group is None or getattr(group, "delete_contact_allowed", False)
        deletable = [c for c in contacts if getattr(c, "deletable", False) and getattr(c, "editable", True) is not False]
        blockable = self.blockableContactsForItems(contacts)
        if blockable and len(blockable) == len(contacts):
            mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Block %d Contacts", "Menu item") % len(contacts), "blockContacts:", "")
            mitem.setRepresentedObject_(contacts)
        mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Delete Contacts...", "Menu item"), "deleteSelectedContacts:", "")
        mitem.setEnabled_(delete_allowed and bool(deletable))

    @objc.python_method
    def _addressbookContact(self, item):
        contact = getattr(item, 'contact', None)
        if contact is not None and not isinstance(contact, Contact):
            contact = getattr(contact, 'contact', None)     # a history row's contact is a BlinkContact
        return contact if isinstance(contact, Contact) else None

    @objc.python_method
    def blockedContactsForItems(self, items):
        """The addressbook contacts among items that are in the Blocked group."""
        group = _blocked_group()
        if group is None:
            return []
        contacts = {}
        for item in items:
            contact = self._addressbookContact(item)
            if contact is not None and contact.id in group.contacts:
                contacts[contact.id] = contact
        return list(contacts.values())

    @objc.python_method
    def blockableContactsForItems(self, items):
        """The addressbook contacts Block applies to: not blocked already, not deleted;
        [] when any item is something else."""
        contacts = {}
        for item in items:
            contact = self._addressbookContact(item)
            if contact is None or self.isRowInDeletedGroup(item) or self.blockedContactsForItems([item]):
                return []
            contacts[contact.id] = contact
        return list(contacts.values())

    @objc.python_method
    def addBlockedContactItems(self, items):
        """What a blocked contact offers: Unblock and Delete."""
        n = len(self.blockedContactsForItems(items))
        title = NSLocalizedString("Unblock...", "Menu item") if n == 1 else NSLocalizedString("Unblock %d Contacts...", "Menu item") % n
        mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(title, "unblockContacts:", "")
        mitem.setRepresentedObject_(list(items))
        if len(items) == 1:
            mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Delete", "Menu item"), "deleteItem:", "")
            mitem.setEnabled_(getattr(items[0], "deletable", False))
            mitem.setRepresentedObject_(items[0])
        else:
            mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Delete Contacts...", "Menu item"), "deleteSelectedContacts:", "")
            mitem.setEnabled_(any(getattr(c, "deletable", False) for c in items))

    @objc.IBAction
    def blockContacts_(self, sender):
        items = sender.representedObject()
        items = list(items) if isinstance(items, (list, tuple)) else [items] if items is not None else []
        contacts = self.blockableContactsForItems(items)
        if contacts:
            block_contacts(contacts)
            self.refreshContactsList()
            self.searchContacts()

    @objc.IBAction
    def unblockContacts_(self, sender):
        items = sender.representedObject()
        items = list(items) if isinstance(items, (list, tuple)) else [items] if items is not None else []
        contacts = self.blockedContactsForItems(items)
        if contacts and self.model.confirmUnblock(items):
            unblock_contacts(contacts)
            self.refreshContactsList()
            self.searchContacts()

    @objc.IBAction
    def startConferenceWithSelectedContacts_(self, sender):
        account = self.activeAccount()
        if account is None:
            return
        contacts, group = self.selectedContactsForGroupAction()
        participants = []
        for contact in contacts:
            uri = str(contact.uri or '').strip()
            uri = re.sub(r'^sips?:', '', uri, flags=re.I)
            if not uri:
                continue
            if '@' not in uri:
                uri = '%s@%s' % (uri, account.id.domain)
            if uri not in participants:
                participants.append(uri)
        if not participants:
            return
        conference = self.showJoinConferenceWindow(participants=participants, default_domain=account.id.domain)
        if conference is not None:
            self.startConferenceIfAppropriate(conference, play_initial_announcement=True)
        self.joinConferenceWindow.release()
        self.joinConferenceWindow = None

    @objc.IBAction
    def mergeSelectedContacts_(self, sender):
        contacts, group = self.selectedContactsForGroupAction()
        if len(contacts) < 2:
            return
        target = contacts[0]
        if self.model.mergeContacts(target, contacts[1:]):
            self.refreshContactsList()
            row = self.contactOutline.rowForItem_(target)
            if row >= 0:
                self.contactOutline.selectRowIndexes_byExtendingSelection_(NSIndexSet.indexSetWithIndex_(row), False)
                self.contactOutline.scrollRowToVisible_(row)
            self.searchContacts()

    @objc.IBAction
    def deleteSelectedContacts_(self, sender):
        contacts, group = self.selectedContactsForGroupAction()
        if group is not None and getattr(group, "isDeletedGroup", None) and group.isDeletedGroup():
            self.model.expungeContacts(contacts)
            self.refreshContactsList()
            self.searchContacts()
            return
        if group is not None and not getattr(group, "delete_contact_allowed", False):
            return
        contacts = [c for c in contacts if getattr(c, "editable", True) is not False]
        if not contacts:
            return
        self.model.deleteContacts(contacts)
        self.refreshContactsList()
        self.searchContacts()

    @objc.python_method
    def logContactContextMenu(self, menu):
        """One Activity log line per right click on a contact: who, and the
        menu items offered, the disabled ones in brackets. A debugging aid."""
        try:
            names = []
            rows = []
            indexes = self.contactOutline.selectedRowIndexes()
            index = indexes.firstIndex() if indexes is not None else NSNotFound
            while index != NSNotFound and len(rows) < 20:
                rows.append(index)
                index = indexes.indexGreaterThanIndex_(index)
            for row in rows:
                item = self.contactOutline.itemAtRow_(row)
                name = getattr(item, 'name', None) or str(getattr(item, 'uri', '') or '')
                if name:
                    names.append(str(name))
            autoenables = menu.autoenablesItems()
            offered = []
            for item in menu.itemArray():
                if item.isSeparatorItem():
                    continue
                title = str(item.title() or '').strip()
                if not title:
                    continue
                enabled = item.isEnabled()
                if enabled and autoenables and item.action() is None and not item.hasSubmenu():
                    enabled = False     # AppKit greys an item with no action
                offered.append(title if enabled else '[%s]' % title)
            BlinkLogger().log_info('Contact menu for %s: %s' % (', '.join(names) or '-', ', '.join(offered) or '(empty)'))
        except Exception as e:
            BlinkLogger().log_debug('Cannot describe the contact menu: %s' % e)

    @objc.python_method
    def updateContactContextMenu(self):
        from ContactMangler import mangled_name, mangled_uri
        settings = SIPSimpleSettings()
        if self.mainTabView.selectedTabViewItem().identifier() == "contacts" and self.contactOutline.numberOfSelectedRows() > 1:
            self.updateMultipleContactsContextMenu()
            return
        if self.mainTabView.selectedTabViewItem().identifier() == "contacts":
            sel = self.contactOutline.selectedRow()
            if sel < 0:
                item = None
            else:
                item = self.contactOutline.itemAtRow_(sel)
        else:
            sel = self.searchOutline.selectedRow()
            if sel < 0:
                item = None
            else:
                item = self.searchOutline.itemAtRow_(sel)

        if item is None:
            for item in self.contactContextMenu.itemArray():
                item.setEnabled_(False)
            return

        while self.contactContextMenu.numberOfItems() > 0:
            self.contactContextMenu.removeItemAtIndex_(0)

        if isinstance(item, BlinkPendingWatcher):
            lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("%s Subscribed To My Availability", "Menu item") % item.uri, "", "")
            lastItem.setEnabled_(False)
            if not self.hasContactMatchingURI(item.uri, exact_match=True, skip_system_address_book=True):
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Accept Request and Create Contact...", "Menu item"), "createPresenceContactFromOtherContact:", "")
                lastItem.setIndentationLevel_(1)
                lastItem.setRepresentedObject_(item)
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Drag Request To An Existing Contact To Accept It", "Menu item"), "", "")
                lastItem.setIndentationLevel_(1)
                lastItem.setEnabled_(False)
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Block Request", "Menu item"), "blockPresenceForURI:", "")
                lastItem.setRepresentedObject_(item)
                lastItem.setIndentationLevel_(1)
            else:
                all_contacts_with_uri = self.model.getBlinkContactsForURI(item.uri, exact_match=True)
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Accept Request", "Menu item"), "allowPresenceForContacts:", "")
                lastItem.setIndentationLevel_(1)
                lastItem.setRepresentedObject_(all_contacts_with_uri)
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Block Request", "Menu item"), "blockPresenceForContacts:", "")
                lastItem.setIndentationLevel_(1)
                lastItem.setRepresentedObject_(all_contacts_with_uri)

            lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Copy", "Menu item"), "copyURI:", "")
            lastItem.setRepresentedObject_(item)
            lastItem.setIndentationLevel_(1)

            blink_contacts_with_same_name = self.model.getBlinkContactsForName(item.name)
            if blink_contacts_with_same_name:
                name_submenu = NSMenu.alloc().init()
                for blink_contact in blink_contacts_with_same_name:
                    name_item = name_submenu.addItemWithTitle_action_keyEquivalent_('%s (%s)' % (mangled_name(blink_contact.name, uri=blink_contact.uri), mangled_uri(blink_contact.uri)), "mergeContacts:", "")
                    name_item.setRepresentedObject_((item, blink_contact))    # (source, destination)
                if name_submenu.itemArray():
                    mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Add %s to", "Menu item") % item.uri, "", "")
                    self.contactContextMenu.setSubmenu_forItem_(name_submenu, mitem)

            self.contactContextMenu.addItem_(NSMenuItem.separatorItem())

        history_contact = None

        if isinstance(item, HistoryBlinkContact):
            history_contact = item
            if isinstance(item.contact, BlinkContact):
                item = item.contact

        if isinstance(item, BlinkContact):
            if hasattr(item, 'job_title') and hasattr(item, 'organization'):
                if item.name and item.job_title and item.organization:
                    _name = "%s, %s" % (str(item.name), str(item.job_title)) + NSLocalizedString(" at %s", "Organization name follows") % str(item.organization)
                elif item.name and item.job_title:
                    _name = "%s, %s" % (str(item.name), str(item.job_title))
                elif item.name and item.organization:
                    _name = str(item.name) + NSLocalizedString(" at %s", "Organization name follows") % str(item.organization)
                elif item.organization:
                    _name = str(item.organization)
                else:
                    _name = str(item.name)
            elif hasattr(item, 'organization'):
                if item.name and item.organization:
                    _name = str(item.name) + NSLocalizedString(" at %s", "Organization name follows") % str(item.organization)
                elif item.name:
                    _name = str(item.name)
                else:
                    _name = str(item.organization)
            else:
                _name = str(item.name)

            mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(_name, "", "")
            mitem.setEnabled_(False)
            self.contactContextMenu.addItem_(NSMenuItem.separatorItem())

            # A removed conversation. Nothing has actually been deleted yet:
            # the messages are marked and hidden, the files they carried are
            # still on disc, and these two items are the fork -- have it all
            # back, or have it gone for good. Offered first because that is
            # the only decision to make about a row in this group.
            if self.isRowInDeletedGroup(item):
                # Sylk Mobile's Deleted folder: the contact comes back as it
                # was, or goes for good -- XCAP, messages, files, and the
                # other devices told. Nothing else: a deleted contact is not
                # called or written to.
                mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(
                    NSLocalizedString("Restore", "Menu item"), "restoreDeletedContacts:", "")
                mitem.setRepresentedObject_(item)
                mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(
                    NSLocalizedString("Delete Permanently...", "Menu item"), "expungeDeletedContacts:", "")
                mitem.setEnabled_(getattr(item, "deletable", False))
                mitem.setRepresentedObject_(item)
                # Still editable (its groups and presence subscription stay as
                # they are until it is restored)
                if isinstance(item, BlinkPresenceContact):
                    self.contactContextMenu.addItem_(NSMenuItem.separatorItem())
                    mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(
                        NSLocalizedString("Edit", "Menu item"), "editContact:", "")
                    mitem.setEnabled_(bool(getattr(item, "editable", False)))
                    mitem.setRepresentedObject_(item)
                return
            if self.blockedContactsForItems([item]):
                # Blocked, wherever it shows: unblocked or deleted, nothing
                # else (no calls or messages to them), as on Blink Qt.
                self.addBlockedContactItems([item])
                return
            if self._deletedConversationURIs(item):
                mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(
                    NSLocalizedString("Restore Conversation", "Menu item"),
                    "restoreDeletedConversation:", "")
                mitem.setRepresentedObject_(item)
                mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(
                    NSLocalizedString("Delete Conversation Permanently...", "Menu item"),
                    "purgeDeletedConversation:", "")
                mitem.setRepresentedObject_(item)
                self.contactContextMenu.addItem_(NSMenuItem.separatorItem())

            # A Bonjour row. The generic Delete is not offered for these --
            # the group forbids it and the contact is not deletable, both
            # correctly, because there is no address book entry to delete.
            # What there is, is a conversation. For a neighbour who has left
            # the network the row exists only because of it, so deleting the
            # conversation deletes the row; for one who is here, the row
            # comes from discovery and stays, so only the messages go.
            if type(item) is BonjourBlinkContact:
                offline = item.bonjour_neighbour is None
                # A Bonjour row cannot be edited -- there is no address book
                # entry behind it, and there must not be one -- but the name
                # it shows is whatever the far end announces, which is often
                # a machine's idea of a person. This renames it locally.
                mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(
                    NSLocalizedString("Rename...", "Menu item"),
                    "renameBonjourContact:", "")
                mitem.setRepresentedObject_(item)
                title = (NSLocalizedString("Delete Conversation...", "Menu item") if offline
                         else NSLocalizedString("Delete Messages...", "Menu item"))
                mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(
                    title, "deleteBonjourConversation:", "")
                mitem.setRepresentedObject_(item)
                mitem.setEnabled_(
                    self.model.bonjour_group.has_conversation(item.id) or offline)
                self.contactContextMenu.addItem_(NSMenuItem.separatorItem())

            has_fully_qualified_sip_uri = is_sip_aor_format(item.uri)

            gruu_devices = None
            if isinstance(item, BlinkPresenceContact):
                gruu_devices = [device for device in list(item.presence_state['devices'].values()) if device['contact'] not in device['aor'] and device['description']]

            has_multiple_uris = len(item.uris) > 1

            if (has_multiple_uris or gruu_devices) and not isinstance(item, BlinkBlockedPresenceContact):
                # Contact has multiple URIs
                audio_submenu = NSMenu.alloc().init()
                audio_submenu.setAutoenablesItems_(False)

                for uri in sorted(item.uris, key=lambda uri: uri.position if uri.position is not None else sys.maxsize):
                    if uri.type is not None and uri.type.lower() == 'url':
                        continue
                        
                    if uri.type == 'Bonjour':
                        continue

                    audio_item = audio_submenu.addItemWithTitle_action_keyEquivalent_('%s (%s)' % (mangled_uri(uri.uri), format_uri_type(uri.type)), "startAudioToSelected:", "")
                    target_uri = uri.uri+';xmpp' if uri.type is not None and uri.type.lower() == 'xmpp' else uri.uri
                    audio_item.setRepresentedObject_(target_uri)
                    if isinstance(item, BlinkPresenceContact):
                        status = presence_status_for_contact(item, uri.uri) or 'offline'
                        icon = self.presence_dots[status]
                        icon.setScalesWhenResized_(True)
                        icon.setSize_(NSMakeSize(15, 15))
                        audio_item.setImage_(icon)

                if gruu_devices:
                    audio_submenu.addItem_(NSMenuItem.separatorItem())
                    audio_item = audio_submenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Online Devices", "Menu item"), "", "")
                    audio_item.setEnabled_(False)

                    for device in gruu_devices:
                        if device['user_agent'] and device['local_time']:
                            title = '%s @ %s %s' % (str(device['user_agent']), str(device['description']), device['local_time'])
                        else:
                            title = str(device['description'])
                        title += ' in %s' % str(device['location']) if device['location'] else ''
                        audio_item = audio_submenu.addItemWithTitle_action_keyEquivalent_(title, "startAudioSessionWithSIPURI:", "")
                        audio_item.setRepresentedObject_(device['contact'])

                        status = device['status'] or 'offline'
                        image = self.presence_dots[status]
                        image.setScalesWhenResized_(True)
                        image.setSize_(NSMakeSize(15, 15))
                        audio_item.setImage_(image)

                        audio_item.setIndentationLevel_(1)
                        if device['caps'] is not None and 'audio' not in device['caps']:
                            audio_item.setEnabled_(False)

                mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Start Audio Call", "Menu item"), "", "")
                self.contactContextMenu.setSubmenu_forItem_(audio_submenu, mitem)

                if self.sessionControllersManager.isMediaTypeSupported('video') and settings.video.device:
                    video_submenu = NSMenu.alloc().init()
                    video_submenu.setAutoenablesItems_(False)

                    for uri in sorted(item.uris, key=lambda uri: uri.position if uri.position is not None else sys.maxsize):
                        if uri.type is not None and uri.type.lower() == 'url':
                            continue

                        if uri.type == 'Bonjour':
                            continue

                        video_item = video_submenu.addItemWithTitle_action_keyEquivalent_('%s (%s)' % (mangled_uri(uri.uri), format_uri_type(uri.type)), "startVideoToSelected:", "")
                        target_uri = uri.uri+';xmpp' if uri.type is not None and uri.type.lower() == 'xmpp' else uri.uri
                        video_item.setRepresentedObject_(target_uri)
                        video_item.setEnabled_(self.contactSupportsMedia("video", item, uri.uri) and settings.video.device)

                        if isinstance(item, BlinkPresenceContact):
                            status = presence_status_for_contact(item, uri.uri) or 'offline'
                            icon = self.presence_dots[status]
                            icon.setScalesWhenResized_(True)
                            icon.setSize_(NSMakeSize(15, 15))
                            video_item.setImage_(icon)

                    if gruu_devices:
                        video_submenu.addItem_(NSMenuItem.separatorItem())
                        video_item = video_submenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Online Devices", "Menu item"), "", "")
                        video_item.setEnabled_(False)

                        for device in gruu_devices:
                            if device['user_agent'] and device['local_time']:
                                title = '%s @ %s %s' % (str(device['user_agent']), str(device['description']), device['local_time'])
                            else:
                                title = str(device['description'])
                            title += ' in %s' % str(device['location']) if device['location'] else ''
                            video_item = video_submenu.addItemWithTitle_action_keyEquivalent_(title, "startVideoSessionWithSIPURI:", "")
                            video_item.setRepresentedObject_(device['contact'])

                            status = device['status'] or 'offline'
                            icon = self.presence_dots[status]
                            icon.setScalesWhenResized_(True)
                            icon.setSize_(NSMakeSize(15, 15))
                            video_item.setImage_(icon)

                            video_item.setIndentationLevel_(1)
                            if device['caps'] is not None and 'video' not in device['caps']:
                                video_item.setEnabled_(False)

                    if video_submenu.itemArray():
                        mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Start Video Call", "Menu item"), "", "")
                        self.contactContextMenu.setSubmenu_forItem_(video_submenu, mitem)
                        mitem.setEnabled_(self.contactSupportsMedia("video", item) and settings.video.device)

                if self.sessionControllersManager.isMediaTypeSupported('sms'):
                    sms_submenu = NSMenu.alloc().init()
                    sms_submenu.setAutoenablesItems_(False)
                    for uri in sorted(item.uris, key=lambda uri: uri.position if uri.position is not None else sys.maxsize):
                        if uri.type is not None and uri.type.lower() == 'url':
                            continue

                        if uri.type == 'Bonjour':
                            continue

                        sms_item = sms_submenu.addItemWithTitle_action_keyEquivalent_('%s (%s)' % (mangled_uri(uri.uri), format_uri_type(uri.type)), "sendMessageToSelected:", "")
                        target_uri = uri.uri+';xmpp' if uri.type is not None and uri.type.lower() == 'xmpp' else uri.uri
                        sms_item.setRepresentedObject_(target_uri)
                        if isinstance(item, BlinkPresenceContact):
                            status = presence_status_for_contact(item, uri.uri) or 'offline'
                            icon = self.presence_dots[status]
                            icon.setScalesWhenResized_(True)
                            icon.setSize_(NSMakeSize(15, 15))
                            sms_item.setImage_(icon)
                    mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Send Short Message...", "Menu item"), "", "")
                    self.contactContextMenu.setSubmenu_forItem_(sms_submenu, mitem)

                if self.sessionControllersManager.isPeerToPeerChatEnabled():
                    chat_submenu = NSMenu.alloc().init()
                    chat_submenu.setAutoenablesItems_(False)

                    for uri in sorted(item.uris, key=lambda uri: uri.position if uri.position is not None else sys.maxsize):
                        if uri.type is not None and uri.type.lower() == 'url':
                            continue

                        if uri.type == 'Bonjour':
                            continue

                        chat_item = chat_submenu.addItemWithTitle_action_keyEquivalent_('%s (%s)' % (mangled_uri(uri.uri), format_uri_type(uri.type)), "startChatToSelected:", "")
                        target_uri = uri.uri+';xmpp' if uri.type is not None and uri.type.lower() == 'xmpp' else uri.uri
                        chat_item.setRepresentedObject_(target_uri)
                        chat_item.setEnabled_(self.contactSupportsMedia("chat", item, uri.uri))

                        if isinstance(item, BlinkPresenceContact):
                            status = presence_status_for_contact(item, uri.uri) or 'offline'
                            icon = self.presence_dots[status]
                            icon.setScalesWhenResized_(True)
                            icon.setSize_(NSMakeSize(15, 15))
                            chat_item.setImage_(icon)

                    if gruu_devices:
                        chat_submenu.addItem_(NSMenuItem.separatorItem())
                        chat_item = chat_submenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Online Devices", "Menu item"), "", "")
                        chat_item.setEnabled_(False)

                        for device in gruu_devices:
                            if device['user_agent'] and device['local_time']:
                                title = '%s @ %s %s' % (str(device['user_agent']), str(device['description']), device['local_time'])
                            else:
                                title = str(device['description'])
                            title += ' in %s' % str(device['location']) if device['location'] else ''
                            chat_item = chat_submenu.addItemWithTitle_action_keyEquivalent_(title, "startChatSessionWithSIPURI:", "")
                            chat_item.setRepresentedObject_(device['contact'])

                            status = device['status'] or 'offline'
                            icon = self.presence_dots[status]
                            icon.setScalesWhenResized_(True)
                            icon.setSize_(NSMakeSize(15, 15))
                            chat_item.setImage_(icon)

                            chat_item.setIndentationLevel_(1)
                            if device['caps'] is not None and 'chat' not in device['caps']:
                                chat_item.setEnabled_(False)

                    if chat_submenu.itemArray():
                        mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Invite to Chat...", "Menu item"), "", "")
                        self.contactContextMenu.setSubmenu_forItem_(chat_submenu, mitem)
                        mitem.setEnabled_(self.contactSupportsMedia("chat", item))

                if isinstance(item, BlinkPresenceContact) or isinstance(item, BonjourBlinkContact):
                    if self.sessionControllersManager.isMediaTypeSupported('file-transfer'):
                        ft_submenu = NSMenu.alloc().init()
                        ft_submenu.setAutoenablesItems_(False)
                        for uri in sorted(item.uris, key=lambda uri: uri.position if uri.position is not None else sys.maxsize):
                            if uri.type is not None and uri.type.lower() == 'url':
                                continue

                            if uri.type == 'Bonjour':
                                continue

                            ft_item = ft_submenu.addItemWithTitle_action_keyEquivalent_('%s (%s)' % (mangled_uri(uri.uri), format_uri_type(uri.type)), "sendFile:", "")
                            target_uri = uri.uri+';xmpp' if uri.type is not None and uri.type.lower() == 'xmpp' else uri.uri
                            ft_item.setRepresentedObject_(target_uri)
                            ft_item.setEnabled_(self.contactSupportsMedia("file-transfer", item, uri.uri))

                            if isinstance(item, BlinkPresenceContact):
                                status = presence_status_for_contact(item, uri.uri) or 'offline'
                                icon = self.presence_dots[status]
                                icon.setScalesWhenResized_(True)
                                icon.setSize_(NSMakeSize(15, 15))
                                ft_item.setImage_(icon)

                        if gruu_devices:
                            ft_submenu.addItem_(NSMenuItem.separatorItem())
                            ft_submenu.setAutoenablesItems_(False)
                            ft_item = ft_submenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Online Devices", "Menu item"), "", "")
                            ft_item.setEnabled_(False)

                            for device in gruu_devices:
                                if device['user_agent'] and device['local_time']:
                                    title = '%s @ %s %s' % (str(device['user_agent']), str(device['description']), device['local_time'])
                                else:
                                    title = str(device['description'])
                                title += ' in %s' % str(device['location']) if device['location'] else ''
                                ft_item = ft_submenu.addItemWithTitle_action_keyEquivalent_(title, "sendFile:", "")
                                ft_item.setRepresentedObject_(device['contact'])

                                status = device['status'] or 'offline'
                                icon = self.presence_dots[status]
                                icon.setScalesWhenResized_(True)
                                icon.setSize_(NSMakeSize(15, 15))
                                ft_item.setImage_(icon)

                                ft_item.setIndentationLevel_(1)
                                if device['caps'] is not None and 'file-transfer' not in device['caps']:
                                    ft_item.setEnabled_(False)

                        if ft_submenu.itemArray():
                            mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Send Files...", "Menu item"), "", "")
                            self.contactContextMenu.setSubmenu_forItem_(ft_submenu, mitem)
                            mitem.setEnabled_(self.contactSupportsMedia("file-transfer", item))

                    if NSApp.delegate().screen_sharing_ui_enabled and self.sessionControllersManager.isMediaTypeSupported('screen-sharing-client'):
                        ds_submenu = NSMenu.alloc().init()
                        ds_submenu.setAutoenablesItems_(False)
                        for uri in sorted(item.uris, key=lambda uri: uri.position if uri.position is not None else sys.maxsize):
                            if uri.type is not None and uri.type.lower() == 'url':
                                continue

                            if uri.type == 'Bonjour':
                                continue

                            ds_item = ds_submenu.addItemWithTitle_action_keyEquivalent_('%s (%s)' % (mangled_uri(uri.uri), format_uri_type(uri.type)), "startScreenSharing:", "")
                            ds_item.setRepresentedObject_(uri.uri)
                            ds_item.setTag_(1)
                            ds_item.setEnabled_(self.contactSupportsMedia("screen-sharing-server", item, uri.uri))

                            if isinstance(item, BlinkPresenceContact):
                                status = presence_status_for_contact(item, uri.uri) or 'offline'
                                icon = self.presence_dots[status]
                                icon.setScalesWhenResized_(True)
                                icon.setSize_(NSMakeSize(15, 15))
                                ds_item.setImage_(icon)

                        if gruu_devices:
                            ds_submenu.addItem_(NSMenuItem.separatorItem())
                            ds_item = ds_submenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Online Devices", "Menu item"), "", "")
                            ds_item.setEnabled_(False)

                            for device in gruu_devices:
                                if device['user_agent'] and device['local_time']:
                                    title = '%s @ %s %s' % (str(device['user_agent']), str(device['description']), device['local_time'])
                                else:
                                    title = str(device['description'])
                                title += ' in %s' % str(device['location']) if device['location'] else ''
                                ds_item = ds_submenu.addItemWithTitle_action_keyEquivalent_(title, "startScreenSharing:", "")
                                ds_item.setRepresentedObject_(device['contact'])

                                status = device['status'] or 'offline'
                                icon = self.presence_dots[status]
                                icon.setScalesWhenResized_(True)
                                icon.setSize_(NSMakeSize(15, 15))
                                ds_item.setImage_(icon)

                                ds_item.setIndentationLevel_(1)
                                if device['caps'] is not None and 'screen-sharing-server' not in device['caps']:
                                    ds_item.setEnabled_(False)

                        if ds_submenu.itemArray():
                            mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Request Screen Sharing", "Menu item"), "", "")
                            self.contactContextMenu.setSubmenu_forItem_(ds_submenu, mitem)
                            mitem.setEnabled_(self.contactSupportsMedia("screen-sharing-server", item))

                    if NSApp.delegate().screen_sharing_ui_enabled and self.sessionControllersManager.isMediaTypeSupported('screen-sharing-server'):
                        ds_submenu = NSMenu.alloc().init()
                        ds_submenu.setAutoenablesItems_(False)
                        for uri in sorted(item.uris, key=lambda uri: uri.position if uri.position is not None else sys.maxsize):
                            if uri.type is not None and uri.type.lower() == 'url':
                                continue

                            if uri.type == 'Bonjour':
                                continue

                            ds_item = ds_submenu.addItemWithTitle_action_keyEquivalent_('%s (%s)' % (mangled_uri(uri.uri), format_uri_type(uri.type)), "startScreenSharing:", "")
                            ds_item.setRepresentedObject_(uri.uri)
                            ds_item.setTag_(2)
                            ds_item.setEnabled_(self.contactSupportsMedia("screen-sharing-client", item, uri.uri))

                            if isinstance(item, BlinkPresenceContact):
                                status = presence_status_for_contact(item, uri.uri) or 'offline'
                                icon = self.presence_dots[status]
                                icon.setScalesWhenResized_(True)
                                icon.setSize_(NSMakeSize(15, 15))
                                ds_item.setImage_(icon)

                        if gruu_devices:
                            ds_submenu.addItem_(NSMenuItem.separatorItem())
                            ds_item = ds_submenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Online Devices", "Menu item"), "", "")
                            ds_item.setEnabled_(False)

                            for device in gruu_devices:
                                if device['user_agent'] and device['local_time']:
                                    title = '%s @ %s %s' % (str(device['user_agent']), str(device['description']), device['local_time'])
                                else:
                                    title = str(device['description'])
                                title += ' in %s' % str(device['location']) if device['location'] else ''
                                ds_item = ds_submenu.addItemWithTitle_action_keyEquivalent_(title, "startScreenSharing:", "")
                                ds_item.setRepresentedObject_(device['contact'])
                                status = device['status'] or 'offline'

                                icon = self.presence_dots[status]
                                icon.setScalesWhenResized_(True)
                                icon.setSize_(NSMakeSize(15, 15))
                                ds_item.setImage_(icon)

                                ds_item.setTag_(2)
                                ds_item.setIndentationLevel_(1)
                                if device['caps'] is not None and 'screen-sharing-client' not in device['caps']:
                                    ds_item.setEnabled_(False)

                        if ds_submenu.itemArray():
                            mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Share My Screen", "Menu item"), "", "")
                            self.contactContextMenu.setSubmenu_forItem_(ds_submenu, mitem)
                            mitem.setEnabled_(self.contactSupportsMedia("screen-sharing-client", item, uri.uri))

                urls = list(uri.uri for uri in item.uris if uri.type is not None and uri.type.lower() == 'url')
                if isinstance(item, BlinkPresenceContact):
                    for url in item.presence_state['urls']:
                        if url not in urls:
                            urls.append(url)
                if urls:
                    urls_submenu = NSMenu.alloc().init()
                    urls_submenu.setAutoenablesItems_(False)
                    for url in urls:
                        url_item = urls_submenu.addItemWithTitle_action_keyEquivalent_(url, "openURL:", "")
                        url_item.setRepresentedObject_(url)
                    mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Open URL", "Menu item"), "", "")
                    self.contactContextMenu.setSubmenu_forItem_(urls_submenu, mitem)

            else:
                # Contact has a single URI
                if isinstance(item, VoicemailBlinkContact):
                    audio_item = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Start Audio Call", "Menu item"), "startAudioToSelected:", "")
                    audio_item.setEnabled_(bool(item.uri))
                    return

                if not isinstance(item, BlinkBlockedPresenceContact) and not is_anonymous(item.uri):
                    # A Bonjour neighbour who is not on the network cannot be
                    # called: there is no route to resolve and nothing to
                    # ring. The item stayed enabled and the call simply
                    # failed, which the user had to read an error to
                    # understand.
                    reachable = not (type(item) is BonjourBlinkContact
                                     and item.bonjour_neighbour is None)
                    audio_item = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Start Audio Call", "Menu item"), "startAudioToSelected:", "")
                    audio_item.setEnabled_(reachable)

                    if self.sessionControllersManager.isMediaTypeSupported('video'):
                        video_item = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Start Video Call", "Menu item"), "startVideoToSelected:", "")
                        video_item.setEnabled_(reachable
                                               and self.contactSupportsMedia("video", item, item.uri)
                                               and settings.video.device)

                    if self.sessionControllersManager.isMediaTypeSupported('sms'):
                        if has_fully_qualified_sip_uri:
                            sms_item = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Send Short Message...", "Menu item"), "sendMessageToSelected:", "")
                            sms_item.setEnabled_(self.contactSupportsMedia("sms", item, item.uri))

                    if self.sessionControllersManager.isPeerToPeerChatEnabled():
                        if has_fully_qualified_sip_uri:
                            chat_item = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Invite to Chat...", "Menu item"), "startChatToSelected:", "")
                            chat_item.setEnabled_(self.contactSupportsMedia("chat", item, item.uri))

                    if self.sessionControllersManager.isMediaTypeSupported('file-transfer'):
                        if has_fully_qualified_sip_uri:
                            ft_item = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Send Files...", "Menu item"), "sendFile:", "")
                            ft_item.setEnabled_(self.contactSupportsMedia("file-transfer", item, item.uri))

                    if NSApp.delegate().screen_sharing_ui_enabled and self.sessionControllersManager.isMediaTypeSupported('screen-sharing-client'):
                        mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Request Screen Sharing", "Menu item"), "startScreenSharing:", "")
                        mitem.setTag_(1)
                        mitem.setEnabled_(has_fully_qualified_sip_uri)
                        mitem.setRepresentedObject_(item.uri)
                        mitem.setEnabled_(self.contactSupportsMedia("screen-sharing-server", item, item.uri))

                    if NSApp.delegate().screen_sharing_ui_enabled and self.sessionControllersManager.isMediaTypeSupported('screen-sharing-server'):
                        mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Share My Screen", "Menu item"), "startScreenSharing:", "")
                        mitem.setTag_(2)
                        mitem.setRepresentedObject_(item.uri)
                        mitem.setEnabled_(self.contactSupportsMedia("screen-sharing-client", item, item.uri))

            if isinstance(item, BlinkPresenceContact) or isinstance(item, BonjourBlinkContact):
                self.contactContextMenu.addItem_(NSMenuItem.separatorItem())
                history_item = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("History...", "Menu item"), "viewHistory:", "")
                history_item.setRepresentedObject_(item)
                history_item.setEnabled_(NSApp.delegate().history_enabled)

                all_uris = []
                for uri in sorted(item.uris, key=lambda uri: uri.position if uri.position is not None else sys.maxsize):
                    all_uris.append(str(uri.uri))
                # get_recordings matches the filename's user@host against these
                # by exact string, and a recording is named after the URI that
                # was dialled -- not after the E.164 a contact stores. Same
                # expansion the conversation queries use.
                for uri in list(all_uris):
                    for spelling in pstn_uri_spellings_for_accounts(uri):
                        if spelling not in all_uris:
                            all_uris.append(spelling)
                recordings = self.backend.get_recordings(all_uris)[-10:]

                if recordings:
                    audio_recordings_submenu = NSMenu.alloc().init()
                    for dt, name, f, t in recordings:
                        r_item = audio_recordings_submenu.insertItemWithTitle_action_keyEquivalent_atIndex_(dt, "recordingClicked:", "", 0)
                        r_item.setTarget_(self)
                        r_item.setRepresentedObject_(f)

                    mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Audio Recordings", "Menu item"), "", "")
                    self.contactContextMenu.setSubmenu_forItem_(audio_recordings_submenu, mitem)

                    if history_contact and history_contact.answering_machine_filenames:
                        voice_messages_submenu = NSMenu.alloc().init()
                        for dt, name, f, t in recordings:
                            if f not in history_contact.answering_machine_filenames:
                                continue
                            r_item = voice_messages_submenu.insertItemWithTitle_action_keyEquivalent_atIndex_(dt, "recordingClicked:", "", 0)
                            r_item.setTarget_(self)
                            r_item.setRepresentedObject_(f)

                        mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Voice Messages", "Menu item"), "", "")
                        self.contactContextMenu.setSubmenu_forItem_(voice_messages_submenu, mitem)

                mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Last Calls", "Menu item"), "", "")
                self.contactContextMenu.setSubmenu_forItem_(self.last_calls_submenu, mitem)
                self.get_last_calls_entries_for_contact(item)

            if history_contact is not None:
                mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Copy To Search Bar", "Menu item"), "copyToSearchBar:", "")
                mitem.setRepresentedObject_(str(history_contact.uri))
                mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Hide Entry", "Menu item"), "hideHistoryEntries:", "")
                mitem.setRepresentedObject_(history_contact.session_ids)

            elif history_contact is not None:
                if NSApp.delegate().history_enabled:
                    if history_contact not in self.model.bonjour_group.contacts:
                        if not is_anonymous(history_contact.uris[0].uri):
                            self.contactContextMenu.addItem_(NSMenuItem.separatorItem())
                        mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Show in History Viewer...", "Menu item"), "viewHistoryForContact:", "")
                        group = self.contactOutline.parentForItem_(history_contact)
                        if isinstance(group, MissedCallsBlinkGroup):
                            days = settings.contacts.missed_calls_period
                        elif isinstance(group, IncomingCallsBlinkGroup):
                            days = settings.contacts.incoming_calls_period
                        elif isinstance(group, OutgoingCallsBlinkGroup):
                            days = settings.contacts.outgoing_calls_period
                        else:
                            days = 2

                        o = {'uris': (str(item.uri.lower()),), 'days': days}
                        mitem.setRepresentedObject_(o)

                        mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Last Calls", "Menu item"), "", "")
                        self.contactContextMenu.setSubmenu_forItem_(self.last_calls_submenu, mitem)
                        self.get_last_calls_entries_for_contact(history_contact)

                    if not is_anonymous(history_contact.uris[0].uri):
                        mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Copy To Search Bar", "Menu item"), "copyToSearchBar:", "")
                        mitem.setRepresentedObject_(str(history_contact.uri))
                    mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Hide Entry", "Menu item"), "hideHistoryEntries:", "")
                    mitem.setRepresentedObject_(history_contact.session_ids)

                    if not is_anonymous(history_contact.uris[0].uri) and not self.hasContactMatchingURI(history_contact.uri):
                        self.contactContextMenu.addItem_(NSMenuItem.separatorItem())
                        lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Add to Contacts List...", "Menu item"), "createPresenceContactFromOtherContact:", "")
                        lastItem.setRepresentedObject_(history_contact)

                recordings = self.backend.get_recordings([history_contact.uris[0].uri])[-10:]
                if recordings:
                    audio_recordings_submenu = NSMenu.alloc().init()
                    for dt, name, f, t in recordings:
                        aitem = audio_recordings_submenu.insertItemWithTitle_action_keyEquivalent_atIndex_(dt, "recordingClicked:", "", 0)
                        aitem.setTarget_(self)
                        aitem.setRepresentedObject_(f)

                    mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Audio Recordings", "Menu item"), "", "")
                    self.contactContextMenu.setSubmenu_forItem_(audio_recordings_submenu, mitem)

                    if history_contact.answering_machine_filenames:
                        voice_messages_submenu = NSMenu.alloc().init()
                        for dt, name, f, t in recordings:
                            if f not in history_contact.answering_machine_filenames:
                                continue
                            r_item = voice_messages_submenu.insertItemWithTitle_action_keyEquivalent_atIndex_(dt, "recordingClicked:", "", 0)
                            r_item.setTarget_(self)
                            r_item.setRepresentedObject_(f)

                        mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Voice Messages", "Menu item"), "", "")
                        self.contactContextMenu.setSubmenu_forItem_(voice_messages_submenu, mitem)

            if isinstance(item, BlinkPresenceContact):
                self.contactContextMenu.addItem_(NSMenuItem.separatorItem())
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Edit", "Menu item"), "editContact:", "")
                lastItem.setRepresentedObject_(item)
                pboard = NSPasteboard.generalPasteboard()
                if pboard.availableTypeFromArray_(["x-blink-sip-uri"]):
                    uri = str(pboard.stringForType_("x-blink-sip-uri"))
                    lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Paste %s", "Menu item") % uri, "pasteURI:", "")
                    lastItem.setRepresentedObject_({'uri': uri, 'contact': item})

            elif isinstance(item, BlinkBlockedPresenceContact):
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Delete", "Menu item"), "deletePolicyItem:", "")
                lastItem.setEnabled_(item.deletable)
                lastItem.setRepresentedObject_(item)
            elif isinstance(item, SystemAddressBookBlinkContact):
                self.contactContextMenu.addItem_(NSMenuItem.separatorItem())
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Edit in Address Book...", "Menu item"), "editContact:", "")
                lastItem.setRepresentedObject_(item)
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Add to Contacts List...", "Menu item"), "createPresenceContactFromOtherContact:", "")
                lastItem.setRepresentedObject_(item)
            elif isinstance(item, SearchResultContact):
                self.contactContextMenu.addItem_(NSMenuItem.separatorItem())
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Add to Contacts List...", "Menu item"), "createPresenceContactFromOtherContact:", "")
                lastItem.setRepresentedObject_(item)
            elif isinstance(item, LdapSearchResultContact):
                self.contactContextMenu.addItem_(NSMenuItem.separatorItem())
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Add to Contacts List...", "Menu item"), "createPresenceContactFromOtherContact:", "")
                lastItem.setRepresentedObject_(item)
                blink_contacts_with_same_name = self.model.getBlinkContactsForName(item.name)
                if blink_contacts_with_same_name:
                    name_submenu = NSMenu.alloc().init()
                    for blink_contact in blink_contacts_with_same_name:
                        name_item = name_submenu.addItemWithTitle_action_keyEquivalent_('%s (%s)' % (blink_contact.name, blink_contact.uri), "mergeContacts:", "")
                        name_item.setRepresentedObject_((item, blink_contact))    # (source, destination)
                    if name_submenu.itemArray():
                        mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Add %s to", "Menu item") % item.uri, "", "")
                        self.contactContextMenu.setSubmenu_forItem_(name_submenu, mitem)

            group = self.groupForContact(item)
            if group is not None and getattr(group, "isDeletedGroup", None) and group.isDeletedGroup():
                group = _DeletedGroupMenuPolicy
            # A contact in NO group is still deletable. deleteContact works on
            # the addressbook entry itself, and delete_contact_allowed is a
            # per-group POLICY -- Messages and Deleted refuse it -- not a
            # requirement that a group exist at all. Without this allowance an
            # ungrouped contact, which is what a half-finished write leaves
            # behind, could not be removed from the interface by any means.
            if self.blockableContactsForItems([item]):
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Block", "Menu item"), "blockContacts:", "")
                lastItem.setRepresentedObject_(item)
            if group is None or group.delete_contact_allowed:
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Delete", "Menu item"), "deleteItem:", "")
                lastItem.setEnabled_(item.deletable)
                lastItem.setRepresentedObject_(item)
            if group and group.delete_contact_allowed:
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Delete duplicates", "Menu item"), "deleteDuplicateItems:", "")
                lastItem.setEnabled_(item.deletable)
                lastItem.setRepresentedObject_(item)
            if group and group.remove_contact_allowed:
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Remove From Group", "Menu item"), "removeContactFromGroup:", "")
                lastItem.setEnabled_(item.deletable)
                lastItem.setRepresentedObject_((item, group))

            if isinstance(item, BlinkPresenceContact):
                self.contactContextMenu.addItem_(NSMenuItem.separatorItem())
                mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Tell Me When Contact Is Available", "Menu item"), "tellMeWhenContactBecomesAvailable:", "")
                mitem.setEnabled_(item.contact.presence.subscribe and presence_status_for_contact(item) != 'available')
                mitem.setState_(NSOnState if item.contact in self.tellMeWhenContactBecomesAvailableList else NSOffState)
                mitem.setRepresentedObject_(item.contact)

                mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Show Availability Information...", "Menu item"), "showPresenceInfo:", "")
                mitem.setEnabled_(bool(item.pidfs) if isinstance(item, BlinkPresenceContact) else False)
                mitem.setRepresentedObject_(item)

        elif isinstance(item, BlinkGroup):
            lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("%s Group", "Menu item") % item.name, "", "")
            lastItem.setEnabled_(False)
            self.contactContextMenu.addItem_(NSMenuItem.separatorItem())

            # adding a contact is the Contacts button's (Add Contact... / Add Group...), not a group's
            lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Rename...", "Menu item"), "renameGroup:", "")
            lastItem.setRepresentedObject_(item)
            if isinstance(item, HistoryBlinkGroup) or isinstance(item, OnlineGroup):
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Hide", "Menu item"), "hideGroup:", "")
                lastItem.setRepresentedObject_(item)
            elif getattr(item, "isDeletedGroup", None) and item.isDeletedGroup():
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Restore All", "Menu item"), "restoreDeletedGroup:", "")
                lastItem.setEnabled_(bool(item.contacts))
                lastItem.setRepresentedObject_(item)
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Empty Deleted Group...", "Menu item"), "emptyDeletedGroup:", "")
                lastItem.setEnabled_(bool(item.contacts))
                lastItem.setRepresentedObject_(item)
            else:
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Delete...", "Menu item"), "deleteItem:", "")
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Delete Contacts...", "Menu item"), "deleteContactsFromGroup:", "")
                lastItem.setEnabled_(item.deletable)
                lastItem.setRepresentedObject_(item)

            grp_submenu = NSMenu.alloc().init()
            grp_submenu.setAutoenablesItems_(False)
            grp_item = grp_submenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("To First Position", "Menu item"), "moveGroupToIndex:", "")
            grp_item.setRepresentedObject_({'group': item, 'index': 0})
            for group in self.model.groupsList:
                if group == item:
                    continue
                grp_item = grp_submenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("After %s", "Menu item") % group.name, "moveGroupToIndex:", "")
                index = self.model.groupsList.index(group)
                grp_item.setRepresentedObject_({'group': item, 'index': index+1})
            mitem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Move Group", "Menu item"), "", "")
            self.contactContextMenu.setSubmenu_forItem_(grp_submenu, mitem)

            if isinstance(item, HistoryBlinkGroup):
                self.contactContextMenu.addItem_(NSMenuItem.separatorItem())
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Show Hidden Entries", "Menu item"), "showHiddenEntries:", "")
                lastItem.setEnabled_(item)
                lastItem.setRepresentedObject_(item)
                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Hide Entries", "Menu item"), "hideHistoryEntries:", "")
                session_ids = list(chain(*(_history_contact.session_ids for _history_contact in item.contacts)))
                lastItem.setEnabled_(bool(session_ids))
                lastItem.setRepresentedObject_(session_ids)

                lastItem = self.contactContextMenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Select Period", "Menu item"), "", "")
                period_submenu = NSMenu.alloc().init()
                self.contactContextMenu.setSubmenu_forItem_(period_submenu, lastItem)

                p_item = period_submenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Last Day", "Menu item"), "setHistoryPeriod:", "")
                p_item.setRepresentedObject_({'group': item, 'days': 2})
                p_item.setState_(NSOnState if item.days == 2 else NSOffState)

                p_item = period_submenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Last Week", "Menu item"), "setHistoryPeriod:", "")
                p_item.setRepresentedObject_({'group': item, 'days': 7})
                p_item.setState_(NSOnState if item.days == 7 else NSOffState)

                p_item = period_submenu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Last Month", "Menu item"), "setHistoryPeriod:", "")
                p_item.setState_(NSOnState if item.days == 30 else NSOffState)
                p_item.setRepresentedObject_({'group': item, 'days': 30})

    @objc.python_method
    def photoClicked(self, sender):
        picker = PhotoPicker()
        path, image = picker.runModal()
        if image and path:
            self.photoImage.setImage_(image)
            self.photoImage.hasPhoto = not path.endswith(NO_PHOTO_AVATAR)
            self.photoImage.setNeedsDisplay_(True)
            if path.endswith("default_user_icon.tiff"):
                # Skip all icon processing, just set the setting to None
                settings = SIPSimpleSettings()
                settings.presence_state.icon = None
                settings.save()
                return
            # Scale it down if needed
            size = image.size()
            image_rep = image.representations()[0]
            real_size_w = image_rep.pixelsWide()
            real_size_h = image_rep.pixelsHigh()
            if real_size_w > ICON_SIZE or real_size_h > ICON_SIZE:
                new_size_w = ICON_SIZE
                new_size_h = ICON_SIZE * size.height/size.width
                scaled_image = NSImage.alloc().initWithSize_(NSMakeSize(new_size_w, new_size_h))
                scaled_image.lockFocus()
                image.drawInRect_fromRect_operation_fraction_(NSMakeRect(0, 0, new_size_w, new_size_h), NSMakeRect(0, 0, size.width, size.height), NSCompositeSourceOver, 1.0)
                scaled_image.unlockFocus()
                tiff_data = scaled_image.TIFFRepresentation()
            else:
                tiff_data = image.TIFFRepresentation()
            bitmap_data = NSBitmapImageRep.alloc().initWithData_(tiff_data)
            png_data = bitmap_data.representationUsingType_properties_(NSPNGFileType, None)
            data = png_data.bytes().tobytes()
            data_hash = hashlib.sha512(data).hexdigest()
            self.saveUserIcon(data, data_hash)

    @objc.python_method
    def loadUserIcon(self):
        # Call this in the GUI thread
        settings = SIPSimpleSettings()
        if settings.presence_state.icon and os.path.exists(settings.presence_state.icon.path):
            path = settings.presence_state.icon.path
        else:
            path = DefaultUserAvatar().path
        self.photoImage.setImage_(NSImage.alloc().initWithContentsOfFile_(path))
        # The stand-in is not a photograph: with no icon of my own the view
        # draws my initials, exactly as a contact without one does.
        self.photoImage.hasPhoto = os.path.basename(str(path)) != NO_PHOTO_AVATAR
        self.updateOwnAvatarName()

    @objc.python_method
    def updateOwnAvatarName(self):
        """What my own initials and colour come from: my display name."""
        try:
            name = str(self.nameText.stringValue()).strip()
        except Exception:
            name = ''
        if not name:
            try:
                account = self.activeAccount()
                name = str(account.id) if account is not None else ''
            except Exception:
                name = ''
        self.photoImage.avatarName = name
        self.photoImage.setNeedsDisplay_(True)

    @objc.python_method
    @run_in_thread('file-io')
    def saveUserIcon(self, data, data_hash):
        settings = SIPSimpleSettings()
        if settings.presence_state.icon and settings.presence_state.icon.etag == data_hash:
            return
        if settings.presence_state.icon:
            unlink(settings.presence_state.icon.path)
        filename = '%s.png' % unique_id(prefix='user_icon')
        path = ApplicationData.get(os.path.join('photos', filename))
        with open(path, 'wb') as f:
            f.write(data)
        settings.presence_state.icon = UserIcon(path, data_hash)
        settings.save()

    @objc.python_method
    def getSelectedParticipant(self):
        row = self.participantsTableView.selectedRow()
        if not self.participantsTableView.isRowSelected_(row):
            return None

        try:
            return self.participants[row]
        except IndexError:
            return None

    @objc.python_method
    @run_in_gui_thread
    def resizeDrawerSplitter(self):
        if self.drawerSplitterPosition is not None:
            self.sessionsView.setFrame_(self.drawerSplitterPosition['topFrame'])
            self.participantsView.setFrame_(self.drawerSplitterPosition['middleFrame'])
            self.videoView.superview().setFrame_(self.drawerSplitterPosition['videoFrame'])
        else:
            frame = self.participantsView.frame()
            frame.size.height = 0
            self.participantsView.setFrame_(frame)

            frame = self.videoView.superview().frame()
            frame.size.height = 0
            self.videoView.superview().setFrame_(frame)

        if not self.videoView.superview().frame().size.height:
            self.setVideoProducer(None)
        elif not self.getSelectedAudioSession():
            self.setVideoProducer(SIPApplication.video_device.producer)

    @objc.python_method
    @run_in_gui_thread
    def recalculateDrawerSplitter(self):
        if not self.drawer.isOpen():
            return

        parent_frame = self.drawerSplitView.frame()
        topFrame = self.sessionsView.frame()
        middleFrame = self.participantsView.frame()
        videoFrame = self.videoView.superview().frame()

        h0 = parent_frame.size.height
        h1 = topFrame.size.height
        h2 = middleFrame.size.height
        h3 = videoFrame.size.height

        must_resize = False
        session = self.getSelectedAudioSession()

        if session:
            if session.hasStreamOfType("video") and session.video_consumer == "audio":
                if self.videoView.aspect_ratio is not None:
                    new_height = int(videoFrame.size.width / self.videoView.aspect_ratio)
                    if new_height != h3:
                        h3 = new_height
                        must_resize = True
                else:
                    new_height = int(h3 / 1.77)
                    if new_height != h3:
                        h3 = new_height
                        must_resize = True
            else:
                if h3 > 0:
                    h3 = 0
                    must_resize = True
        else:
            if h3 > 0:
                h3 = 0
                must_resize = True

        # we have conference participants
        if len(self.participants):
            if h2 == 0:
                h2 = 170
                must_resize = True
        else:
            if h2 != 0:
                h2 = 0
                must_resize = True

        if h3 > parent_frame.size.height - 62 - h2:
            h3 = 0
            if session and session.hasStreamOfType("video") and session.video_consumer == "audio":
                session.setVideoConsumer("standalone")

        h1 = h0 - h2 - h3
        
        topFrame.size.height = int(h1)
        middleFrame.size.height = int(h2)
        videoFrame.size.height = int(h3)

        topFrame.origin.y = 0
        middleFrame.origin.y = h1
        videoFrame.origin.y = h1 + h2
        
        position = {'topFrame': topFrame,
                    'middleFrame': middleFrame,
                    'videoFrame': videoFrame
        }

        self.drawerSplitterPosition = position
        if must_resize:
            self.resizeDrawerSplitter()

    @objc.python_method
    def getSelectedAudioSession(self):
        session = None
        try:
            selected_audio_view = next((view for view in self.audioSessionsListView.subviews() if view.selected is True))
        except StopIteration:
            pass
        else:
            session = selected_audio_view.delegate.sessionController if hasattr(selected_audio_view.delegate, 'sessionController') else None

        return session

    @objc.python_method
    def setMainTabView(self, identifier):
        self.mainTabView.selectTabViewItemWithIdentifier_(identifier)

        frame = self.window().frame()
        frame.size.width = 274

        if identifier == "dialpad":
            self.addContactButtonDialPad.setHidden_(True)
            if not isinstance(self.window().firstResponder(), AudioSession):
                self.focusSearchTextField()

            self.searchBox.cell().setPlaceholderString_(NSLocalizedString("Enter Phone Number", "Search box placeholder"))
            self.searchBox.setToolTip_(NSLocalizedString("You may type digits or letters, letters will automatically be translated into digits. Press enter or click # on the dialpad to start the call", "Search box tooltip"))

            if not isinstance(self.window().firstResponder(), AudioSession):
                self.focusSearchTextField()

            new_value = ""
            for l in str(self.searchBox.stringValue().strip()):
                new_value = new_value + translate_alpha2digit(l)
            else:
                self.searchBox.setStringValue_(new_value)

            self.originalWindowPosition = self.window().frame()

            frame.size.height = 480
            change_y = self.originalWindowPosition.size.height - frame.size.height

            if change_y:
                frame.origin.y += change_y

            self.window().setContentMinSize_(frame.size)
            self.window().setContentMaxSize_(frame.size)
            self.window().setFrame_display_animate_(frame, True, True)

        else:
            self.addContactButtonDialPad.setHidden_(False)
            self.searchBox.cell().setPlaceholderString_(NSLocalizedString("Search Contacts or Enter Address", "Search box placeholder"))
            self.searchBox.setToolTip_(NSLocalizedString("You may type text to search for contacts or press enter to start a call to an arbitrary address or phone number", "Search box tooltip"))

            frame.size.height = 132
            self.window().setContentMinSize_(frame.size)

            frame.size.height = 2000
            frame.size.width = 800
            self.window().setContentMaxSize_(frame.size)

            if self.originalWindowPosition is not None:
                self.window().setFrame_display_animate_(self.originalWindowPosition, True, False)

            self.searchContacts()

    @objc.python_method
    def playSilence(self):
        # used to keep the audio device open
        audio_active = any(sess.hasStreamOfType("audio") for sess in self.sessionControllersManager.sessionControllers)
        if not audio_active and SIPApplication.voice_audio_bridge:
            if self.silence_player is None:
                self.silence_player = WavePlayer(SIPApplication.voice_audio_mixer, Resources.get('silence.wav'), volume=0, loop_count=15)
                SIPApplication.voice_audio_bridge.add(self.silence_player)

            if not self.silence_player.is_active:
                self.silence_player.start()

    @objc.python_method
    def fillPresenceMenu(self, presenceMenu):
        if presenceMenu == self.presenceMenu:
            attributes = NSDictionary.dictionaryWithObjectsAndKeys_(NSFont.systemFontOfSize_(NSFont.systemFontSize()), NSFontAttributeName)
        else:
            attributes = NSDictionary.dictionaryWithObjectsAndKeys_(self.nameText.font(), NSFontAttributeName)

        for item in PresenceActivityList:
            if item['type'] == 'delimiter':
                presenceMenu.addItem_(NSMenuItem.separatorItem())
                continue

            lastItem = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_("", item['action'], "")
            try:
                tag = item['tag']
            except KeyError:
                pass
            else:
                lastItem.setTag_(tag)
            title = NSAttributedString.alloc().initWithString_attributes_(item['title'], attributes)
            lastItem.setAttributedTitle_(title)
            try:
                indentation = item['indentation']
            except KeyError:
                pass
            else:
                lastItem.setIndentationLevel_(indentation)

            if item['represented_object'] is not None:
                try:
                    try:
                        status = item['represented_object']['extended_status']
                        image = self.presence_dots[status]
                        # image = presence_status_icons[status]
                        image.setScalesWhenResized_(True)
                        image.setSize_(NSMakeSize(15, 15))
                        lastItem.setImage_(image)
                        try:
                            tag = item['represented_object']['tag']
                        except KeyError:
                            pass
                        else:
                            lastItem.setTag_(tag)

                    except KeyError:
                        pass
                except KeyError:
                    pass
                lastItem.setRepresentedObject_(item['represented_object'])
            lastItem.setTarget_(self)
            presenceMenu.addItem_(lastItem)

    @objc.python_method
    def setSpeechSynthesis(self):
        settings = SIPSimpleSettings()
        self.useSpeechSynthesisMenuItem.setState_(NSOnState if settings.sounds.enable_speech_synthesizer else False)

    @objc.python_method
    def setAlwaysOnTop(self):
        always_on_top = NSUserDefaults.standardUserDefaults().boolForKey_("AlwaysOnTop")
        self.window().setLevel_(NSFloatingWindowLevel if always_on_top else NSNormalWindowLevel)
        self.alwaysOnTopMenuItem.setState_(NSOnState if always_on_top else NSOffState)

    @objc.python_method
    def removePresenceContactForOurselves(self):
        # myself contact was used in the past to replicate our own presence
        addressbook_manager = AddressbookManager()
        try:
            contact = addressbook_manager.get_contact('myself')
        except KeyError:
            pass
        else:
            contact.delete()

    @objc.python_method
    def loadPresenceStates(self):
        settings = SIPSimpleSettings()

        # populate presence menus
        self.presenceActivityPopUp.removeAllItems()
        while self.presenceMenu.numberOfItems() > 0:
            self.presenceMenu.removeItemAtIndex_(0)

        self.fillPresenceMenu(self.presenceMenu)
        self.fillPresenceMenu(self.presenceActivityPopUp.menu())

        status = settings.presence_state.status
        if not status:
            status = 'Available'
            settings.presence_state.status = status
            settings.save()

        self.changeMyPresenceFromStatus(status)

        note = settings.presence_state.note
        if note:
            self.presenceNoteText.setStringValue_(note if note.lower() != on_the_phone_activity['note'] else '')

    @objc.python_method
    def setLastPresenceActivity(self):
        settings = SIPSimpleSettings()
        status = settings.presence_state.status
        if status:
            self.setStatusBarIcon(status)
            self.presenceActivityPopUp.selectItemWithTitle_(status)
            for item in self.presenceMenu.itemArray():
                item.setState_(NSOnState if item.title() == status else NSOffState)

    @objc.python_method
    def changeMyPresenceFromStatus(self, status='Available'):
        tag = 1
        # update presence activity popup menu
        for item in self.presenceMenu.itemArray():
            item.setState_(NSOffState)
            if item.tag() and item.representedObject() and item.representedObject()['title'] == status:
                tag = item.tag()

        item = self.presenceMenu.itemWithTag_(tag)
        item.setState_(NSOnState)

        menu = self.presenceActivityPopUp.menu()
        item = menu.itemWithTag_(tag)
        self.presenceActivityPopUp.selectItem_(item)

        self.setStatusBarIcon(status)

    @objc.python_method
    def savePresenceActivityToHistory(self, object):
        if not object['note']:
            return

        if object['note'].lower() == on_the_phone_activity['note'].lower() and object['title'] == on_the_phone_activity['history_title']:
            return
        try:
            next(item for item in PresenceActivityList if item['type'] == 'menu_item' and item['action'] == 'presenceActivityChanged:' and item['represented_object']['title'] == object['title'] and item['represented_object']['note'] == object['note'])
        except StopIteration:
            try:
                self.presence_notes_history.remove(object)
            except ValueError:
                pass

            self.presence_notes_history.append(object)
            storage_path = ApplicationData.get('presence/presence_notes_history.pickle')
            try:
                pickle.dump(self.presence_notes_history, open(storage_path, "wb+"))
            except (pickle.PickleError, IOError):
                pass

    @objc.python_method
    def toggleOnThePhonePresenceActivity(self):
        # check if there are any active voice sessions
        hasAudio = any(sess.hasStreamOfType("audio") for sess in self.sessionControllersManager.sessionControllers if sess.account.presence.enable_on_the_phone)
        selected_item = self.presenceActivityPopUp.selectedItem()
        if selected_item is None:
            return

        current_presence_activity = selected_item.representedObject()
        current_presence_activity['note'] = self.presenceNoteText.stringValue()

        if self.presenceActivityBeforeOnThePhone:
            if not hasAudio and current_presence_activity['extended_status'] != 'available':
                i = self.presenceActivityPopUp.indexOfItemWithRepresentedObject_(self.presenceActivityBeforeOnThePhone)
                self.presenceActivityPopUp.selectItemAtIndex_(i)
                menu = self.presenceActivityPopUp.menu()
                item = menu.itemWithTag_(self.presenceActivityBeforeOnThePhone['tag'])
                self.presenceNoteText.setStringValue_(self.presenceActivityBeforeOnThePhone['note'])
                self.setStatusBarIcon(self.presenceActivityBeforeOnThePhone['extended_status'])
                self.presenceActivityBeforeOnThePhone = None
                self.presenceActivityChanged_(item)

        else:
            if hasAudio and current_presence_activity['extended_status'] == 'available':
                i = self.presenceActivityPopUp.indexOfItemWithTitle_(on_the_phone_activity['title'])
                self.presenceActivityPopUp.selectItemAtIndex_(i)
                self.presenceNoteText.setStringValue_(on_the_phone_activity['localized_note'])
                self.presenceNoteChanged_(None)
                self.presenceActivityBeforeOnThePhone = current_presence_activity
                self.setStatusBarIcon('busy')

    @objc.python_method
    def updatePresenceWatchersMenu(self, menu):
        while self.presenceWatchersMenu.numberOfItems() > 0:
            self.presenceWatchersMenu.removeItemAtIndex_(0)
        i = 0

        for key in list(self.model.active_watchers_map.keys()):
            active_watchers = self.model.active_watchers_map[key]
            if not active_watchers:
                continue

            if i:
                self.presenceWatchersMenu.addItem_(NSMenuItem.separatorItem())
            lastItem = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(NSLocalizedString("Account %s", "Watchers menu item") % key, "", "")
            lastItem.setEnabled_(False)
            self.presenceWatchersMenu.addItem_(lastItem)
            i += 1

            items = {}
            for watcher in list(active_watchers.keys()):
                uri = sip_prefix_pattern.sub("", watcher)
                try:
                    AccountManager().get_account(uri)
                except KeyError:
                    pass
                else:
                    # skip ourselves
                    continue
                contact = self.getFirstContactMatchingURI(uri, exact_match=True)
                title = '%s <%s>' % (contact.name, uri) if contact else uri
                items[title] = {'status': 'offline', 'contact': None}
                if isinstance(contact, BlinkPresenceContact):
                    items[title]['status'] = presence_status_for_contact(contact) or 'offline'
                    items[title]['contact'] = contact

            keys = list(items.keys())
            keys.sort()
            for title in keys:
                item = items[title]
                lastItem = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(title, 'showChatWindowForAccountWithTargetUri:', "")
                lastItem.setTarget_(self)
                if item['contact'] is not None:
                    lastItem.setRepresentedObject_({'account': key, 'target_uri': item['contact'].uri})
                else:
                    lastItem.setRepresentedObject_(None)
                lastItem.setIndentationLevel_(1)
                if item['status']:
                    icon = self.presence_dots[item['status']]
                    icon.setScalesWhenResized_(True)
                    icon.setSize_(NSMakeSize(15, 15))
                    lastItem.setImage_(icon)
                self.presenceWatchersMenu.addItem_(lastItem)

        if not i:
            lastItem = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(NSLocalizedString("Nobody", "Watchers menu item"), "", "")
            lastItem.setEnabled_(False)
            self.presenceWatchersMenu.addItem_(lastItem)

    @objc.python_method
    def updatePresenceActivityMenu(self, menu):
        if menu == self.presenceMenu:
            attributes = NSDictionary.dictionaryWithObjectsAndKeys_(NSFont.systemFontOfSize_(NSFont.systemFontSize()), NSFontAttributeName)
        else:
            attributes = NSDictionary.dictionaryWithObjectsAndKeys_(self.nameText.font(), NSFontAttributeName)

        while menu.numberOfItems() > len(PresenceActivityList):
            menu.removeItemAtIndex_(len(PresenceActivityList))

        offline_idx = 0
        for item in PresenceActivityList:
            offline_idx += 1
            if item['type'] == 'delimiter':
                continue
            if item['action'] == 'setPresenceOfflineNote:':
                break

        menu.removeItemAtIndex_(offline_idx)
        lastItem = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_("", "setPresenceOfflineNote:", "")
        settings = SIPSimpleSettings()
        offline_note = settings.presence_state.offline_note
        title = NSAttributedString.alloc().initWithString_attributes_(offline_note or NSLocalizedString("Not Set", "Menu item"), attributes)
        lastItem.setAttributedTitle_(title)
        lastItem.setIndentationLevel_(2)
        lastItem.setEnabled_(False)
        menu.insertItem_atIndex_(lastItem, offline_idx)

        if self.presence_notes_history:
            menu.addItem_(NSMenuItem.separatorItem())
            lastItem = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_("", "", "")
            title = NSAttributedString.alloc().initWithString_attributes_(NSLocalizedString("My Recent Availability", "Activity menu item"), attributes)
            lastItem.setAttributedTitle_(title)
            lastItem.setEnabled_(False)
            menu.addItem_(lastItem)

        for item in reversed(self.presence_notes_history):
            try:
                status = item['extended_status']
            except IndexError:
                pass
            else:
                lastItem = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_("", "setPresenceActivityFromHistory:", "")
                title = NSAttributedString.alloc().initWithString_attributes_(item['note'], attributes)
                lastItem.setAttributedTitle_(title)
                lastItem.setRepresentedObject_(item)
                try:
                    image = self.presence_dots[status]
                    image.setScalesWhenResized_(True)
                    image.setSize_(NSMakeSize(15, 15))
                    lastItem.setImage_(image)
                except KeyError:
                    pass

                lastItem.setRepresentedObject_(item)
                lastItem.setTarget_(self)
                menu.addItem_(lastItem)

            # if self.presence_notes_history:
            #     menu.addItem_(NSMenuItem.separatorItem())
            #     lastItem = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_("", "deletePresenceHistory:", "")
            #     title = NSAttributedString.alloc().initWithString_attributes_('Clear History', attributes)
            #     lastItem.setAttributedTitle_(title)
            #     lastItem.setTarget_(self)
            #     menu.addItem_(lastItem)

    @objc.python_method
    def setStatusBarIcon(self, status=None):
        if status is None:
            return

        # account = AccountManager().default_account
        # if account is not None and account.audio.do_not_disturb:
        #     status = 'busy'
        # else:
        #     status = status.lower()
        # icon = NSImage.imageNamed_(status)

        icon = NSImage.imageNamed_(NSApp.delegate().statusbar_menu_icon)
        icon.setScalesWhenResized_(True)
        icon.setSize_(NSMakeSize(18, 18))
        self.statusBarItem.setImage_(icon)

    @objc.python_method
    def refreshLdapDirectory(self):
        active_account = self.activeAccount()
        if active_account and active_account.ldap.hostname and active_account.ldap.enabled:
            if self.ldap_directory:
                self.ldap_directory.disconnect()
            self.ldap_directory = LdapDirectory(active_account.ldap)
            self.ldap_search = LdapSearch(self.ldap_directory)
        else:
            if self.ldap_directory:
                self.ldap_directory.disconnect()
                self.ldap_directory = None
                self.ldap_search = None

    @objc.python_method
    def setup(self, sipManager):
        self.backend = sipManager
        self.backend.set_delegate(self)

    @objc.python_method
    def setupFinished(self):
        # BlinkLogger().log_info('startup: setupFinished mute/silent state')
        if self.backend.is_muted():
            self.muteButton.setImage_(NSImage.imageNamed_("muted"))
            self.muteButton.setState_(NSOnState)
        else:
            self.muteButton.setImage_(NSImage.imageNamed_("mute"))
            self.muteButton.setState_(NSOffState)

        if self.backend.is_silent():
            self.silentButton.setImage_(NSImage.imageNamed_("belloff"))
            self.silentButton.setState_(NSOnState)
        else:
            self.silentButton.setImage_(NSImage.imageNamed_("bellon"))
            self.silentButton.setState_(NSOffState)
        active = self.activeAccount()
        if active and active.display_name != self.nameText.stringValue():
            self.updateNameLabel(active.display_name or "")

        # BlinkLogger().log_info('startup: contactOutline.reloadData')
        self.contactOutline.reloadData()
        # BlinkLogger().log_info('startup: setupFinished done')

        # selected_tab = NSUserDefaults.standardUserDefaults().stringForKey_("MainWindowSelectedTabView")
        # self.setMainTabView(selected_tab if selected_tab else "contacts")

    @objc.python_method
    def contactSupportsMedia(self, media, contact, uri=None):
        if not isinstance(contact, BlinkPresenceContact):
            return True

        settings = SIPSimpleSettings()
        if not settings.gui.media_support_detection:
            return True

        if uri is not None:
            uri_devices = list(device for device in list(contact.presence_state['devices'].values()) if 'sip:%s' % uri in device['aor'])
            if not uri_devices:
                return True

            has_caps = any(device for device in uri_devices if device['caps'])
            if not has_caps:
                return True

            return any(device for device in uri_devices if media in device['caps'])

        devices = list(contact.presence_state['devices'].values())

        if not devices:
            return True

        has_caps = any(device for device in devices if device['caps'])
        if not has_caps:
            return True

        return any(device for device in devices if media in device['caps'])

    @objc.python_method
    def switchAudioDevice(self, device):
        hasAudio = any(sess.hasStreamOfType("audio") for sess in self.sessionControllersManager.sessionControllers)
        settings = SIPSimpleSettings()
        if hasAudio or settings.audio.automatic_device_switch:
            BlinkLogger().log_info("Switching input/output audio devices to %s" % device.strip())
            settings.audio.input_device = str(device)
            settings.audio.output_device = str(device)
            settings.save()
        else:
            NSApp.activateIgnoringOtherApps_(True)
            panel = NSGetInformationalAlertPanel(NSLocalizedString("New Audio Device", "Window title"),
                                                 NSLocalizedString("A new audio device %s has been plugged-in. Would you like to switch to it?", "Label") % device.strip(),
                                                 NSLocalizedString("Switch", "Button title"), NSLocalizedString("Ignore", "Button title"), None)
            timer = NSTimer.timerWithTimeInterval_target_selector_userInfo_repeats_(7, self, "newAudioDeviceTimeout:", panel, False)
            NSRunLoop.currentRunLoop().addTimer_forMode_(timer, NSModalPanelRunLoopMode)
            NSRunLoop.currentRunLoop().addTimer_forMode_(timer, NSDefaultRunLoopMode)
            session = NSApp.beginModalSessionForWindow_(panel)
            while True:
                ret = NSApp.runModalSession_(session)
                if ret != NSRunContinuesResponse:
                    break
            NSApp.endModalSession_(session)
            panel.close()
            NSReleaseAlertPanel(panel)

            if ret == NSAlertDefaultReturn:
                BlinkLogger().log_info("Switching input/output audio devices to %s" % device.strip())
                settings.audio.input_device = str(device)
                settings.audio.output_device = str(device)
                settings.save()

        #self.menuWillOpen_(self.devicesMenu)

    @objc.python_method
    def showAudioSession(self, streamController, add_to_conference=False):
        self.audioSessionsListView.addItemView_(streamController.view)
        self.updateAudioButtons()
        hasAudio = any(sess.hasStreamOfType("audio") for sess in self.sessionControllersManager.sessionControllers if streamController not in sess.streamHandlers)
        go_to_background = bool(hasAudio and (streamController.sessionController.answeringMachineMode or add_to_conference))
        streamController.view.setSelected_(not go_to_background)
        if add_to_conference:
            streamController.addToConference()

        if not streamController.sessionController.hasStreamOfType("chat"):
            if (streamController.sessionController.hasStreamOfType("video") and streamController.sessionController.video_consumer == "audio") or not streamController.sessionController.hasStreamOfType("video"):
                self.window().makeFirstResponder_(streamController.view)
                self.showAudioDrawer()

    def windowDidBecomeKey_(self, notification):
        self.notifyMessagePaneVisibility()

    def windowDidResignKey_(self, notification):
        self.notifyMessagePaneVisibility()

    @objc.python_method
    def notifyMessagePaneVisibility(self):
        """Tell the messages pane whether its conversation is really on screen.

        Drives outgoing IMDN display receipts and the contact unread badge, so
        it has to fire on every transition: the pane being shown or hidden,
        and the main window becoming or resigning key.
        """
        if self.messagePaneController is not None:
            self.messagePaneController.visibilityChanged()

    @objc.python_method
    def accountForOwnURI(self, uri):
        """The enabled account that owns this address, or None.

        Answers "is this me?" -- the case where a contact's address is one of
        the accounts configured on this device. Bonjour is never the answer:
        it has no address of its own and no keys.
        """
        text = str(uri or '')
        for scheme in ('sip:', 'sips:'):
            if text.startswith(scheme):
                text = text[len(scheme):]
                break
        text = text.split(';')[0].split('?')[0].strip()
        if not text:
            return None
        try:
            manager = AccountManager()
            if not manager.has_account(text):
                return None
            account = manager.get_account(text)
        except Exception as e:
            BlinkLogger().log_error('Cannot tell whether %s is one of our accounts: %s' % (uri, e))
            return None
        if account is None or account is BonjourAccount():
            return None
        return account if account.enabled else None

    @objc.python_method
    def reportNoMessagingAccount(self, display_name=None):
        """Say why nothing opened, rather than opening nothing."""
        who = (' %s' % display_name) if display_name else ''
        BlinkLogger().log_info('No account can send a message to%s' % (who or ' this address'))
        NSRunAlertPanel(
            NSLocalizedString("Cannot Send Message", "Window title"),
            NSLocalizedString("Bonjour can only reach neighbours on this network, and no other "
                              "account is enabled. Enable a SIP account to send messages.",
                              "Label"),
            NSLocalizedString("OK", "Button title"), None, None)

    @objc.python_method
    def messagingAccountFor(self, account, contact=None):
        """The account a message to this contact can actually go out on.

        Bonjour is only ever the answer for a neighbour on this network. It
        has no registrar and no way to reach anybody else: a conversation
        opened on it with an ordinary SIP contact can never resolve a route,
        every message in it fails, and setAccount refuses to move it
        somewhere it would work -- so it is a conversation that exists only
        to not function.

        That used to be one click away. The account popup is the account
        unless something overrides it, and the overrides only covered a live
        neighbour, an address we had talked to before, and our own
        addresses; with Bonjour selected, clicking any other contact made
        exactly that dead conversation, and its messages went into history
        filed under bonjour@local.

        Returns None when there is no usable account at all, and the caller
        says so rather than opening something that cannot work.
        """
        if account is not BonjourAccount():
            return account
        if contact is not None and contact in self.model.bonjour_group.contacts:
            return account
        try:
            manager = AccountManager()
            default = manager.default_account
            if default is not None and default is not BonjourAccount() and default.enabled:
                return default
            return next((item for item in manager.get_accounts()
                         if item is not BonjourAccount() and item.enabled), None)
        except Exception as e:
            BlinkLogger().log_error('Cannot find an account to send from: %s' % e)
            return None

    @objc.python_method
    def messageTargetForContact(self, contact):
        """(target, display_name, account, instance_id) for a contact, or None.

        Same resolution sendMessageToURI uses -- Bonjour contacts switch to
        the Bonjour account and key on their instance id.
        """
        account = self.activeAccount()
        if not account or contact is None:
            return None

        instance_id = None
        # Not contact.uri, which is the default address: for a contact with
        # more than one, the conversation runs on the one last messaged (or
        # picked in the pane header). See messageURIForContact.
        try:
            target = SMSWindowManager.SMSWindowManager().messageURIForContact(contact) or contact.uri
        except Exception as e:
            BlinkLogger().log_error('Cannot tell which address of %s to message: %s' % (contact.name, e))
            target = contact.uri
        display_name = contact.name
        # A conversation with one of YOUR OWN addresses is not a Bonjour
        # neighbour, even though this machine publishes itself as one and so
        # appears in that group. Taking the Bonjour branch there opened the
        # conversation on bonjour@local, an account with no PGP private key --
        # which is why talking to yourself showed a grey lock, could not
        # decrypt its own stored messages ("no private key"), and refused
        # every attachment in it with "encrypted, and no key is available
        # yet". The keys belong to the account that owns the address, so that
        # is the account the conversation has to run on.
        own_account = self.accountForOwnURI(target)
        if own_account is None and contact in self.model.bonjour_group.contacts:
            account = BonjourAccount()
            instance_id = contact.id
        else:
            # The account popup picks the account for someone this
            # application has never talked to. For anyone else the
            # conversation already has one -- whichever account last
            # carried a message either way -- and starting a new
            # conversation on a different one would answer from an
            # address the peer never wrote to.
            try:
                remembered = SMSWindowManager.SMSWindowManager().accountForRemoteURI(target)
            except Exception as e:
                BlinkLogger().log_error('Cannot tell which account %s uses: %s' % (target, e))
                remembered = None
            if remembered is not None:
                account = remembered
            elif own_account is not None:
                account = own_account

            # Whatever the popup says, this contact is not a neighbour.
            account = self.messagingAccountFor(account, contact)
            if account is None:
                self.reportNoMessagingAccount(display_name)
                return None

        target = normalize_sip_uri_for_outgoing_session(target, account)
        if not target:
            return None
        return (target, display_name, account, instance_id)

    @objc.python_method
    def openMessagesForURI(self, uri, prefill=None, attachments=None):
        """Reveal the messages pane showing the conversation with one address.

        Unlike switchMessagePaneToSelectedContact this DOES open the pane:
        it exists for someone clicking a notification about a message, and
        the whole point of that click is to be shown the message.

        prefill puts text in the composer without sending it. That is for
        the Share menu: text or a link shared from another app opens the
        conversation ready to send, because a link shared by reflex should
        not leave before its sender has looked at it.

        attachments are file paths to offer through the usual preview once
        the conversation is up -- the same gate the file panel, the camera
        and Cmd-V go through, so a picture shared from Finder can still be
        cropped or made smaller before it goes anywhere.
        """
        uri = str(uri or '').strip()
        if not uri:
            return False
        try:
            contact = None
            if '@' not in uri:
                # A Bonjour neighbour. The banner names them by the key
                # their conversation is filed under -- their instance id --
                # and the address book has never heard of it, rightly: a
                # neighbour is not an address book entry. Without this the
                # click fell through to messageTargetForURI, which resolved
                # a UUID to nothing and opened an empty conversation.
                contact = self.model.getBonjourContactMatchingDeviceId(uri)
                if contact is None and self.deferConversationOpen(uri, prefill, attachments):
                    # Nothing to open it with YET. A banner clicked while
                    # Blink is not running launches it, and this runs
                    # while the Bonjour group is still empty -- the
                    # accounts activate seconds later and the rows are
                    # rebuilt from history after that. Asked again shortly
                    # rather than answered with an empty conversation.
                    return False
                if contact is None:
                    BlinkLogger().log_debug('No Bonjour row for %s' % uri)
                elif self.conversationOpenRetries:
                    self.conversationOpenRetries.pop(uri, None)
            if contact is None:
                contact = self.getFirstContactMatchingURI(uri)
            self.showWindow_(None)
            self.window().makeKeyAndOrderFront_(None)
            self.showMessagesPane()

            pane = self.messagePane()
            if pane is None:
                return False

            if contact is not None:
                # Select the row too, so the contact list agrees with what
                # the pane is showing rather than leaving the two apart.
                try:
                    row = self.contactOutline.rowForItem_(contact)
                    if row >= 0:
                        self.contactOutline.selectRowIndexes_byExtendingSelection_(
                            NSIndexSet.indexSetWithIndex_(row), False)
                        self.contactOutline.scrollRowToVisible_(row)
                except Exception as e:
                    BlinkLogger().log_debug('Cannot select %s in the list: %s' % (uri, e))

            resolved = None
            if contact is not None:
                resolved = self.messageTargetForContact(contact)
            if resolved is None:
                # No contact for this address -- open the conversation on the
                # address itself rather than doing nothing, which is what the
                # click asked for.
                resolved = self.messageTargetForURI(uri)
            if resolved is None:
                BlinkLogger().log_error('Cannot open a conversation with %s' % uri)
                return False

            target, display_name, account, instance_id = resolved
            manager = SMSWindowManager.SMSWindowManager()
            # An open conversation with this person wins over the account
            # popup: the popup picks the account for a NEW one only.
            viewer = manager.openViewerForURI(str(target)) if instance_id is None else None
            if viewer is None:
                viewer = manager.viewerForTarget(target, display_name, account,
                                                 instance_id=instance_id,
                                                 selected_contact=contact)
            if viewer is None:
                return False
            manager.presentViewer(viewer, focus=True, note_new_message=False)
            pane.selectViewer(viewer)
            if prefill:
                self.prefillComposer(viewer, prefill)
            if attachments:
                self.presentAttachmentsLater(viewer, attachments)
            BlinkLogger().log_info('Opened the conversation with %s' % uri)
            return True
        except Exception as e:
            BlinkLogger().log_error('Cannot open the conversation with %s: %s' % (uri, e))
            return False

    @objc.python_method
    def deferConversationOpen(self, uri, prefill=None, attachments=None):
        """Ask again in a moment for a neighbour the list does not have yet.

        True when the open has been rescheduled and the caller should stop;
        False when the waiting is over, so the caller fails the open and
        says so. The count is kept per address rather than passed back in,
        because the retry goes through openMessagesForURI again -- and a
        counter that started afresh on each pass would wait for ever.
        """
        retries = dict(self.conversationOpenRetries or {})
        left = retries.get(uri, 10)
        if left <= 0:
            retries.pop(uri, None)
            self.conversationOpenRetries = retries
            BlinkLogger().log_info('Gave up waiting for the Bonjour row of %s' % uri)
            return False
        if self.pendingConversationOpen is not None:
            # One at a time: a second click on the same banner should not
            # start a second chain of retries.
            return True
        retries[uri] = left - 1
        self.conversationOpenRetries = retries
        BlinkLogger().log_info('Waiting for the Bonjour list before opening the '
                               'conversation with %s (%d more)' % (uri, left - 1))
        self.pendingConversationOpen = (uri, prefill, attachments)
        self.performSelector_withObject_afterDelay_(
            'retryPendingConversationOpen:', None, 1.5)
        return True

    def retryPendingConversationOpen_(self, sender):
        pending = self.pendingConversationOpen
        self.pendingConversationOpen = None
        if not pending:
            return
        uri, prefill, attachments = pending
        self.openMessagesForURI(uri, prefill, attachments)

    @objc.python_method
    def presentAttachmentsLater(self, viewer, paths):
        """Offer files through the preview, once the conversation is up.

        A turn of the run loop later, on purpose: the preview runs modally
        and a modal window opened in the middle of building a conversation
        stops the conversation from finishing. The user should see who
        they are sending to behind the thing asking what to send.
        """
        self.pendingAttachments = (viewer, [str(path) for path in paths])
        self.performSelector_withObject_afterDelay_(
            'presentPendingAttachments:', None, 0.0)

    def presentPendingAttachments_(self, sender):
        pending = self.pendingAttachments
        self.pendingAttachments = None
        if not pending:
            return
        viewer, paths = pending
        try:
            sent = viewer.chatViewController.confirmAndSendFiles(paths)
        except Exception as e:
            BlinkLogger().log_error('Cannot offer the shared files: %s' % e)
            return
        BlinkLogger().log_info('Shared %d of %d file(s)' % (sent, len(paths)))

    @objc.python_method
    def prefillComposer(self, viewer, text):
        """Put text in a conversation's composer, leaving it unsent.

        Appends rather than replaces: whatever the user had half-typed in
        there is theirs, and losing it to a share would be worse than the
        share arriving on a second line.
        """
        try:
            input_text = viewer.chatViewController.inputText
            existing = str(input_text.string() or '')
            input_text.setString_('%s\n%s' % (existing, text) if existing else text)
            input_text.window().makeFirstResponder_(input_text)
        except Exception as e:
            BlinkLogger().log_error('Cannot fill the composer for the share: %s' % e)

    @objc.python_method
    def messageTargetForURI(self, uri):
        """(target, display name, account, instance id) for a bare address."""
        try:
            from sipsimple.core import SIPURI
            from sipsimple.account import AccountManager
            account = AccountManager().default_account
            if account is None:
                return None
            # A bare address is never a neighbour: neighbours are addressed
            # by instance id and arrive here as contacts, not as text. The
            # default account IS the popup selection, so without this a
            # notification click or a share with Bonjour selected opened a
            # conversation that could never resolve a route.
            account = self.messagingAccountFor(account)
            if account is None:
                self.reportNoMessagingAccount(uri)
                return None
            target = SIPURI.parse('sip:%s' % uri if '@' in uri and not uri.startswith('sip')
                                  else str(uri))
            return target, uri, account, None
        except Exception as e:
            BlinkLogger().log_error('Cannot make a conversation target from %s: %s' % (uri, e))
            return None

    @objc.python_method
    def switchMessagePaneToSelectedContact(self):
        """Selecting a contact switches the conversation the pane is showing.

        Only ever SWITCHES -- it never reveals the pane. Otherwise selecting
        a contact to place a call would pop a transcript open. The
        conversation is created here because selecting a contact IS the user
        asking for it; nothing arriving over the wire creates one.
        """
        from MessageHost import (load_trace_now, load_trace_key, load_trace_start,
                                 load_trace_mark, load_trace_label,
                                 load_trace_finish, load_trace_cancel,
                                 load_trace_tick, load_trace_bucket,
                                 load_trace_bucket_span)
        # The clock starts here, not once the viewer exists: resolving the
        # contact to an account and a target is part of the wait the user
        # feels, and it is the only phase that runs before anything is traced.
        trace_t0 = load_trace_now()
        trace_key = None

        if not self.isMessagesPaneVisible():
            return

        pane = self.messagePane()
        if pane is None:
            return
        try:
            contact = self.getSelectedContacts()[0]
        except IndexError:
            pane.selectViewer(None)
            return
        trace_contact = load_trace_now()

        resolved = self.messageTargetForContact(contact)
        if resolved is None:
            pane.selectViewer(None)
            return
        trace_target = load_trace_now()

        target, display_name, account, instance_id = resolved
        trace_key = load_trace_key(target)
        load_trace_start(trace_key, t0=trace_t0)
        load_trace_bucket_span('- selected contact', trace_t0, trace_contact)
        load_trace_bucket_span('- resolve target', trace_contact, trace_target)
        try:
            manager = SMSWindowManager.SMSWindowManager()
            _t = load_trace_tick()
            viewer = manager.openViewerForURI(str(target)) if instance_id is None else None
            load_trace_bucket('- viewer lookup', _t)
            # A viewer that already exists has its transcript rendered: this
            # click only unhides it, and the trace ends with the switch.
            trace_warm = viewer is not None
            if viewer is None:
                _t = load_trace_tick()
                viewer = manager.viewerForTarget(target, display_name, account,
                                                 instance_id=instance_id,
                                                 selected_contact=contact)
                load_trace_bucket('- viewer create', _t)
            if viewer is None:
                load_trace_cancel(trace_key)
                pane.selectViewer(None)
                return
            # attach without bringing anything to the front
            manager.presentViewer(viewer, focus=False, note_new_message=False)
            load_trace_mark(trace_key, 'present')
            pane.selectViewer(viewer)
            load_trace_mark(trace_key, 'select')
            if trace_warm:
                # Already built and rendered -- say how much was on screen, so
                # a 'warm' line that is somehow slow can still be read.
                load_trace_label(trace_key, 'warm')
                try:
                    from MessageHost import load_trace_note
                    load_trace_note(trace_key, '%d in view'
                                    % len(viewer.chatViewController.rendered_messages))
                except Exception:
                    pass
                load_trace_finish(trace_key)
        except Exception as e:
            load_trace_cancel(trace_key)
            BlinkLogger().log_error('Cannot show conversation for %s: %s' % (contact.uri, e))

    MESSAGE_PANE_DEFAULT_WIDTH = 480.0

    @objc.python_method
    def messagePaneHost(self):
        """The split view that hosts the conversation, built on first use.

        The window's existing content is moved wholesale into the left half,
        keeping every frame and autoresizing mask, so the contact list keeps
        behaving exactly as it did and no nib has to change -- which matters
        here, because MainWindow.xib exists in six localisations.
        """
        if self.messagePaneSplitView is not None:
            return self.messagePaneSplitView
        try:
            from MessagePaneController import MessagePaneSplitView
            window = self.window()
            if window is None:
                return None
            content = window.contentView()
            bounds = content.bounds()

            list_side = NSView.alloc().initWithFrame_(bounds)
            list_side.setAutoresizingMask_(NSViewWidthSizable | NSViewHeightSizable)
            for view in list(content.subviews()):
                view.removeFromSuperview()
                list_side.addSubview_(view)

            split = MessagePaneSplitView.alloc().initWithFrame_(bounds)
            split.setAutoresizingMask_(NSViewWidthSizable | NSViewHeightSizable)
            split.addSubview_(list_side)
            content.addSubview_(split)

            self.messagePaneListSide = list_side
            self.messagePaneSplitView = split
            BlinkLogger().log_info('Messages pane host installed in the main window')
        except Exception as e:
            BlinkLogger().log_error('Cannot build the messages pane host: %s' % e)
            import traceback
            traceback.print_exc()
            return None
        return self.messagePaneSplitView

    @objc.python_method
    def storedMessagePaneWidth(self):
        try:
            stored = NSUserDefaults.standardUserDefaults().floatForKey_('MessagesPaneWidth')
        except Exception:
            stored = 0
        if stored and stored >= 320.0:
            return stored
        return self.MESSAGE_PANE_DEFAULT_WIDTH

    @objc.python_method
    def rememberMessagePaneWidth(self):
        split = self.messagePaneSplitView
        if split is None or not self._message_pane_visible:
            return
        if self._fitting_window_for_drawer:
            # A width the drawer imposed is not a width the user chose.
            return
        try:
            width = split.frame().size.width - split.listWidth() - split.dividerThickness()
            if width >= 320.0:
                NSUserDefaults.standardUserDefaults().setFloat_forKey_(width, 'MessagesPaneWidth')
            list_width = split.listWidth()
            if list_width >= self.CONTACT_LIST_MIN_WIDTH:
                NSUserDefaults.standardUserDefaults().setFloat_forKey_(list_width, 'ContactListWidth')
        except Exception:
            pass

    @objc.python_method
    def switchMessagePaneToSession(self, session_controller):
        """Point the open conversation pane at the party of a call.

        Clicking a call is the user saying which conversation they are in. If
        the pane is already open beside the contact list, it follows -- one
        person's transcript sitting next to another person's call is how a
        message gets sent to the wrong person.

        Only ever SWITCHES, never reveals: the same rule
        switchMessagePaneToSelectedContact follows. Answering a call must not
        make a transcript appear over whatever the user was doing, and a call
        arriving is not a request to read anything.
        """
        if session_controller is None or not self.isMessagesPaneVisible():
            return
        pane = self.messagePane()
        if pane is None:
            return

        target = getattr(session_controller, 'target_uri', None)
        if target is None:
            return

        # The call's own target is the conversation key: calling one of a
        # contact's numbers and then reading the transcript of a different
        # address of theirs is the same wrong-person mistake this method
        # exists to prevent.
        contact = None
        try:
            contact = self.getFirstContactMatchingURI(target, exact_match=True)
        except Exception:
            contact = None

        display_name = None
        if contact is not None:
            display_name = contact.name
        if not display_name:
            try:
                display_name = session_controller.remoteIdentity.display_name
            except Exception:
                display_name = None

        # Same account resolution the contact path uses: whichever account
        # last carried a message either way owns the conversation, and the
        # call's account is only the fallback. Bonjour is filtered out --
        # a conversation opened there can never resolve a route.
        account = None
        try:
            account = SMSWindowManager.SMSWindowManager().accountForRemoteURI(target)
        except Exception as e:
            BlinkLogger().log_error('Cannot tell which account %s uses: %s' % (target, e))
        if account is None:
            account = getattr(session_controller, 'account', None)
        try:
            account = self.messagingAccountFor(account, contact)
        except Exception:
            pass
        if account is None:
            return

        # A neighbour is addressed by instance id, not by address; without one
        # viewerForTarget rightly refuses to build a conversation that could
        # never resolve a route.
        instance_id = None
        if account is BonjourAccount():
            try:
                if contact is not None and contact in self.model.bonjour_group.contacts:
                    instance_id = contact.id
            except Exception:
                instance_id = None
            if instance_id is None:
                return

        try:
            manager = SMSWindowManager.SMSWindowManager()
            viewer = manager.openViewerForURI(str(target)) if instance_id is None else None
            if viewer is None:
                viewer = manager.viewerForTarget(target, display_name, account,
                                                 instance_id=instance_id,
                                                 selected_contact=contact)
            if viewer is None:
                return
            # Attached without focus: the call is what the user clicked, and
            # stealing first responder would take the keyboard away from it.
            manager.presentViewer(viewer, focus=False, note_new_message=False)
            pane.selectViewer(viewer)
        except Exception as e:
            BlinkLogger().log_error('Cannot switch the messages pane to %s: %s' % (target, e))

    @objc.python_method
    def isMessagesPaneVisible(self):
        return bool(self._message_pane_visible and self.window() is not None
                    and self.window().isVisible())

    @objc.python_method
    def showMessagesPane(self, restoring=False):
        """Reveal the conversation beside the contact list.

        `restoring` means the window is already sized for two panes, so the
        transcript is fitted into the existing frame instead of the window
        being grown to make room for it.
        """
        self.showWindow_(None)
        pane = self.messagePane()
        if pane is None:
            BlinkLogger().log_error('Messages pane unavailable')
            return
        split = self.messagePaneHost()
        if split is None:
            return

        if not self._message_pane_visible:
            width = self.storedMessagePaneWidth()
            if restoring:
                list_width = self.storedContactListWidth()
            else:
                list_width = self.messagePaneListSide.frame().size.width
            if pane.view.superview() is not split:
                split.addSubview_(pane.view)
            self._message_pane_visible = True

            if restoring:
                # Only widen if the restored frame cannot actually hold both
                # halves -- the window was narrowed while the pane was shut,
                # or the autosaved frame predates the pane entirely.
                needed = list_width + split.dividerThickness() + 320.0
                short = needed - self.window().frame().size.width
                if short > 0:
                    self.growWindowForMessagePane(short - split.dividerThickness())
            else:
                self.growWindowForMessagePane(width)

            split.setListWidth(list_width)
            self.releaseContactListWidth()
            BlinkLogger().log_debug('Messages pane shown at %.0f (list %.0f)%s'
                                    % (width, list_width,
                                       ' -- restored' if restoring else ''))

        if pane.selectedSessionController() is None:
            self.switchMessagePaneToSelectedContact()
        self.notifyMessagePaneVisibility()

    @objc.python_method
    def storedContactListWidth(self):
        try:
            stored = NSUserDefaults.standardUserDefaults().floatForKey_('ContactListWidth')
        except Exception:
            stored = 0
        if stored and stored >= self.CONTACT_LIST_MIN_WIDTH:
            return stored
        return self.CONTACT_LIST_MIN_WIDTH

    @objc.python_method
    def restoreContactListWidthAtLaunch(self):
        """Open with the transcript closed, at the width the list had alone.

        The pane is deliberately NOT reopened even when the user left it
        open: putting a conversation on screen at launch would start marking
        its messages read before anyone asked to see them. Reading is an act
        of intent, so it waits for a click.

        The width still has to be undone, though. The window frame is
        autosaved under ContactWindow, so quitting with the pane open brings
        the app back with the contact list stretched across a window sized
        for two panes.
        """
        from MessageHost import USE_MESSAGE_PANEL
        if not USE_MESSAGE_PANEL:
            return
        self._message_pane_visible = False
        try:
            window = self.window()
            if window is None:
                return
            self.releaseContactListWidth()
            width = self.storedContactListWidth()
            frame = window.frame()
            if frame.size.width - width < 1.0:
                return
            BlinkLogger().log_info('Contact list opens at %.0f, was %.0f with the transcript'
                                   % (width, frame.size.width))
            frame.size.width = width
            # display=False: at this point the window has not been shown yet
            # and asking it to redraw is what makes the resize visible.
            window.setFrame_display_animate_(frame, window.isVisible(), False)
        except Exception as e:
            BlinkLogger().log_error('Cannot restore the contact list width: %s' % e)

    @objc.python_method
    def hideMessagesPane(self):
        if not self._message_pane_visible:
            return
        self.rememberMessagePaneWidth()
        pane_width = 0.0
        split = self.messagePaneSplitView
        try:
            if split is not None:
                pane_width = split.frame().size.width - split.listWidth() - split.dividerThickness()
                if self.messagePaneController is not None:
                    self.messagePaneController.view.removeFromSuperview()
        except Exception as e:
            BlinkLogger().log_error('Cannot hide the messages pane: %s' % e)
        self._message_pane_visible = False
        self.shrinkWindowAfterMessagePane(pane_width)
        self.notifyMessagePaneVisibility()

    @objc.python_method
    def growWindowForMessagePane(self, pane_width):
        """Make the window wide enough to hold the conversation.

        The contact list keeps its own width: the window grows by the width
        of the transcript rather than stealing it from the list.
        """
        try:
            window = self.window()
            split = self.messagePaneSplitView
            if window is None or split is None:
                return
            self.releaseContactListWidth()
            extra = pane_width + split.dividerThickness()
            frame = window.frame()
            frame.size.width += extra
            screen = window.screen() or NSScreen.mainScreen()
            if screen is not None:
                visible = screen.visibleFrame()
                # An open drawer is on the screen too. Leave its strip out
                # of what the window may take, or opening a conversation
                # during a call pushes the call list off the screen.
                edge = self.audioDrawerEdge()
                reserved = self.audioDrawerWidth() if edge is not None else 0.0
                room = max(self.CONTACT_LIST_MIN_WIDTH, visible.size.width - reserved)
                frame.size.width = min(frame.size.width, room)
                if edge == NSMinXEdge:
                    lower = NSMinX(visible) + reserved
                    if NSMinX(frame) < lower:
                        frame.origin.x = lower
                    if NSMaxX(frame) > NSMaxX(visible):
                        frame.origin.x = max(lower, NSMaxX(visible) - frame.size.width)
                else:
                    upper = NSMaxX(visible) - reserved
                    if NSMaxX(frame) > upper:
                        frame.origin.x = max(NSMinX(visible), upper - frame.size.width)
            window.setFrame_display_animate_(frame, True, False)
        except Exception as e:
            BlinkLogger().log_error('Cannot widen the window for the messages pane: %s' % e)

    @objc.python_method
    def shrinkWindowAfterMessagePane(self, pane_width):
        try:
            window = self.window()
            split = self.messagePaneSplitView
            if window is None or split is None or pane_width <= 0:
                return
            self.releaseContactListWidth()
            frame = window.frame()
            frame.size.width = max(self.CONTACT_LIST_MIN_WIDTH,
                                   frame.size.width - pane_width - split.dividerThickness())
            window.setFrame_display_animate_(frame, True, False)
        except Exception as e:
            BlinkLogger().log_error('Cannot narrow the window after the messages pane: %s' % e)

    @objc.python_method
    def messagePane(self):
        """The conversation view, created on first use."""
        if self.messagePaneController is None:
            try:
                from MessagePaneController import MessagePaneController as _Pane
                self.messagePaneController = _Pane.alloc().initWithOwner_(self)
            except Exception as e:
                # Previously this propagated out of the caller and the
                # pane just came up empty with no explanation.
                BlinkLogger().log_error('Cannot build the messages pane: %s' % e)
                import traceback
                traceback.print_exc()
                return None

            split = self.messagePaneHost()
            if split is None:
                BlinkLogger().log_error('The messages pane has nowhere to live')
            else:
                # Sized and attached by showMessagesPane; built detached so
                # that merely creating a conversation never changes the
                # window layout.
                self.messagePaneController.view.setAutoresizingMask_(
                    NSViewWidthSizable | NSViewHeightSizable)
                BlinkLogger().log_info('Messages pane built for the main window')

            try:
                from SMSWindowManager import SMSWindowManager
                SMSWindowManager().setMessagePanelController(self.messagePaneController)
            except Exception as e:
                BlinkLogger().log_error('Cannot register the messages pane: %s' % e)
        return self.messagePaneController

    @objc.python_method
    def contactListMinimumWidth(self):
        """274 for the list alone, twice that with a transcript beside it.

        Narrower than two list minimums and there is no honest way to divide
        the window: the split view has to starve one side, and it is always
        the transcript that ends up a sliver.
        """
        if self._message_pane_visible:
            return self.CONTACT_LIST_MIN_WIDTH * 2
        return self.CONTACT_LIST_MIN_WIDTH

    @objc.python_method
    def releaseContactListWidth(self):
        """Hold the window's width bounds to whatever the layout needs now.

        Both directions matter. A minimum left behind at the window's
        current width strands the contact list wide with no way back; a
        minimum left at 274 while the transcript is open lets the user drag
        the window down to where neither pane fits. The dial pad pins
        min == max at 274 on purpose and is the one state left alone.
        """
        try:
            window = self.window()
            if window is None:
                return
            try:
                if self.mainTabView.selectedTabViewItem().identifier() == 'dialpad':
                    return
            except Exception:
                pass
            minimum = window.contentMinSize()
            maximum = window.contentMaxSize()
            wanted = self.contactListMinimumWidth()
            if maximum.width < wanted or maximum.width <= self.CONTACT_LIST_MIN_WIDTH:
                window.setContentMaxSize_(NSMakeSize(10000.0, max(maximum.height, 2000.0)))
            if abs(minimum.width - wanted) >= 1.0:
                BlinkLogger().log_debug('Window minimum width %.0f -> %.0f'
                                        % (minimum.width, wanted))
                window.setContentMinSize_(NSMakeSize(wanted, minimum.height))
        except Exception as e:
            BlinkLogger().log_error('Cannot set the contact list width bounds: %s' % e)

    @objc.python_method
    def shouldDrawerStayOpen(self):
        # The drawer is the audio call list again and nothing else, so the
        # only reason to keep it open is a call.
        return bool(self.has_audio)

    # 267 is the drawer's content width in MainWindow.xib and its minimum
    # there too; the margin is the border and shadow AppKit draws around the
    # content, which are on screen as much as the content is.
    AUDIO_DRAWER_MIN_WIDTH = 267.0
    AUDIO_DRAWER_MARGIN = 12.0

    @objc.python_method
    def audioDrawerWidth(self):
        """Screen space the open drawer occupies beside the window."""
        width = self.AUDIO_DRAWER_MIN_WIDTH
        try:
            if self.drawer is not None:
                width = max(width, self.drawer.contentSize().width)
        except Exception:
            pass
        return width + self.AUDIO_DRAWER_MARGIN

    @objc.python_method
    def visibleScreenFrame(self):
        try:
            window = self.window()
            screen = (window.screen() if window is not None else None) or NSScreen.mainScreen()
            return screen.visibleFrame() if screen is not None else None
        except Exception:
            return None

    @objc.python_method
    def audioDrawerEdge(self):
        """Which side the drawer is on, or None when it is shut."""
        try:
            if self.drawer is not None and self.drawer.isOpen():
                return self.drawer.edge()
        except Exception:
            pass
        return None

    @objc.python_method
    def openAudioDrawerFitted(self):
        """Open the audio drawer on whichever side of the window it fits.

        Room is found in the cheapest order: the space already beside the
        window, then sliding the window along the screen, and only then
        narrowing it. The narrowing is spent by the split view on the
        transcript, so the contact list keeps the width the user gave it,
        and the window is put back to its old width when the drawer closes.

        The right edge stays the preference; the left is used only when the
        right cannot hold the drawer and the left can. Either way the
        window plus the drawer end up inside the screen.
        """
        if self.drawer is None:
            return
        window = self.window()
        if window is None:
            self.drawer.open()
            return
        try:
            needed = self.audioDrawerWidth()
            visible = self.visibleScreenFrame()
            if visible is None:
                self.drawer.open()
                return

            frame = window.frame()
            right = NSMaxX(visible) - NSMaxX(frame)
            left = NSMinX(frame) - NSMinX(visible)

            if right < needed and left < needed:
                self._width_before_audio_drawer = frame.size.width
                slack = visible.size.width - frame.size.width
                if slack < needed:
                    # Narrow the window by exactly what is missing, never
                    # past the point where both panes still fit.
                    floor = self.contactListMinimumWidth()
                    frame.size.width = max(floor, frame.size.width - (needed - slack))
                frame.origin.x = max(NSMinX(visible),
                                     NSMaxX(visible) - frame.size.width - needed)
                self._fitting_window_for_drawer = True
                try:
                    window.setFrame_display_animate_(frame, True, False)
                finally:
                    self._fitting_window_for_drawer = False
                frame = window.frame()
                # Both widths: the one to give back, and the one that was
                # actually applied, so a width the USER chose in the
                # meantime is left alone when the drawer closes.
                self._width_before_audio_drawer = (self._width_before_audio_drawer,
                                                   frame.size.width)
                right = NSMaxX(visible) - NSMaxX(frame)
                left = NSMinX(frame) - NSMinX(visible)

            if right >= needed:
                edge = NSMaxXEdge
            elif left >= needed:
                edge = NSMinXEdge
            else:
                # A window as wide as the screen with both panes at their
                # minimum: nothing fits properly, so use the roomier side.
                edge = NSMaxXEdge if right >= left else NSMinXEdge
            self.drawer.openOnEdge_(edge)
            BlinkLogger().log_debug('Audio drawer opened on the %s (%.0f free right, %.0f left)'
                                    % ('right' if edge == NSMaxXEdge else 'left', right, left))
        except Exception as e:
            BlinkLogger().log_error('Cannot fit the audio drawer on screen: %s' % e)
            try:
                self.drawer.open()
            except Exception:
                pass

    @objc.python_method
    def restoreWindowWidthAfterAudioDrawer(self):
        """Give back the width that was borrowed to make room for the drawer."""
        stored = self._width_before_audio_drawer
        self._width_before_audio_drawer = None
        if not stored:
            return
        try:
            width, applied = stored
        except (TypeError, ValueError):
            return
        if not width:
            return
        try:
            window = self.window()
            visible = self.visibleScreenFrame()
            if window is None or visible is None:
                return
            frame = window.frame()
            if abs(frame.size.width - applied) > 1.0:
                # Resized by hand while the call was up -- that is the
                # width they want now.
                return
            if frame.size.width >= width - 1.0:
                return
            frame.size.width = min(width, visible.size.width)
            if NSMaxX(frame) > NSMaxX(visible):
                frame.origin.x = max(NSMinX(visible), NSMaxX(visible) - frame.size.width)
            self._fitting_window_for_drawer = True
            try:
                window.setFrame_display_animate_(frame, True, False)
            finally:
                self._fitting_window_for_drawer = False
        except Exception as e:
            BlinkLogger().log_error('Cannot restore the window width after the audio drawer: %s' % e)

    @objc.python_method
    def showAudioDrawer(self):
        if not self.drawer.isOpen() and self.has_audio:
            self.showWindow_(None)
            self.openAudioDrawerFitted()

    @objc.python_method
    def shuffleUpAudioSession(self, audioSessionView):
        # move up the given view in the audio call list so that it is after
        # all other conferenced sessions already at the top and before anything else
        last = None
        found = False
        for v in self.audioSessionsListView.subviews():
            last = v
            if not v.conferencing:
                found = True
                break
            else:
                v.setNeedsDisplay_(True)
        if found and last != audioSessionView:
            audioSessionView.retain()
            audioSessionView.removeFromSuperview()
            self.audioSessionsListView.insertItemView_before_(audioSessionView, last)
            audioSessionView.release()
            audioSessionView.setNeedsDisplay_(True)

    @objc.python_method
    def shuffleDownAudioSession(self, audioSessionView):
        # move down the given view in the audio call list so that it is after
        # all other conferenced sessions
        audioSessionView.retain()
        audioSessionView.removeFromSuperview()
        self.audioSessionsListView.addItemView_(audioSessionView)
        audioSessionView.release()

    @objc.python_method
    def addAudioSessionToConference(self, stream):
        if self.conference is None:
            self.conference = AudioConference()
            BlinkLogger().log_info("Audio conference started")

        self.conference.add(stream.stream)

        stream.view.setConferencing_(True)
        self.shuffleUpAudioSession(stream.view)
        self.conferenceButton.setState_(NSOnState)
        stream.view.setSelected_(True)

    @objc.python_method
    def removeAudioSessionFromConference(self, stream):
        # if we're in a conference and the session is selected, then select back the conference
        # after removing
        wasSelected = stream.view.selected
        self.conference.remove(stream.stream)
        stream.view.setConferencing_(False)
        self.shuffleDownAudioSession(stream.view)

        count = 0
        for session in self.sessionControllersManager.sessionControllers:
            if session.hasStreamOfType("audio"):
                s = session.streamHandlerOfType("audio")
                if s.isConferencing:
                    if count == 0:  # we're the 1st one
                        if not s.view.selected and wasSelected:
                            # force select back of conference
                            s.view.setSelected_(True)
                    count += 1
        if count < 2 and not self.disbandingConference:
            self.disbandConference()

    @objc.python_method
    def holdConference(self):
        if self.conference is not None:
            self.conference.hold()

    @objc.python_method
    def unholdConference(self):
        if self.conference is not None:
            self.conference.unhold()

    @objc.python_method
    def disbandConference(self):
        self.disbandingConference = True
        for session in self.sessionControllersManager.sessionControllers:
            if session.hasStreamOfType("audio"):
                stream = session.streamHandlerOfType("audio")
                if stream.isConferencing:
                    stream.removeFromConference()
        self.conference = None
        self.disbandingConference = False
        self.conferenceButton.setState_(NSOffState)
        BlinkLogger().log_info("Audio conference ended")

    @objc.python_method
    def moveConferenceToServer(self):
        participants = []
        for session in self.sessionControllersManager.sessionControllers:
            if session.hasStreamOfType("audio"):
                stream = session.streamHandlerOfType("audio")
                if stream.isConferencing:
                    participants.append(format_identity_to_string(session.target_uri))

        account = AccountManager().default_account
        if not isinstance(account, BonjourAccount) and account is not None:
            room = random_room()
            if account.conference.server_address:
                target = '%s@%s' % (room, account.conference.server_address)
            else:
                target = '%s@%s' % (room, default_conference_server)

            self.joinConference(target, ("chat", "audio"), participants)
            BlinkLogger().log_info("Move conference to server root %s" % target)
            self.disbandConference()

    @objc.python_method
    def finalizeAudioSession(self, streamController):
        if streamController.isConferencing and self.conference is not None:
            self.removeAudioSessionFromConference(streamController)

        self.audioSessionsListView.removeItemView_(streamController.view)
        self.updateAudioButtons()
        count = self.audioSessionsListView.numberOfItems()
        if self.drawer.isOpen() and count == 0 and not self.shouldDrawerStayOpen():
            self.drawer.close()

    @objc.python_method
    def updateAudioButtons(self):
        c = self.audioSessionsListView.subviews().count()
        cview = self.drawer.contentView()
        hangupAll = cview.viewWithTag_(10)
        conference = cview.viewWithTag_(11)
        hangupAll.setEnabled_(c > 0)

        # number of sessions that can be conferenced
        c = sum(s and 1 or 0 for s in self.sessionControllersManager.sessionControllers if s.hasStreamOfType("audio") and s.streamHandlerOfType("audio").canConference)
        conference.setEnabled_(c > 1)

    @objc.python_method
    def updateStartSessionButtons(self):
        tabItem = self.mainTabView.selectedTabViewItem().identifier()
        audioOk = False
        chatOk = False
        videoOk = False
        screenOk = False
        account = self.activeAccount()
        contacts = self.getSelectedContacts()
        if account is not None:
            if tabItem == "contacts":
                if len(contacts) and not is_anonymous(contacts[0].uri):
                    audioOk = len(contacts) > 0
                    if contacts and account is BonjourAccount() and not is_sip_aor_format(contacts[0].uri):
                        chatOk = False
                        videoOk = False
                    else:
                        chatOk = audioOk
                        videoOk = audioOk and self.sessionControllersManager.isMediaTypeSupported('video')
                    if contacts and not is_sip_aor_format(contacts[0].uri):
                        screenOk = False
                    else:
                        screenOk = audioOk
            elif tabItem == "search":
                audioOk = self.searchBox.stringValue().strip() != ""
                chatOk = audioOk
                screenOk = audioOk
                videoOk = audioOk and self.sessionControllersManager.isMediaTypeSupported('video')
            elif tabItem == "dialpad":
                audioOk = self.searchBox.stringValue().strip() != ""
                chatOk = False
                videoOk = False

        settings = SIPSimpleSettings()
        videoOk = videoOk and bool(settings.video.device)

        if self.sessionControllersManager.isMediaTypeSupported('video'):
            self.actionButtons.setEnabled_forSegment_(audioOk, 0)
            self.actionButtons.setEnabled_forSegment_(videoOk, 1)
            self.actionButtons.setEnabled_forSegment_(chatOk, 2)
            if self.actionButtons.segmentCount() > 3:
                self.actionButtons.setEnabled_forSegment_(screenOk, 3)
        else:
            self.actionButtons.setEnabled_forSegment_(audioOk, 0)
            self.actionButtons.setEnabled_forSegment_(chatOk, 1)
            if self.actionButtons.segmentCount() > 2:
                self.actionButtons.setEnabled_forSegment_(screenOk, 2)

        c = sum(s and 1 or 0 for s in self.sessionControllersManager.sessionControllers if s.hasStreamOfType("audio") and s.streamHandlerOfType("audio").canConference)
        self.addContactToConferenceDialPad.setEnabled_((self.isJoinConferenceWindowOpen() or self.isAddParticipantsWindowOpen() or c > 0) and self.searchBox.stringValue().strip() != "")

    @objc.python_method
    def isJoinConferenceWindowOpen(self):
        return any(window for window in NSApp().windows() if window.title() == 'Join Conference' and window.isVisible())

    @objc.python_method
    def isAddParticipantsWindowOpen(self):
        return any(window for window in NSApp().windows() if window.title() == 'Add Participants' and window.isVisible())

    @objc.python_method
    def getFirstContactMatchingURI(self, uri, exact_match=False):
        if uri is None:
            return None
        return self.model.getFirstContactMatchingURI(uri, exact_match)

    @objc.python_method
    def getBonjourContact(self, instance, uri, online_only=False):
        """The Bonjour row for a neighbour, by device id then by address.

        Pass online_only when the answer is going to be dialled: the group
        also holds rows for neighbours who are not on the network, and their
        address is a placeholder.
        """
        bonjour_contact = None

        if instance:
            bonjour_contact = self.model.getBonjourContactMatchingDeviceId(instance, online_only=online_only)

        if not bonjour_contact:
            bonjour_contact = self.model.getBonjourContactMatchingUri(uri, online_only=online_only)

        return bonjour_contact

    @objc.python_method
    def getFirstContactFromAllContactsGroupMatchingURI(self, uri, exact_match=False):
        return self.model.getFirstContactFromAllContactsGroupMatchingURI(uri, exact_match)

    @objc.python_method
    def hasContactMatchingURI(self, uri, exact_match=False, skip_system_address_book=False):
        return self.model.hasContactMatchingURI(uri, exact_match, skip_system_address_book)

    @objc.python_method
    def iconPathForURI(self, uri, is_focus=False):
        if AccountManager().has_account(uri):
            return self.iconPathForSelf()
        contact = self.getFirstContactFromAllContactsGroupMatchingURI(uri)
        if contact:
            path = contact.avatar.path
            if path is not None and os.path.isfile(path):
                # A face identifies its owner as well as their address
                # does, so while mangling the photograph is withheld and
                # the invented person is drawn as initials instead. The
                # default path is the answer the callers already know how
                # to read as "no picture".
                from ContactMangler import mangling_enabled
                if not mangling_enabled():
                    return path
        return DefaultUserAvatar().path if not is_focus else DefaultMultiUserAvatar().path

    @objc.python_method
    def iconPathForSelf(self):
        settings = SIPSimpleSettings()
        if settings.presence_state.icon and os.path.exists(settings.presence_state.icon.path):
            return settings.presence_state.icon.path
        else:
            return DefaultUserAvatar().path

    @objc.python_method
    def addContact(self, uris=(), name=None):
        self.model.addContact(uris=uris, name=name)
        self.contactOutline.reloadData()

    @objc.python_method
    def resetWidgets(self):
        self.searchBox.setStringValue_("")
        self.addContactToConferenceDialPad.setEnabled_(False)
        self.addContactButtonDialPad.setEnabled_(False)
        self.updateStartSessionButtons()

    @objc.python_method
    def startConferenceIfAppropriate(self, conference, play_initial_announcement=False):
        start_now = True
        if conference.start_when_participants_available and conference.participants:
            for uri in conference.participants:
                presence_contact = self.getFirstContactFromAllContactsGroupMatchingURI(uri)
                if presence_contact:
                    status = presence_status_for_contact(presence_contact)
                    if status != 'available':
                        start_now = False
                else:
                    start_now = False

        if start_now:
            self.joinConference(conference.target, conference.media_type, conference.participants, conference.nickname)
            return True
        else:
            settings = SIPSimpleSettings()
            if play_initial_announcement and not self.speech_synthesizer_active and not self.has_audio and not settings.audio.silent:
                if self.speech_synthesizer is None:
                    self.speech_synthesizer = NSSpeechSynthesizer.alloc().init() or Null
                    self.speech_synthesizer.setDelegate_(self)

                settings = SIPSimpleSettings()
                this_hour = int(datetime.datetime.now(tzlocal()).strftime("%H"))
                volume = 0.8
                if settings.sounds.night_volume.start_hour < settings.sounds.night_volume.end_hour:
                    if settings.sounds.night_volume.start_hour <= this_hour < settings.sounds.night_volume.end_hour:
                        volume = settings.sounds.night_volume.volume/100.0
                elif settings.sounds.night_volume.start_hour > settings.sounds.night_volume.end_hour:
                    if this_hour < settings.sounds.night_volume.end_hour:
                        volume = settings.sounds.night_volume.volume/100.0
                    elif this_hour >= settings.sounds.night_volume.start_hour:
                        volume = settings.sounds.night_volume.volume/100.0
                self.speech_synthesizer.setVolume_(volume)

                self.speech_synthesizer_active = True
                speak_text = 'Conference Scheduled'
                self.speech_synthesizer.startSpeakingString_(speak_text)
            self.scheduled_conferences.add(conference)

        return False

    @objc.python_method
    def showJoinConferenceWindow(self, target=None, participants=None, media_type=None, default_domain=None, autostart=False):
        self.joinConferenceWindow = JoinConferenceWindowController(target=target, participants=participants, media_type=media_type, default_domain=default_domain, autostart=autostart)
        conference = self.joinConferenceWindow.run()
        return conference

    @objc.python_method
    def showAddParticipantsWindow(self, target=None, default_domain=None):
        self.addParticipantsWindow = AddParticipantsWindowController(target=target, default_domain=default_domain)
        participants = self.addParticipantsWindow.run()
        return participants

    @objc.python_method
    def searchContacts(self):
        # BlinkLogger().log_info('startup: searchContacts(noarg) body enter')
        if self.mainTabView.selectedTabViewItem().identifier() == "dialpad":
            self.updateStartSessionButtons()
            # BlinkLogger().log_info('startup: searchContacts(noarg) body exit (dialpad)')
            return

        text = self.searchBox.stringValue().strip()
        if text == "":
            # Nothing to search for. Switch back to the contacts tab and
            # bail before touching matchesURI — that list comprehension
            # iterates every contact in every group and was the single
            # hottest frame in the py-spy startup profile (~18% of CPU
            # samples). Running it for an empty query produced an empty
            # result set anyway.
            self.mainTabView.selectTabViewItemWithIdentifier_("contacts")
            self.updateStartSessionButtons()
            # BlinkLogger().log_info('startup: searchContacts(noarg) body exit (empty query)')
            return
        else:
            self.contactOutline.deselectAll_(None)
            self.mainTabView.selectTabViewItemWithIdentifier_("search")

        self.updateStartSessionButtons()

        if self.mainTabView.selectedTabViewItem().identifier() == "search":
            self.local_found_contacts = []
            local_found_contacts = [contact for group in self.model.groupsList if group.ignore_search is False for contact in self.model.displayedContacts(group) if (text in contact or contact.matchesURI(text))]
            found_count = {}
            for local_found_contact in local_found_contacts:
                if hasattr(local_found_contact, 'contact') and local_found_contact.contact is not None:
                    if local_found_contact.contact.id in list(found_count.keys()):
                        continue
                    else:
                        self.local_found_contacts.append(local_found_contact)
                        found_count[local_found_contact.contact.id] = True
                else:
                    self.local_found_contacts.append(local_found_contact)

            active_account = self.activeAccount()
            if active_account:
                # perform LDAP search
                if len(text) > 3 and self.ldap_directory is not None:
                    self.ldap_found_contacts = []
                    if self.ldap_search.ldap_query_id is not None:
                        self.ldap_search.cancel()
                    self.ldap_search.search(text)

                # create a synthetic contact with what we typed
                try:
                    str(text)
                except UnicodeEncodeError:
                    pass
                else:
                    if " " not in text:
                        input_text = text
                        if active_account is not BonjourAccount():
                            if text.endswith('@'):
                                input_text = '%s%s' % (text, active_account.id.domain)
                            elif "@" not in text:
                                input_text = '%s@%s' % (text, active_account.id.domain)
                        search_icon = NSImage.imageNamed_("lupa")
                        search_icon.setSize_(NSMakeSize(32, 32))
                        input_contact = SearchResultContact(input_text, name=str(input_text), icon=search_icon)
                        exists = text in (contact.uri for contact in self.local_found_contacts)

                        if not exists:
                            self.local_found_contacts.append(input_contact)

                        self.addContactButtonSearch.setEnabled_(not exists)

                self.searchResultsModel.groupsList = self.local_found_contacts
                self.searchOutline.reloadData()
        # BlinkLogger().log_info('startup: searchContacts(noarg) body exit')

    @objc.python_method
    def startSessionToSelectedContact(self, media_type, uri=None):
        # double click
        selected_contact = None
        account = None
        uri_type = 'SIP'
        media_type_print = ", ".join(media_type) if type(media_type) in (tuple, list) else media_type
        try:
            contact = self.getSelectedContacts()[0]
            BlinkLogger().log_info("Starting %s session to selected contact %s %s" % (media_type_print, contact.name, contact.uri))
        except IndexError:
            target = str(self.searchBox.stringValue()).strip()
            if not target:
                return
            BlinkLogger().log_info("Starting %s session to entered address %s" % (media_type_print, target))
        else:
            selected_contact = contact
            if uri:
                target = uri
                uri = uri.split(";")[0]
                try:
                    uri_type = next((contact_uri.type for contact_uri in contact.uris if contact_uri.uri.startswith(uri)))
                except StopIteration:
                    pass
            else:
                target = contact.uri
                uri = contact.uri.split(";")[0]
                try:
                    uri_type = next((contact_uri.type for contact_uri in contact.uris if contact_uri.uri.startswith(uri)))
                except StopIteration:
                    pass

            if isinstance(selected_contact, VoicemailBlinkContact):
                account = AccountManager().get_account(selected_contact.name)
                BlinkLogger().log_info('Auto-selecting voicemail account %s' % account.id)
            elif uri_type == 'XMPP' and isinstance(selected_contact, BlinkPresenceContact):
                try:
                    matched_accounts = list(selected_contact.pidfs_map['sip:%s' % str(uri)].keys())
                except KeyError:
                    matched_accounts = None

                if matched_accounts and AccountManager().default_account.id not in matched_accounts:
                    random_local_aor = matched_accounts.pop()
                    account = AccountManager().get_account(random_local_aor)
                    BlinkLogger().log_info('Auto-selecting account %s authorized by XMPP contact' % account.id)

                if ';xmpp' not in target:
                    target += ';xmpp'

        if account is None:
            account = self.getAccountWitDialPlan(target)
        local_uri = account.id if account is not None else None
        self.startSessionWithTarget(target, media_type=media_type, local_uri=local_uri, selected_contact=selected_contact)

    @objc.python_method
    def startSessionWithTarget(self, target, media_type='audio', local_uri=None, selected_contact=None, from_history=False, display_name=''):
        # activate the app in case the app is not active
        NSApp.activateIgnoringOtherApps_(True)

        if not target:
            if isinstance(selected_contact, VoicemailBlinkContact):
                BlinkLogger().log_error("Missing voicemail URI for %s" % selected_contact.name)
                NSRunAlertPanel(NSLocalizedString("Cannot Initiate Session", "Window title"), NSLocalizedString("No voicemail URI set", "Label"), NSLocalizedString("OK", "Button title"), None, None)
            else:
                BlinkLogger().log_error("Missing target")
            return None

        account = None
        target_uri = None

        if local_uri is not None:
            if local_uri == 'bonjour@local':
                account = BonjourAccount()
            else:
                try:
                    account = AccountManager().get_account(local_uri)
                except KeyError:
                    pass

        if not selected_contact:
            if account is BonjourAccount():
                try:
                    contact = next((contact for contact in self.model.bonjour_group.contacts if contact.id == target))
                except StopIteration:
                    # Addressed by the instance id, never by the ephemeral
                    # sip:user@host:port the neighbour last answered on.
                    _uri = bonjour_offline_uri(target)
                    selected_contact = BonjourBlinkContact(_uri, None, target, name=display_name)
                    target = str(_uri)
                else:
                    account = BonjourAccount()
                    display_name = contact.name
                    target = str(contact.uri)
                    selected_contact = contact
        else:
            try:
                contact = next((contact for contact in self.model.bonjour_group.contacts if contact.uri == target))
            except StopIteration:
                if isinstance(selected_contact, VoicemailBlinkContact):
                    account = AccountManager().get_account(selected_contact.name)
                    BlinkLogger().log_info('Auto-selecting voicemail account %s' % account.id)
                else:
                    display_name = selected_contact.name
            else:
                account = BonjourAccount()
                display_name = contact.name

        if not account:
            account = self.activeAccount()

        if not account:
            NSRunAlertPanel(NSLocalizedString("Cannot Initiate Session", "Window title"), NSLocalizedString("There are currently no active accounts", "Label"),
                            NSLocalizedString("OK", "Button title"), None, None)
            return None

        target_uri = normalize_sip_uri_for_outgoing_session(target, account)

        if not target_uri:
            BlinkLogger().log_error("Error parsing URI %s" % target)
            return None

        if media_type == "sms":
            self.sendMessageToURI()
            return

        if media_type == "video":
            media_type = ("audio", "video")

        try:
            session_controller = self.sessionControllersManager.addControllerWithAccount_target_displayName_contact_(account, target_uri, display_name, selected_contact)
        except AttributeError as e:
            import traceback
            traceback.print_exc()
            return

        if type(media_type) is not tuple:
            if media_type == "chat" and (account is not BonjourAccount() or from_history):
                # just show the window and wait for user to type before starting the outgoing session
                session_controller.open_chat_window_only = True

            if not session_controller.startSessionWithStreamOfType(media_type):
                BlinkLogger().log_error("Failed to start session with stream of type %s" % str(media_type))
                return None
        else:
            if not session_controller.startCompositeSessionWithStreamsOfTypes(media_type):
                BlinkLogger().log_error("Failed to start session with streams of types %s" % str(media_type))
                return None

        return session_controller

    @objc.python_method
    def joinConference(self, target, media_type, participants=(), nickname=None):
        BlinkLogger().log_info("Join conference %s with media %s" % (target, media_type))
        if participants:
            BlinkLogger().log_info("Inviting participants: %s" % participants)

        # activate the app in case the app is not active
        NSApp.activateIgnoringOtherApps_(True)
        account = self.activeAccount()
        if not account:
            NSRunAlertPanel(NSLocalizedString("Cannot Initiate Session", "Window title"), NSLocalizedString("There are currently no active accounts", "Label"), NSLocalizedString("OK", "Button title"), None, None)
            return
            
        if account.conference.nickname != nickname:
            account.conference.nickname = nickname
            account.save()

        target = normalize_sip_uri_for_outgoing_session(target, account)
        if not target:
            return

        session_controller = self.sessionControllersManager.addControllerWithAccount_target_displayName_contact_(account, target, str(target), None)
        session_controller.nickname = nickname
        # A room's chat rides the MSRP plane just like a one-to-one chat, but
        # it is not one -- chat.enable_msrp_chat must not be able to break
        # joining a conference. remote_focus is not known yet (it arrives with
        # the remote Contact header), so say so up front.
        session_controller.is_conference = True

        if participants:
            # Add invited participants to the drawer
            session_controller.mustShowDrawer = True
            for uri in participants:
                presence_contact = self.getFirstContactFromAllContactsGroupMatchingURI(uri)
                if presence_contact:
                    contact = BlinkConferenceContact(uri, name=presence_contact.name, icon=presence_contact.icon, presence_contact=presence_contact)
                else:
                    contact = BlinkConferenceContact(uri=uri, name=uri)
                contact.detail = NSLocalizedString("Invitation sent...", "Contact detail")
                session_controller.invited_participants.append(contact)
                session_controller.participants_log.add(uri)

        if not media_type:
            media_type = 'audio'

        if type(media_type) in (tuple, list):
            if not session_controller.startCompositeSessionWithStreamsOfTypes(media_type):
                BlinkLogger().log_error("Failed to start session with streams of types %s" % str(media_type))
        else:
            if not session_controller.startSessionWithStreamOfType(media_type):
                BlinkLogger().log_error("Failed to start session with stream of type %s" % media_type)

    @objc.python_method
    def focusSearchTextField(self):
        self.searchBox.window().makeFirstResponder_(self.searchBox)
        self.searchBox.window().makeKeyAndOrderFront_(None)

    @objc.python_method
    def play_dtmf(self, key):
        self.playSilence()
        if SIPApplication.voice_audio_bridge:
            filename = 'dtmf_%s_tone.wav' % {'*': 'star', '#': 'pound'}.get(key, key)
            wave_player = WavePlayer(SIPApplication.voice_audio_mixer, Resources.get(filename), volume=50)
            SIPApplication.voice_audio_bridge.add(wave_player)
            wave_player.start()

    @objc.python_method
    @run_in_green_thread
    def get_last_outgoing_session_from_history(self):
        results = SessionHistory().get_entries(direction='outgoing', count=1)
        try:
            session_info = results[0]
        except IndexError:
            pass
        else:
            self.redial(session_info)

    @objc.python_method
    @run_in_gui_thread
    def redial(self, session_info):
        display_name = session_info.display_name
        streams = session_info.media_types.split(",")
        if session_info.local_uri == 'bonjour@local':
            account = BonjourAccount()
            device_id = session_info.device_id
            BlinkLogger().log_info("Redialing to device %s of %s using bonjour account" % (device_id, display_name))
            bonjour_contact = self.model.getBonjourContactMatchingDeviceId(device_id, online_only=True)
            BlinkLogger().log_info("Bonjour neighbour id %s found" % device_id)

            if bonjour_contact:
                target_uri = bonjour_contact.uri
                contact = bonjour_contact
            else:
                BlinkLogger().log_info("Bonjour neighbour %s was not found on this network" % display_name)
                message = NSLocalizedString("Bonjour neighbour %s was not found on this network. ", "label") % display_name
                NSRunAlertPanel(NSLocalizedString("Error", "Window title"), message, NSLocalizedString("OK", "Button title"), None, None)
                return
        else:
            try:
                account = AccountManager().get_account(session_info.local_uri)
            except:
                account = None

            target_uri = sipuri_components_from_string(session_info.remote_uri)[0]
            BlinkLogger().log_info("Redialing to %s using account %s" % (target_uri, account))

            contact = self.getFirstContactFromAllContactsGroupMatchingURI(target_uri)

        if not account:
            BlinkLogger().log_info("Account %s is not active, using default account" % account)
            account = self.activeAccount()

        target_uri = normalize_sip_uri_for_outgoing_session(target_uri, account)

        session_controller = self.sessionControllersManager.addControllerWithAccount_target_displayName_contact_(account, target_uri, display_name, contact)

        if 'audio' in streams and 'chat' in streams:
            # give priority to chat stream so that we do not open audio drawer for composite streams
            sorted_streams = sorted(streams, key=lambda stream: 0 if stream == 'chat' else 1)
            session_controller.startCompositeSessionWithStreamsOfTypes(sorted_streams)
        elif 'audio' in streams and 'video' in streams:
            # give priority to audio stream so that we do not open video window first
            sorted_streams = sorted(streams, key=lambda stream: 0 if stream == 'video' else 1)
            session_controller.startCompositeSessionWithStreamsOfTypes(sorted_streams)
        elif 'audio' in streams:
            session_controller.startAudioSession()
        elif 'chat' in streams:
            session_controller.startChatSession()

    @objc.python_method
    @run_in_green_thread
    def show_last_sms_conversations(self):
        results = SessionHistory().get_last_sms_conversations()
        self.open_last_sms_conversations(results)

    @objc.python_method
    @run_in_green_thread
    def show_unsent_messages(self):
        results = SessionHistory().get_last_unsent_messages()
        self.open_last_sms_conversations(results)

    @objc.python_method
    @run_in_gui_thread
    def open_last_sms_conversations(self, conversations, raise_window=True):
        if raise_window and SMSWindowManager.SMSWindowManager().raiseLastWindowFront():
            return

        for conversation in conversations:
            selected_contact = None
            try:
                account = AccountManager().get_account(conversation['local_uri'])
            except KeyError:
                account = AccountManager().default_account

            contact = self.model.getFirstContactFromAllContactsGroupMatchingURI(conversation['remote_uri'], exact_match=True)
            if contact:
                display_name = contact.name
                selected_contact = contact
            else:
                display_name = conversation['display_name'] or conversation['local_uri']

            if '@' in conversation['remote_uri']:
                instance_id = None
                target_uri = SIPURI.parse(str('sip:%s' % conversation['remote_uri']))
            else:
                instance_id = conversation['remote_uri']
                # The Bonjour group holds a row for this neighbour now --
                # live if they are here, restored from history if not -- so
                # use it rather than inventing a second contact for the same
                # person. The manufactured one carried a loopback address
                # that went on to appear in the conversation header and in
                # the history query as though it were somewhere real.
                # ONLINE only. A Bonjour neighbour who is not on the network
                # has no address to retry against -- the conversation would sit
                # there resolving a route that cannot exist, logging "not
                # found / is offline" on every heartbeat, for as long as the
                # application runs. Retrying is not a thing to attempt at
                # startup and hope for; it is a thing to do WHEN THE NEIGHBOUR
                # COMES BACK, which _NH_BonjourAccountDidAddNeighbour below
                # does with the same unsent messages.
                found = self.model.getBonjourContactMatchingDeviceId(instance_id, online_only=True)
                if found is None:
                    BlinkLogger().log_info('Not reopening the conversation with Bonjour neighbour %s: '
                                           'it is not on the network -- its unsent messages wait '
                                           'until it comes back' % instance_id)
                    continue
                selected_contact = found
                display_name = found.name
                target_uri = SIPURI.parse(str(found.uri))

            # viewerForTarget, not getWindow: this runs ten seconds after
            # launch, unasked, and getWindow puts the conversation on screen.
            # A message pane opening by itself is the application deciding
            # what the user is looking at. The conversation still has to
            # EXIST -- that is what retries the messages that never went out
            # and flushes them when the peer comes back -- but existing and
            # being shown are different things, and viewerForTarget is the
            # half that never touches a window.
            manager = SMSWindowManager.SMSWindowManager()
            viewer = manager.viewerForTarget(
                target_uri, display_name, account,
                instance_id=instance_id, selected_contact=selected_contact)
            if viewer is not None:
                manager.retainViewerInBackground(viewer)
                BlinkLogger().log_info('Conversation with %s reopened in the background '
                                       'to retry unsent messages'
                                       % (instance_id or conversation['remote_uri']))

    @objc.python_method
    @run_in_green_thread
    def show_last_chat_conversations(self):
        results = SessionHistory().get_last_chat_conversations()
        self.open_last_chat_conversations(results)

    @objc.python_method
    @run_in_gui_thread
    def open_last_chat_conversations(self, conversations):
        for conversation in conversations:
            self.startSessionWithTarget(conversation['remote_uri'], media_type="chat", local_uri=conversation['local_uri'], from_history=True, display_name=conversation['display_name'])

    @objc.python_method
    @run_in_green_thread
    def get_last_calls_entries_for_contact(self, contact):
        session_history = SessionHistory()

        if isinstance(contact, BonjourBlinkContact):
            remote_uris = [contact.id]
        else:
            remote_uris = list(uri.uri for uri in contact.uris)

        results = session_history.get_entries(count=10, remote_uris=remote_uris)
        self.renderLastCallsEntriesForContact(results, contact)

    @objc.python_method
    @run_in_green_thread
    def get_session_history_entries(self, count=10):
        entries = {'incoming': [], 'outgoing': [], 'missed': []}

        session_history = SessionHistory()
        results = session_history.get_entries(direction='incoming', status='completed', count=count)

        last_recipient = None
        for result in results:
            if not result or not result.remote_uri or '@' not in result.remote_uri:
                continue

            target_uri, _display_name, full_uri, fancy_uri = sipuri_components_from_string(result.remote_uri)

            try:
                existing_entry = next(e for e in entries['incoming'] if e['account'] == result.local_uri and e['target_uri'] == target_uri)
            except StopIteration:
                display_name = result.display_name or _display_name
                display_name = sip_prefix_pattern.sub("", display_name)

                if target_uri == last_recipient:
                    continue

                last_recipient = target_uri
                contact = self.getFirstContactMatchingURI(target_uri)
                if contact and contact.name and contact.name != contact.uri:
                    display_name = contact.name

                if display_name == target_uri:
                    fancy_uri = target_uri
                elif display_name:
                    if result.local_uri == 'bonjour@local':
                        fancy_uri = display_name
                    else:
                        fancy_uri = '%s <%s>' % (display_name, target_uri)

                item = {
                    "streams": result.media_types.split(","),
                    "account": result.local_uri,
                    "remote_party": fancy_uri,
                    "display_name": display_name,
                    "target_uri": target_uri,
                    "status": result.status,
                    "failure_reason": result.failure_reason,
                    "start_time": format_date(utc_to_local(result.start_time)),
                    "duration": result.end_time - result.start_time,
                    "focus": result.remote_focus,
                    "participants": result.participants.split(",") if result.participants else []
                }

                entries['incoming'].append(item)

        results = session_history.get_entries(direction='outgoing', count=count)

        last_recipient = None
        for result in results:
            if not result or not result.remote_uri or '@' not in result.remote_uri:
                continue

            target_uri, _display_name, full_uri, fancy_uri = sipuri_components_from_string(result.remote_uri)
            
            try:
                existing_entry = next(e for e in entries['outgoing'] if e['account'] == result.local_uri and e['target_uri'] == target_uri)
            except StopIteration:
                display_name = result.display_name or _display_name
                display_name = sip_prefix_pattern.sub("", display_name)

                if target_uri == last_recipient:
                    continue
                last_recipient = target_uri
                contact = self.getFirstContactMatchingURI(target_uri)
                if contact and contact.name and contact.name != target_uri:
                    display_name = contact.name

                if display_name == target_uri:
                    fancy_uri = target_uri
                elif display_name:
                    if result.local_uri == 'bonjour@local':
                        fancy_uri = display_name
                    else:
                        fancy_uri = '%s <%s>' % (display_name, target_uri)

                item = {
                    "streams": result.media_types.split(","),
                    "account": result.local_uri,
                    "remote_party": fancy_uri,
                    "display_name": display_name,
                    "target_uri": target_uri,
                    "status": result.status,
                    "failure_reason": result.failure_reason,
                    "start_time": format_date(utc_to_local(result.start_time)),
                    "duration": result.end_time - result.start_time,
                    "focus": result.remote_focus,
                    "participants": result.participants.split(",") if result.participants else []
                }

                entries['outgoing'].append(item)

        results = session_history.get_entries(direction='incoming', status='missed', count=count)

        last_recipient = None
        for result in results:
            if not result or not result.remote_uri or '@' not in result.remote_uri:
                continue

            target_uri, _display_name, full_uri, fancy_uri = sipuri_components_from_string(result.remote_uri)

            try:
                existing_entry = next(e for e in entries['missed'] if e['account'] == result.local_uri and e['target_uri'] == target_uri)
            except StopIteration:
                display_name = result.display_name or _display_name
                display_name = sip_prefix_pattern.sub("", display_name)

                if target_uri == last_recipient:
                    continue
                last_recipient = target_uri
                contact = self.getFirstContactMatchingURI(target_uri)
                if contact and contact.name and contact.name != target_uri:
                    display_name = contact.name

                if display_name == target_uri:
                    fancy_uri = target_uri
                elif display_name:
                    if result.local_uri == 'bonjour@local':
                        fancy_uri = display_name
                    else:
                        fancy_uri = '%s <%s>' % (display_name, target_uri)

                item = {
                    "streams": result.media_types.split(","),
                    "account": result.local_uri,
                    "remote_party": fancy_uri,
                    "display_name": display_name,
                    "target_uri": target_uri,
                    "status": result.status,
                    "failure_reason": result.failure_reason,
                    "start_time": format_date(utc_to_local(result.start_time)),
                    "duration": result.end_time - result.start_time,
                    "focus": result.remote_focus,
                    "participants": result.participants.split(",") if result.participants else []
                }
                entries['missed'].append(item)

        self.renderHistoryEntriesInHistoryMenu(entries)
        self.renderHistoryEntriesInStatusBarMenu(entries)

    @objc.python_method
    def format_history_menu_item(self, item):
        from ContactMangler import mangled_text
        a = NSMutableAttributedString.alloc().init()
        # The other party's name or address, as this menu draws it.
        n = NSAttributedString.alloc().initWithString_attributes_(
            "%s  " % mangled_text(item['remote_party']), normal_font_color)
        a.appendAttributedString_(n)
        text = "%(start_time)s" % item
        if item["duration"].seconds > 0:
            text += NSLocalizedString(" for ", "Menu item")
            dur = item["duration"]
            if dur.days > 0 or dur.seconds > 60*60:
                text += "%i hours, " % (dur.days*60*60*24 + int(dur.seconds/(60*60)))
            s = dur.seconds % (60*60)
            text += "%02i:%02i" % divmod(s, 60)
        else:
            if item['status'] == 'failed':
                text += " (%s)" % item['failure_reason'].capitalize()
            elif item['status'] not in ('completed', 'missed'):
                text += " %s" % session_status_localized[item['status']]

        text_format = red_font_color if item['status'] == 'failed' else gray_font_color
        t = NSAttributedString.alloc().initWithString_attributes_(text, text_format)
        a.appendAttributedString_(t)
        return a

    @objc.python_method
    def sip_session_missed(self, session, stream_types):
        BlinkLogger().log_info("Missed incoming session from %s" % format_identity_to_string(session.remote_identity))
        if 'audio' in stream_types:
            NSApp.delegate().noteMissedCall()

    @objc.python_method
    def speak_text(self, text):
        settings = SIPSimpleSettings()
        if not settings.audio.silent:
            this_hour = int(datetime.datetime.now(tzlocal()).strftime("%H"))
            volume = 0.8

            if settings.sounds.night_volume.start_hour < settings.sounds.night_volume.end_hour:
                if settings.sounds.night_volume.start_hour <= this_hour < settings.sounds.night_volume.end_hour:
                    volume = settings.sounds.night_volume.volume/100.0
            elif settings.sounds.night_volume.start_hour > settings.sounds.night_volume.end_hour:
                if this_hour < settings.sounds.night_volume.end_hour:
                    volume = settings.sounds.night_volume.volume/100.0
                elif this_hour >= settings.sounds.night_volume.start_hour:
                    volume = settings.sounds.night_volume.volume/100.0

            self.speech_synthesizer.setVolume_(volume)
            self.speech_synthesizer.startSpeakingString_(text)

    def purgePresenceTimer_(self, timer):
        for account in AccountManager().get_accounts():
            if account is BonjourAccount():
                continue

            if not account.enabled or not account.presence.enabled:
                continue
            try:
                position = self.accounts.index(account)
            except ValueError:
                continue

            account_info = self.accounts[position]

            if account_info.subscribe_presence_state != 'failed':
                continue

            if account_info.subscribe_presence_purged:
                continue

            if account_info.subscribe_presence_timestamp is None:
                continue

            if account_info.register_failure_reason != NSLocalizedString("No Internet connection", "Label"):
                delta = time.time() - account_info.subscribe_presence_timestamp
                if delta < 90:
                    continue

            account_info.subscribe_presence_timestamp = None
            account_info.subscribe_presence_purged = True
            BlinkLogger().log_debug("Presence subscriptions for account %s purged" % account.id)
            NotificationCenter().post_notification("BlinkPresenceFailed", sender=account.id)

    def rightMouseDown_(self, event):
        point = self.window().convertScreenToBase_(NSEvent.mouseLocation())
        event = NSEvent.mouseEventWithType_location_modifierFlags_timestamp_windowNumber_context_eventNumber_clickCount_pressure_(NSRightMouseUp, point, 0, NSDate.timeIntervalSinceReferenceDate(), self.window().windowNumber(), self.window().graphicsContext(), 0, 1, 0)

        menu = NSMenu.alloc().init()
        session = self.getSelectedAudioSession()
        video_stream = session.streamHandlerOfType("video")
        if video_stream and video_stream.status == STREAM_CONNECTED:
            menu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Remove Video", "Menu item"), "userClickedRemoveVideo:", "")

        lastItem = menu.addItemWithTitle_action_keyEquivalent_(NSLocalizedString("Detach Video", "Menu item"), "userClickedDetachVideo:", "")
        NSMenu.popUpContextMenu_withEvent_forView_(menu, event, self.window().contentView())

    def rotateCamera_(self, timer):
        if not self.sessionControllersManager.isMediaTypeSupported('video'):
            return

        settings = SIPSimpleSettings()
        if settings.video.auto_rotate_cameras and len(self.sessionControllersManager.connectedVideoSessions):
            try:
                from ScreenPointer import is_screen_device
            except ImportError:
                is_screen_device = lambda device: False
            current = self.backend._app.video_device.real_name
            if is_screen_device(current):
                # Sharing my screen: not a camera, don't rotate away from it
                return
            devices = list(device for device in NSApp.delegate().video_devices if device not in ('system_default', None) and not is_screen_device(device))
            if len(devices) < 2:
                return
            try:
                idx = devices.index(current)
            except ValueError:
                return
            else:
                try:
                    new_device = devices[idx + 1]
                except IndexError:
                    new_device = devices[0]

                settings.video.device = new_device
                settings.save()

    @run_in_gui_thread
    def userDefaultsDidChange_(self, notification):
        self.setAlwaysOnTop()

    def showWindow_(self, sender):
        if NSApp.delegate().main_window_title is not None:
            self.window().setTitle_(NSApp.delegate().main_window_title)
        else:
            self.window().setTitle_(NSApp.delegate().applicationNamePrint)
        objc.super(ContactWindowController, self).showWindow_(sender)

    def copyToSearchBar_(self, sender):
        self.searchBox.setStringValue_(sender.representedObject())

    def hideHistoryEntries_(self, sender):
        session_ids = sender.representedObject()
        SessionHistory().hide_entries(session_ids)

    def showHiddenEntries_(self, sender):
        group = sender.representedObject()
        if isinstance(group, MissedCallsBlinkGroup):
            SessionHistory().show_missed_entries()
        elif isinstance(group, IncomingCallsBlinkGroup):
            SessionHistory().show_incoming_entries()
        elif isinstance(group, OutgoingCallsBlinkGroup):
            SessionHistory().show_outgoing_entries()

    @objc.IBAction
    def hideGroup_(self, sender):
        settings = SIPSimpleSettings()
        group = sender.representedObject()
        if isinstance(group, MissedCallsBlinkGroup):
            settings.contacts.enable_missed_calls_group = False
        elif isinstance(group, IncomingCallsBlinkGroup):
            settings.contacts.enable_incoming_calls_group = False
        elif isinstance(group, OutgoingCallsBlinkGroup):
            settings.contacts.enable_outgoing_calls_group = False
        elif isinstance(group, OnlineGroup):
            settings.contacts.enable_online_group = False
        settings.save()

    @run_in_green_thread
    def setHistoryPeriod_(self, sender):
        object = sender.representedObject()
        group = object['group']
        group.setPeriod_(object['days'])

        settings = SIPSimpleSettings()
        if isinstance(group, MissedCallsBlinkGroup):
            settings.contacts.missed_calls_period = object['days']
        elif isinstance(group, IncomingCallsBlinkGroup):
            settings.contacts.incoming_calls_period = object['days']
        elif isinstance(group, OutgoingCallsBlinkGroup):
            settings.contacts.outgoing_calls_period = object['days']
        settings.save()

    @objc.IBAction
    def showPendingRequests_(self, sender):
        self.model.renderPendingWatchersGroupIfNecessary(bring_in_focus=True)

    @objc.IBAction
    def toggleChatPrivacy_(self, sender):
        settings = SIPSimpleSettings()
        settings.chat.disable_history = not settings.chat.disable_history
        settings.save()

    @objc.IBAction
    def showPresenceInfo_(self, sender):
        if sender.tag() == 50:  # main menu selected
            row = self.contactOutline.selectedRow()
            selected = self.contactOutline.itemAtRow_(row) if row >= 0 else None
            if selected is not None:
                has_presence_info = isinstance(selected, BlinkPresenceContact) and selected.pidfs_map
                if has_presence_info:
                    if not self.presenceInfoPanel:
                        self.presenceInfoPanel = PresenceInfoController()
                    self.presenceInfoPanel.show(selected)
        else:  # contextual menu selected
            if not self.presenceInfoPanel:
                self.presenceInfoPanel = PresenceInfoController()
            self.presenceInfoPanel.show(sender.representedObject())

    @objc.python_method
    def hasOpenChatTabs(self):
        try:
            return bool(NSApp.delegate().chatWindowController.tabView.tabViewItems())
        except AttributeError:
            return False

    @objc.IBAction
    def showChatWindow_(self, sender):
        if not SIPSimpleSettings().chat.enable_msrp_chat and not self.hasOpenChatTabs():
            return
        if NSApp.delegate().chatWindowController.tabView.tabViewItems():
            NSApp.delegate().chatWindowController.window().makeKeyAndOrderFront_(None)
        else:
            self.show_last_chat_conversations()

    @objc.IBAction
    def showSMSWindow_(self, sender):
        from MessageHost import USE_MESSAGE_PANEL
        if USE_MESSAGE_PANEL:
            if self.isMessagesPaneVisible():
                self.hideMessagesPane()
            else:
                self.showMessagesPane()
            return
        self.show_last_sms_conversations()

    @objc.python_method
    def publicKeyLookupTargets(self):
        """Every address of the selected contact worth asking about.

        A contact can hold several addresses and a key is published per
        address, so looking up only the primary one leaves the rest stale --
        and a stale key is exactly what this exists to replace. Our own
        accounts are skipped (we publish that key, we do not fetch it), as
        are Bonjour neighbours, who have no server to ask.
        """
        selected = self.getSelectedContacts(includeGroups=False)
        if len(selected) != 1:
            return []
        contact = selected[0]
        try:
            if contact in self.model.bonjour_group.contacts:
                return []
        except Exception:
            pass

        account_manager = AccountManager()
        targets = []
        seen = set()
        for entry in getattr(contact, 'uris', ()):
            uri = str(getattr(entry, 'uri', '') or '').strip()
            if '@' not in uri or uri.lower() in seen:
                continue
            try:
                if account_manager.has_account(uri):
                    continue
            except Exception:
                pass
            seen.add(uri.lower())
            targets.append(uri)
        return targets

    @objc.python_method
    def canLookupPublicKeyForSelection(self):
        return bool(self.publicKeyLookupTargets())

    @objc.IBAction
    def lookupPublicKey_(self, sender):
        """Ask the server for the selected contact's current PGP keys.

        One lookup per address the contact holds, since keys are published
        per address. The stale keys on disc are deliberately left alone
        until replies arrive: if the server has no key for an address,
        holding the old one still beats holding none, and a reply overwrites
        it anyway.
        """
        targets = self.publicKeyLookupTargets()
        if not targets:
            BlinkLogger().log_info('Public key lookup needs one other contact selected')
            return

        contact = self.getSelectedContacts(includeGroups=False)[0]
        account = self.activeAccount()
        if not account:
            BlinkLogger().log_error('No active account for a public key lookup')
            return

        from SMSWindowManager import SMSWindowManager
        manager = SMSWindowManager()
        for uri in targets:
            try:
                normalized = normalize_sip_uri_for_outgoing_session(uri, account)
                if not normalized:
                    BlinkLogger().log_info('Cannot normalize %s for a public key lookup' % uri)
                    continue
                viewer = manager.viewerForTarget(normalized, contact.name, account)
                if viewer is None:
                    BlinkLogger().log_error('No conversation available for %s' % uri)
                    continue
                BlinkLogger().log_info('Looking up the public key of %s' % uri)
                viewer.requestPublicKey()
            except Exception as e:
                BlinkLogger().log_error('Public key lookup for %s failed: %s' % (uri, e))

    @objc.IBAction
    def showUnsentMessages_(self, sender):
        self.show_unsent_messages()

    @objc.IBAction
    def showDonate_(self, sender):
        NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_("https://www.paypal.me/AGProjectsNL"))

    @objc.IBAction
    def showBlinkPro_(self, sender):
        NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_("http://itunes.apple.com/us/app/blink-pro/id404360415?mt=12&ls=1"))

    @objc.IBAction
    def showSylkServer_(self, sender):
        NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_("https://itunes.apple.com/us/app/sylkserver/id560866009?mt=12"))

    @objc.IBAction
    def showServiceProvider_(self, sender):
        settings = SIPSimpleSettings()
        if sender.tag() == 5:  # About Service Provider
            NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(str(settings.service_provider.about_url)))
        elif sender.tag() == 6:  # Help from Service Provider
            NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(str(settings.service_provider.help_url)))

    @objc.IBAction
    def openURL_(self, sender):
        url = sender.representedObject()
        NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(str(url)))

    def windowShouldClose_(self, sender):
        ev = NSApp.currentEvent()
        if ev.type() == NSKeyDown:
            if ev.keyCode() == 53:  # don't close on Escape key
                return False
        return True

    def copyURI_(self, sender):
        pboard = NSPasteboard.generalPasteboard()
        pboard.declareTypes_owner_([NSStringPboardType], self)
        pboard.setString_forType_(str(sender.representedObject().uri), NSStringPboardType)

    def pasteURI_(self, sender):
        blink_contact = sender.representedObject()['contact']
        blink_contact.contact.uris.add(ContactURI(uri=sender.representedObject()['uri']))
        blink_contact.contact.save()

    def newAudioDeviceTimeout_(self, timer):
        NSApp.stopModalWithCode_(NSAlertAlternateReturn)

    def contactSelectionChanged_(self, notification):
        self.updateStartSessionButtons()
        # Extending the selection with shift is picking contacts to act on
        # together, not opening a conversation: the pane stays where it is.
        if len(self.getSelectedContacts()) <= 1:
            self.switchMessagePaneToSelectedContact()
        readonly = any((getattr(c, "editable", None) is False) for c in self.getSelectedContacts(True))

        self.contactsMenu.itemWithTag_(31).setEnabled_(not readonly and len(self.getSelectedContacts(includeGroups=False)) > 0)
        self.contactsMenu.itemWithTag_(32).setEnabled_(not readonly and len(self.getSelectedContacts(includeGroups=True)) > 0)
        self.contactsMenu.itemWithTag_(33).setEnabled_(not readonly)
        self.contactsMenu.itemWithTag_(34).setEnabled_(not readonly)
        self.contactsMenu.itemWithTag_(36).setEnabled_(len(self.getSelectedContacts(includeGroups=True)) > 0)

        item = self.contactsMenu.itemWithTag_(68)
        if item is not None:
            item.setEnabled_(self.canLookupPublicKeyForSelection())

    def allowPresenceForContacts_(self, sender):
        blink_contacts = sender.representedObject()
        for blink_contact in blink_contacts:
            blink_contact.contact.presence.policy = 'allow'
            blink_contact.contact.save()

    def blockPresenceForContacts_(self, sender):
        blink_contacts = sender.representedObject()
        for blink_contact in blink_contacts:
            blink_contact.contact.presence.policy = 'block'
            blink_contact.contact.save()

    def blockPresenceForURI_(self, sender):
        item = sender.representedObject()
        policy_contact = Policy()
        policy_contact.uri = item.uri
        policy_contact.presence.policy = 'block'
        policy_contact.save()

    def windowWillUseStandardFrame_defaultFrame_(self, window, nframe):
        if self.originalSize:
            nframe = window.frame()
            nframe.size = self.originalSize
            nframe.origin.y -= nframe.size.height - window.frame().size.height
            self.originalSize = None
            self.setCollapsed(False)
        else:
            self.setCollapsed(True)
            self.originalSize = window.frame().size
            nframe = window.frame()
            nframe.origin.y += nframe.size.height - 154
            nframe.size.height = 154
        return nframe

    def windowWillResize_toSize_(self, sender, size):
        if size.height == 157:
            size.height = 154
        return size

    def windowDidResize_(self, notification):
        # A content minimum left behind at the window's current width is what
        # strands the contact list wide with no way back, and it can be set
        # from several places. Relax it on every resize rather than only when
        # the drawer happens to change.
        self.releaseContactListWidth()
        self.rememberMessagePaneWidth()
        if NSHeight(self.window().frame()) > 154:
            self.originalSize = None
            self.setCollapsed(False)
        else:
            self.setCollapsed(True)

        # make sure some controls are in their correct position after a resize of the window
        if self.notFoundTextOffset is not None:
            frame = self.notFoundText.frame()
            frame.origin.y = NSHeight(self.notFoundText.superview().frame()) - self.notFoundTextOffset
            self.notFoundText.setFrame_(frame)

            frame = self.searchOutline.enclosingScrollView().frame()
            if self.searchOutlineTopOffset:
                frame.size.height = NSHeight(self.searchOutline.enclosingScrollView().superview().frame()) - self.searchOutlineTopOffset
                self.searchOutline.enclosingScrollView().setFrame_(frame)

        self.recalculateDrawerSplitter()

    def windowDidMove_(self, notification):
        self.releaseContactListWidth()

    def drawerDidOpen_(self, notification):
        self.windowMenu = NSApp.mainMenu().itemWithTag_(300).submenu()
        if self.collapsedState:
            self.window().zoom_(None)
            self.setCollapsed(True)

        sessionBoxes = self.audioSessionsListView.subviews()
        if sessionBoxes.count() > 0:
            selected = [session for session in sessionBoxes if session.selected]
            if selected:
                self.window().makeFirstResponder_(selected[0])
            else:
                self.window().makeFirstResponder_(sessionBoxes.objectAtIndex_(0))

            session = self.getSelectedAudioSession()
            if session:
                session.setVideoConsumer(session.video_consumer)

        self.recalculateDrawerSplitter()

    def drawerDidClose_(self, notification):
        self.windowMenu = NSApp.mainMenu().itemWithTag_(300).submenu()
        if self.collapsedState:
            self.window().zoom_(None)
            self.setCollapsed(True)
        self.setVideoProducer(None)
        self.restoreWindowWidthAfterAudioDrawer()

    @objc.IBAction
    def toggleAudioSessionsDrawer_(self, sender):
        if self.drawer.isOpen():
            self.drawer.close()
            return
        if not self.drawer.isOpen():
            self.showWindow_(None)
            self.openAudioDrawerFitted()

    @objc.IBAction
    def showDebugWindow_(self, sender):
        NSApp.delegate().debugWindow.show()

    @objc.IBAction
    def setAlwaysOnTop_(self, sender):
        always_on_top = NSUserDefaults.standardUserDefaults().boolForKey_("AlwaysOnTop")
        NSUserDefaults.standardUserDefaults().setBool_forKey_(True if not always_on_top else False, "AlwaysOnTop")

    @objc.IBAction
    def setSpeechSyntezis_(self, sender):
        settings = SIPSimpleSettings()
        settings.sounds.enable_speech_synthesizer = not settings.sounds.enable_speech_synthesizer
        settings.save()
        self.setSpeechSynthesis()

    @objc.IBAction
    def setUseSpeechRecognition_(self, sender):
        settings = SIPSimpleSettings()
        settings.sounds.use_speech_recognition = not settings.sounds.use_speech_recognition
        settings.save()

    @objc.IBAction
    def displayNameChanged_(self, sender):
        name = str(self.nameText.stringValue())
        # The field is showing an invented name while contacts are
        # mangled, and committing it would write that name into the
        # account for good. Put the label back and save nothing.
        from ContactMangler import mangling_enabled
        if mangling_enabled():
            account = self.activeAccount()
            self.updateNameLabel((account.display_name or '') if account is not None else '')
            sender.resignFirstResponder()
            return
        self.activeAccount().display_name = name
        self.activeAccount().save()
        sender.resignFirstResponder()
        # The name typed here is what my initials are made of, and this
        # field's action does not go through updateNameLabel.
        self.updateOwnAvatarName()

    @objc.IBAction
    def startAudioSessionWithSIPURI_(self, sender):
        self.startSessionWithTarget(sender.representedObject(), media_type="audio")

    @objc.IBAction
    def startVideoSessionWithSIPURI_(self, sender):
        self.startSessionWithTarget(sender.representedObject(), media_type=("audio", "video"))

    @objc.IBAction
    def startChatSessionWithSIPURI_(self, sender):
        self.startSessionWithTarget(sender.representedObject(), media_type="chat")

    @objc.IBAction
    def startAudioToSelected_(self, sender):
        self.startSessionToSelectedContact("audio", sender.representedObject())

    @objc.IBAction
    def startVideoToSelected_(self, sender):
        self.startSessionToSelectedContact(("audio", "video"), sender.representedObject())

    @objc.IBAction
    def startChatToSelected_(self, sender):
        self.startSessionToSelectedContact("chat", sender.representedObject())

    @objc.IBAction
    def sendMessageToSelected_(self, sender):
        uri = sender.representedObject()
        self.sendMessageToURI(uri)

    @objc.IBAction
    def createPresenceContactFromOtherContact_(self, sender):
        contact = sender.representedObject()
        uris = ((uri.uri, uri.type) for uri in contact.uris)
        self.model.addContact(uris=uris, name=contact.name)

    @objc.IBAction
    def addContact_(self, sender):
        if self.mainTabView.selectedTabViewItem().identifier() == "search":
            row = self.searchOutline.selectedRow()
            if row != NSNotFound and row != -1:
                item = self.searchOutline.itemAtRow_(row)
                contact = self.model.addContact(uris=[(item.uri, 'sip')], name=item.name)
            else:
                contact = self.model.addContact(uris=[(self.searchBox.stringValue(), 'sip')])

            if contact:
                self.resetWidgets()
                self.refreshContactsList()
                self.searchContacts()

                row = self.contactOutline.rowForItem_(contact)
                if row != NSNotFound and row != -1:
                    self.contactOutline.selectRowIndexes_byExtendingSelection_(NSIndexSet.indexSetWithIndex_(row), False)
                    self.contactOutline.scrollRowToVisible_(row)
                    self.window().makeFirstResponder_(self.contactOutline)
        else:
            item = self.contactOutline.itemAtRow_(self.contactOutline.selectedRow())
            if isinstance(item, BlinkContact):
                group = self.contactOutline.parentForItem_(item)
            else:
                group = item
            contact = self.model.addContact(group=group if group and group.add_contact_allowed else None)
            # TODO  what is this? -adi
            if contact:
                self.refreshContactsList()
                self.searchContacts()

                row = self.contactOutline.rowForItem_(contact)
                if row != NSNotFound and row != -1:
                    self.contactOutline.selectRowIndexes_byExtendingSelection_(NSIndexSet.indexSetWithIndex_(row), False)
                    self.contactOutline.scrollRowToVisible_(row)
                    self.window().makeFirstResponder_(self.contactOutline)

    @objc.IBAction
    def editContact_(self, sender):
        self.model.editContact(sender.representedObject())
        self.refreshContactsList()
        self.searchContacts()

    @objc.IBAction
    def mergeContacts_(self, sender):
        source, destination = sender.representedObject()
        for uri in source.uris:
            destination.contact.uris.add(ContactURI(uri=uri.uri, type=format_uri_type(uri.type)))
        destination.contact.save()

    @objc.IBAction
    def removeContactFromGroup_(self, sender):
        contact, group = sender.representedObject()
        self.model.removeContactFromGroups(contact, [group])
        self.refreshContactsList()
        self.searchContacts()

    @objc.IBAction
    def toogleExpand_(self, sender):
        selection = self.contactOutline.selectedRowIndexes()
        item = selection.firstIndex()
        if item != NSNotFound:
            object = self.contactOutline.itemAtRow_(item)
            if object is not None:
                if isinstance(object, BlinkContact):
                    group = self.contactOutline.parentForItem_(object)
                else:
                    group = object

                if group.group.expanded:
                    self.contactOutline.collapseItem_(group)
                else:
                    self.contactOutline.expandItem_expandChildren_(group, False)
                self.model.saveGroupPosition()
                self.contactOutline.selectRowIndexes_byExtendingSelection_(NSIndexSet.indexSetWithIndex_(self.contactOutline.rowForItem_(group)), False)

    @objc.IBAction
    def deletePolicyItem_(self, sender):
        item = sender.representedObject()
        item.policy.delete()

    @objc.IBAction
    def deleteDuplicateItems_(self, sender):
        item = sender.representedObject()
        try:
            uri = item.uri
            #blink_contacts = list(self.model.getBlinkContactsForName(item.name))
            blink_contacts = list(self.model.getBlinkContactsForURI(str(uri), exact_match=True))
            if len(blink_contacts) > 1:
                message = NSLocalizedString("Delete %d duplicates for %s from the Contacts list?", "Label") % (len(blink_contacts) - 1, item.uri)
                message = re.sub("%", "%%", message)
                ret = NSRunAlertPanel(NSLocalizedString("Delete Duplicates", "Window title"), message, NSLocalizedString("Delete", "Button title"), NSLocalizedString("Cancel", "Button title"), None)
                if ret == NSAlertDefaultReturn:
                    self.model.deleteDuplicateContacts(blink_contacts, selected_item=item)
            else:
                message = NSLocalizedString("No duplicates found", "Label")
                NSRunAlertPanel(NSLocalizedString("Delete Duplicates", "Window title"), message, NSLocalizedString("OK", "Button title"), None, None)

        except Exception as e:
            print(str(e))
            import traceback
            traceback.print_exc()

    @objc.IBAction
    def deleteItem_(self, sender):
        # Reached from the context menu (the item rides on the menu item), from
        # the Contacts menu (no item: act on the selection), and from the Delete
        # key in either list, where the sender is the list itself. The
        # selection has to be read from the list the user is looking at: the
        # contacts outline has no idea what is selected in the search results.
        item = sender.representedObject() if sender.respondsToSelector_("representedObject") else None
        if not item:
            if sender == self.contactOutline or sender == self.searchOutline:
                outline = sender
            elif self.mainTabView.selectedTabViewItem().identifier() == "search":
                outline = self.searchOutline
            else:
                outline = self.contactOutline
            if outline == self.contactOutline and outline.numberOfSelectedRows() > 1:
                self.deleteSelectedContacts_(sender)
                return
            row = outline.selectedRow()
            if row >= 0:
                item = outline.itemAtRow_(row)
            else:
                return
        if item is None or not getattr(item, "deletable", False):
            return
        if isinstance(item, BlinkGroup):
            self.model.deleteGroup(item)
            self.refreshContactsList()
            self.searchContacts()
        else:
            # Same lookup the menu used to offer the item: a row selected in the
            # search tab has no parent in the contacts outline, and asking for
            # one there is how this quietly did nothing.
            group = self.groupForContact(item)
            if group is not None and getattr(group, "isDeletedGroup", None) and group.isDeletedGroup():
                self.model.expungeContacts([item])
                self.refreshContactsList()
                self.searchContacts()
                return
            if group is None or group.delete_contact_allowed:
                # Noted before the confirmation dialog, so the decision is made
                # on the tab the user actually acted from.
                searching = self.mainTabView.selectedTabViewItem().identifier() == "search"
                self.model.deleteContact(item)
                self.refreshContactsList()
                if searching:
                    # The result list is an answer to a question whose subject
                    # has just been deleted. Re-running the same search leaves
                    # the query in the box and the row's neighbours on screen,
                    # which reads as though the delete half-worked. Clearing the
                    # box is enough on its own: searchContacts() treats an empty
                    # query as "go back to the contact list".
                    self.resetWidgets()
                self.searchContacts()

    @objc.IBAction
    def deleteContactsFromGroup_(self, sender):
        item = sender.representedObject()
        if not item:
            row = self.contactOutline.selectedRow()
            if row >= 0:
                item = self.contactOutline.itemAtRow_(row)
            else:
                return
        if isinstance(item, BlinkGroup):
            self.model.deleteContactsFromGroup(item)
            self.refreshContactsList()
            self.searchContacts()

    @objc.python_method
    def _deletedConversationURIs(self, item):
        """Which of a contact's addresses have a removed conversation.

        Asked of the message manager rather than of the outline: the same
        menu is built for a row in the contact list and for one in the
        search results, and only one of those has a group above it.
        """
        try:
            from SMSWindowManager import SMSWindowManager
            manager = SMSWindowManager()
        except Exception:
            return []
        if not getattr(manager, 'deleted_conversations', None):
            return []
        found = []
        try:
            for uri in getattr(item, 'uris', ()) or ():
                address = str(uri.uri)
                if manager._canonical_uri(address) in manager.deleted_conversations:
                    found.append(address)
            if not found:
                address = str(getattr(item, 'uri', '') or '')
                if address and manager._canonical_uri(address) in manager.deleted_conversations:
                    found.append(address)
        except Exception:
            return []
        return found

    @objc.IBAction
    def restoreDeletedConversation_(self, sender):
        item = sender.representedObject()
        addresses = self._deletedConversationURIs(item)
        if not addresses:
            return
        from SMSWindowManager import SMSWindowManager
        manager = SMSWindowManager()
        for address in addresses:
            manager.restoreConversation(address)
        self.refreshContactsList()
        self.searchContacts()

    @objc.IBAction
    def purgeDeletedConversation_(self, sender):
        item = sender.representedObject()
        addresses = self._deletedConversationURIs(item)
        if not addresses:
            return
        # The one step with no way back, so it is the one step that asks.
        message = NSLocalizedString("Permanently delete the conversation with %s, "
                                    "including every file downloaded from it? "
                                    "This cannot be undone.", "Label") % item.name
        message = re.sub("%", "%%", message)
        ret = NSRunAlertPanel(NSLocalizedString("Delete Conversation", "Window title"),
                              message,
                              NSLocalizedString("Delete", "Button title"),
                              NSLocalizedString("Cancel", "Button title"), None)
        if ret != NSAlertDefaultReturn:
            return
        from SMSWindowManager import SMSWindowManager
        SMSWindowManager().purgeDeletedConversations(addresses)
        self.refreshContactsList()
        self.searchContacts()

    @objc.IBAction
    def renameBonjourContact_(self, sender):
        """Give a Bonjour neighbour a name of the user's choosing.

        Stored against the instance id in the local neighbours file -- the
        one thing about that machine that does not change -- so it survives
        their address changing, their going away, and a restart, and it
        follows the conversation rather than the address it was held on.

        Never the address book: a contact there would be replicated to XCAP,
        and a link-local neighbour has no server to replicate to.

        An empty name clears the rename, which is how the announced name is
        put back.
        """
        item = sender.representedObject()
        if item is None or not getattr(item, 'id', None):
            return
        from ContactListModel import (bonjour_nickname, rename_bonjour_neighbour,
                                      bonjour_display_name)
        from NicknameController import BonjourNickname
        current = bonjour_nickname(item.id) or item.name
        nickname = BonjourNickname().runModal(current)
        if nickname is None:
            return                      # cancelled, which is not the same as cleared
        if not rename_bonjour_neighbour(item.id, nickname):
            return

        item.name = bonjour_display_name(item.id, item.name) or item.id
        self.model.bonjour_group.sortContacts()
        # The conversation header reads the name through contactNameFor, so
        # this reaches an open conversation as well as the row.
        self.model.nc.post_notification("BlinkContactsHaveChanged",
                                        sender=self.model.bonjour_group)
        self.refreshContactsList()
        self.searchContacts()

    @objc.IBAction
    def deleteBonjourConversation_(self, sender):
        """Erase the messages held for one Bonjour neighbour.

        The same purge a deleted contact's orphaned addresses get, aimed at
        an instance id instead: the stored messages, the files downloaded
        from them and the public key kept for them all go, and any open
        conversation is closed first.

        A neighbour who is not on the network loses their row with it --
        history was the only reason it was there. One who is here keeps
        theirs, because discovery put it there and would put it back.
        """
        item = sender.representedObject()
        if item is None or not getattr(item, 'id', None):
            return
        offline = item.bonjour_neighbour is None
        message = (NSLocalizedString("Permanently delete the conversation with %s, "
                                     "including every file downloaded from it? "
                                     "This cannot be undone.", "Label") if offline
                   else NSLocalizedString("Permanently delete the messages with %s, "
                                          "including every file downloaded from them? "
                                          "This cannot be undone.", "Label")) % item.name
        message = re.sub("%", "%%", message)
        ret = NSRunAlertPanel(NSLocalizedString("Delete Conversation", "Window title"),
                              message,
                              NSLocalizedString("Delete", "Button title"),
                              NSLocalizedString("Cancel", "Button title"), None)
        if ret != NSAlertDefaultReturn:
            return

        from SMSWindowManager import SMSWindowManager
        SMSWindowManager().purgeConversationsForURIs([item.id],
                                                     reason='bonjour conversation deleted')
        self.model.bonjour_group.conversation_keys.pop(item.id, None)
        if offline:
            try:
                self.model.bonjour_group.contacts.remove(item)
            except ValueError:
                pass
            else:
                item.destroy()
            self.model.nc.post_notification("BlinkContactsHaveChanged",
                                            sender=self.model.bonjour_group)
        self.refreshContactsList()
        self.searchContacts()

    @objc.IBAction
    def renameGroup_(self, sender):
        group = sender.representedObject()
        self.model.editGroup(group)
        self.refreshContactsList()
        self.searchContacts()

    @objc.IBAction
    def moveGroupToIndex_(self, sender):
        group = sender.representedObject()['group']
        index = sender.representedObject()['index']
        try:
            from_index = self.model.groupsList.index(group)
        except IndexError:
            return
        else:
            del self.model.groupsList[from_index]
            try:
                self.model.groupsList.insert(index, group)
            except IndexError:
                return
            else:
                self.model.saveGroupPosition()

    @objc.IBAction
    def silentClicked_(self, sender):
        self.backend.silent(not self.backend.is_silent())

    @objc.IBAction
    def dndClicked_(self, sender):
        account = AccountManager().default_account
        if account is not BonjourAccount and account is not None:
            account.audio.do_not_disturb = not account.audio.do_not_disturb
            account.save()

            settings = SIPSimpleSettings()
            status = settings.presence_state.status
            self.setStatusBarIcon(status)

        self.refreshAccountList()

    @objc.IBAction
    def checkForUpdates_(self, sender):
        NSApp.delegate().updater.sp.checkForUpdates_(None)

    @objc.IBAction
    def muteClicked_(self, sender):
        if sender != self.muteButton:
            if self.backend.is_muted():
                self.muteButton.setState_(NSOffState)
            else:
                self.muteButton.setState_(NSOnState)
        if self.muteButton.state() == NSOnState:
            self.backend.mute(True)
            self.muteButton.setImage_(NSImage.imageNamed_("muted"))
        else:
            self.backend.mute(False)
            self.muteButton.setImage_(NSImage.imageNamed_("mute"))

    @objc.IBAction
    def toggleAnsweringMachine_(self, sender):
        settings = SIPSimpleSettings()
        settings.answering_machine.enabled = not settings.answering_machine.enabled
        settings.save()

    @objc.IBAction
    def toggleAutoAccept_(self, sender):
        settings = SIPSimpleSettings()
        if sender.tag() == 51:    # Chat
            settings.chat.auto_accept = not settings.chat.auto_accept
            settings.save()
        elif sender.tag() == 52:  # Files
            settings.file_transfer.auto_accept = not settings.file_transfer.auto_accept
            settings.save()
        elif sender.tag() == 53:  # Bonjour Audio
            account = BonjourAccount()
            account.audio.auto_accept = not account.audio.auto_accept
            account.save()

    @objc.IBAction
    def searchContacts_(self, sender):
        if sender == self.searchBox:
            text = str(self.searchBox.stringValue()).strip()
            event = NSApp.currentEvent()

            if self.mainTabView.selectedTabViewItem().identifier() == "dialpad":
                self.addContactButtonDialPad.setEnabled_(True if text != "" else False)

                new_value = ""
                for l in str(self.searchBox.stringValue().strip()):
                    new_value = new_value + translate_alpha2digit(l)
                else:
                    self.searchBox.setStringValue_(new_value)
                    # if event.type() == NSKeyUp:
                    #     key = translate_alpha2digit(str(event.characters()))
                    #     if key in string.digits:
                    #         self.play_dtmf(key)

            if text != "" and event.type() == NSKeyDown and event.keyCode() in (36, 76):
                try:
                    text = str(text)
                except:
                    NSRunAlertPanel(NSLocalizedString("Invalid Address", "Alert panel"), NSLocalizedString("The address you typed is invalid, only ASCII characters are allowed.", "Label"), NSLocalizedString("OK", "Button title"), None, None)
                    return
                else:
                    _split = text.split(';')
                    _text = []
                    for item in _split[:]:
                        if not item.startswith("session-type"):
                            _text.append(item)
                            _split.remove(item)
                    text = ";".join(_text)
                    try:
                        media_type = _split[0].split("=")[1]
                    except IndexError:
                        media_type = None

                    # 7.3.0 hotfix: Enter calls again (startSessionWithTarget below), as before 7.3.0
                    if False and media_type is None and self.mainTabView.selectedTabViewItem().identifier() != "dialpad":
                        # Enter opens a conversation in the message pane, as Blink Qt does (calls start
                        # from their own buttons): the selected result, else the first one, else the
                        # typed address. An explicit ;session-type= still starts that session
                        uri = self._searchEnterTarget() or text
                        self.resetWidgets()
                        self.openMessagesForURI(uri)
                        return

                    self.resetWidgets()
                    self.startSessionWithTarget(text, media_type=media_type or 'audio')

            self.searchContacts()

    @objc.python_method
    def _searchEnterTarget(self):
        """The address of the selected search result, else of the first one, or None."""
        try:
            row = self.searchOutline.selectedRow() if self.searchOutline.numberOfSelectedRows() == 1 else -1
            item = self.searchOutline.itemAtRow_(row) if row >= 0 else None
            if item is None:
                results = self.searchResultsModel.groupsList or []
                item = results[0] if results else None
            uri = getattr(item, 'uri', None) if item is not None else None
            return str(uri) if uri else None
        except Exception as e:
            BlinkLogger().log_debug('No search result for Enter: %s' % e)
            return None

    @objc.IBAction
    def backupContacts_(self, sender):
        self.model.backup_contacts()

    @objc.IBAction
    def accountSelectionChanged_(self, sender):
        account = sender.selectedItem().representedObject()
        if account:
            name = format_identity_to_string(account, format='compact')
            self.updateNameLabel(name)
            AccountManager().default_account = account

            if account is BonjourAccount():
                # All other groups stay visible, the Bonjour group is simply
                # promoted to the top of the list.
                self.model.moveBonjourGroupFirst()
                self.contactOutline.reloadData()
                self.contactOutline.selectRowIndexes_byExtendingSelection_(NSIndexSet.indexSetWithIndex_(0), False)
                self.contactOutline.scrollRowToVisible_(0)
            else:
                # Leaving the Bonjour account demotes the Bonjour group to the
                # end of the list, it is not restored to its former position.
                self.model.moveBonjourGroupLast()
                self.refreshContactsList()
                if self.model.bonjour_group.group is not None and not self.model.bonjour_group.group.expanded:
                    self.contactOutline.collapseItem_(self.model.bonjour_group)
        else:
            # select back the account and open the new account wizard
            i = sender.indexOfItemWithRepresentedObject_(AccountManager().default_account)
            sender.selectItemAtIndex_(i)
            enroll = EnrollmentController.alloc().init()
            enroll.setupForAdditionalAccounts()
            enroll.runModal()
            self.refreshAccountList()

    @objc.IBAction
    def backToContacts_(self, sender):
        self.mainTabView.selectTabViewItemWithIdentifier_("contacts")
        self.resetWidgets()

    @objc.IBAction
    def clearSearchField_(self, sender):
        self.resetWidgets()

    @objc.IBAction
    def addGroup_(self, sender):
        self.model.addGroup()
        self.refreshContactsList()
        self.searchContacts()

    @objc.IBAction
    def joinConferenceClicked_(self, sender):
        account = self.activeAccount()
        if account is None:
            return
        conference = self.showJoinConferenceWindow(default_domain=account.id.domain)
        if conference is not None:
            self.startConferenceIfAppropriate(conference, play_initial_announcement=True)
        self.joinConferenceWindow.release()
        self.joinConferenceWindow = None

    @objc.IBAction
    def addContactToConference_(self, sender):
        active_sessions = [s for s in self.sessionControllersManager.sessionControllers if s.hasStreamOfType("audio") and s.streamHandlerOfType("audio").canConference]

        if self.mainTabView.selectedTabViewItem().identifier() == "dialpad":
            target = str(self.searchBox.stringValue()).strip()
            if not target:
                return
        else:
            try:
                contact = self.getSelectedContacts()[0]
            except IndexError:
                target = str(self.searchBox.stringValue()).strip()
                if not target:
                    return
            else:
                target = contact.uri

        self.resetWidgets()

        if self.isJoinConferenceWindowOpen():
            self.joinConferenceWindow.addParticipant(target)
        elif self.isAddParticipantsWindowOpen():
            self.addParticipantsWindow.addParticipant(target)
        elif active_sessions:
            # start conference with active audio calls
            for s in active_sessions:
                handler = s.streamHandlerOfType("audio")
                handler.view.setConferencing_(True)

            session = self.startSessionWithTarget(target, media_type="audio")
            handler = session.streamHandlerOfType("audio")
            handler.view.setConferencing_(True)
            handler.addToConference()
            for s in active_sessions:
                handler = s.streamHandlerOfType("audio")
                handler.addToConference()

    @objc.IBAction
    def sendMessageToSelectedUri_(self, sender):
        account = self.activeAccount()
        if not account:
            NSRunAlertPanel(NSLocalizedString("Cannot Send Message", "Window title"), NSLocalizedString("There are currently no active accounts", "Label"), NSLocalizedString("OK", "Button title"), None, None)
            return

        contact = None
        instance_id = None
        try:
            contact = self.getSelectedContacts()[0]
        except IndexError:
            target = str(self.searchBox.stringValue()).strip()
            if not target:
                return
            display_name = ''
        else:
            target = contact.uri
            display_name = contact.name
            if contact not in self.model.bonjour_group.contacts:
                target = SMSWindowManager.SMSWindowManager().messageURIForContact(contact) or contact.uri
            if contact in self.model.bonjour_group.contacts:
                account = BonjourAccount()
                # Carried through, which it was not before: a Bonjour
                # conversation is addressed by instance id, and one opened
                # without it cannot be routed or matched to the neighbour it
                # belongs to.
                instance_id = contact.id

        if instance_id is None:
            account = self.messagingAccountFor(account, contact)
            if account is None:
                self.reportNoMessagingAccount(display_name or target)
                return

        target = normalize_sip_uri_for_outgoing_session(target, account)
        if not target:
            return

        try:
            NSApp.activateIgnoringOtherApps_(True)
            SMSWindowManager.SMSWindowManager().getWindow(target, display_name, account, focusTab=True, instance_id=instance_id, selected_contact=contact)
        except Exception:
            pass

    @objc.IBAction
    def startScreenSharing_(self, sender):
        uri = sender.representedObject()
        tag = sender.tag()
        if tag == 1:
            self.startSessionToSelectedContact(("screen-sharing-client", "audio"), uri)
        elif tag == 2:
            self.startSessionToSelectedContact(("screen-sharing-server", "audio"), uri)

    @objc.IBAction
    def setSubscribeToPresence_(self, sender):
        item = sender.representedObject()
        item.contact.presence.subscribe = not item.contact.presence.subscribe
        item.contact.save()

    @objc.IBAction
    def setPresencePolicy_(self, sender):
        item = sender.representedObject()
        item.contact.presence.policy = 'allow' if item.contact.presence.policy in ('default', 'block') else 'block'
        item.contact.save()

    @objc.IBAction
    def setDialogPolicy_(self, sender):
        item = sender.representedObject()
        item.contact.dialog.policy = 'allow' if item.contact.dialog.policy in ('default', 'block') else 'block'
        item.contact.save()

    @objc.IBAction
    def userClickedRemoveVideo_(self, sender):
        session = self.getSelectedAudioSession()
        if session:
            session.removeVideoFromSession()

    @objc.IBAction
    def userClickedDetachVideo_(self, sender):
        session = self.getSelectedAudioSession()
        if session:
            session.setVideoConsumer("standalone")

    @objc.IBAction
    def groupButtonClicked_(self, sender):
        # IM button
        point = self.window().convertScreenToBase_(NSEvent.mouseLocation())
        event = NSEvent.mouseEventWithType_location_modifierFlags_timestamp_windowNumber_context_eventNumber_clickCount_pressure_(
                                                                                                                                  NSLeftMouseUp, point, 0, NSDate.timeIntervalSinceReferenceDate(), sender.window().windowNumber(),
                                                                                                                                  sender.window().graphicsContext(), 0, 1, 0)
        NSMenu.popUpContextMenu_withEvent_forView_(self.groupMenu, event, sender)
        return

    @objc.IBAction
    def actionButtonClicked_(self, sender):
        if self.mainTabView.selectedTabViewItem().identifier() == "dialpad":
            target = str(self.searchBox.stringValue()).strip()
            if not target:
                return

            self.startSessionWithTarget(target)
            self.searchBox.setStringValue_("")
            self.addContactToConferenceDialPad.setEnabled_(False)
        else:
            if self.sessionControllersManager.isMediaTypeSupported('video'):
                video_segment = 1
                chat_segment = 2
                screen_segment = 3
            else:
                video_segment = 10000000 # not available
                chat_segment = 1
                screen_segment = 2

            media_type = "audio"
            try:
                contact = self.getSelectedContacts()[0]
            except IndexError:
                return

            if sender == self.contactOutline or sender == self.searchOutline:
                # Double-click / Return opens the contact's conversation in the
                # message pane, for every contact, Bonjour neighbours included:
                # there is no per-contact preferred media any more (as on Blink
                # Qt). Calling is a click away in the menu and the call buttons.
                # A deleted or blocked contact is not written to.
                if self.isRowInDeletedGroup(contact) or self.blockedContactsForItems([contact]):
                    return
                media_type = "sms"
            elif sender.selectedSegment() == video_segment:
                media_type = ("audio", "video")
            elif sender.selectedSegment() == chat_segment:
                # IM button
                if self.sessionControllersManager.isPeerToPeerChatEnabled() and self.sessionControllersManager.isMediaTypeSupported('sms'):
                    point = self.window().convertScreenToBase_(NSEvent.mouseLocation())
                    event = NSEvent.mouseEventWithType_location_modifierFlags_timestamp_windowNumber_context_eventNumber_clickCount_pressure_(
                                    NSLeftMouseUp, point, 0, NSDate.timeIntervalSinceReferenceDate(), sender.window().windowNumber(),
                                    sender.window().graphicsContext(), 0, 1, 0)
                    NSMenu.popUpContextMenu_withEvent_forView_(self.chatMenu, event, sender)
                    return
                elif self.sessionControllersManager.isPeerToPeerChatEnabled():
                    self.startSessionToSelectedContact("chat")
                elif self.sessionControllersManager.isMediaTypeSupported('sms'):
                    no_contact_selected = self.contactOutline.selectedRow() == -1 and self.searchOutline.selectedRow() == -1
                    if not no_contact_selected:
                        self.sendMessageToURI()
                return

            elif sender.selectedSegment() == screen_segment:
                # DS button
                point = self.window().convertScreenToBase_(NSEvent.mouseLocation())
                event = NSEvent.mouseEventWithType_location_modifierFlags_timestamp_windowNumber_context_eventNumber_clickCount_pressure_(
                                NSLeftMouseUp, point, 0, NSDate.timeIntervalSinceReferenceDate(), sender.window().windowNumber(),
                                sender.window().graphicsContext(), 0, 1, 0)
                NSMenu.popUpContextMenu_withEvent_forView_(self.screenShareMenu, event, sender)
                return

            self.startSessionToSelectedContact(media_type)

    @objc.IBAction
    def sessionButtonClicked_(self, sender):
        sessionController = self.sessionListModel.sessions[sender.selectedRow()]
        cell = sender.preparedCellAtColumn_row_(1, sender.selectedRow())
        if cell.selectedSegment() == 0:
            sessionController.toggleHold()
        else:
            sessionController.end()

    @objc.IBAction
    def hangupAllClicked_(self, sender):
        for session in self.sessionControllersManager.sessionControllers:
            if session.hasStreamOfType("audio"):
                if len(session.streamHandlers) == 1:
                    session.end()
                elif session.hasStreamOfType("screen-sharing") and len(session.streamHandlers) == 2:
                    session.end()
                else:
                    stream = session.streamHandlerOfType("audio")
                    stream.end()

    @objc.IBAction
    def conferenceClicked_(self, sender):
        count = sum(s and 1 or 0 for s in self.sessionControllersManager.sessionControllers if s.hasStreamOfType("audio") and s.streamHandlerOfType("audio").canConference)

        if self.conferenceButton.state() == NSOnState:
            if count < 2:
                self.conferenceButton.setState_(NSOffState)
                return
            # if conference already started:
            #    return

            if NSRunAlertPanel(NSLocalizedString("Audio Conference", "Window title"),
                               NSLocalizedString("Would you like to start a conference with the %i active sessions? Once started you may use "
                                                 "drag and drop to add and remove contacts to and from the conference. ", "Label") % count,
                               NSLocalizedString("OK", "Button title"), NSLocalizedString("Cancel", "Button title"), "") != NSAlertDefaultReturn:
                self.conferenceButton.setState_(NSOffState)
                return

            conference_streams = []
            for session in self.sessionControllersManager.sessionControllers:
                if session.hasStreamOfType("audio"):
                    stream = session.streamHandlerOfType("audio")
                    if stream.canConference:
                        stream.view.setConferencing_(True)
                        conference_streams.append(stream)

            for stream in conference_streams:
                stream.addToConference()
        else:
            # if not conference already started:
            #   return
            self.disbandConference()

    @objc.IBAction
    def showHistoryViewer_(self, sender):
        if NSApp.delegate().history_enabled:
            if not self.historyViewer:
                self.historyViewer = HistoryViewer()
                self.historyViewer.refreshViewer()
            self.historyViewer.showWindow_(None)

    @objc.IBAction
    def showFileTransfers_(self, sender):
        self.initFileTransfersWindow()
        self.fileTransfersWindow.showWindow_(None)

    @objc.IBAction
    def deletePresenceHistory_(self, sender):
        self.setLastPresenceActivity()
        self.presence_notes_history.clear()
        storage_path = ApplicationData.get('presence/presence_notes_history.pickle')
        try:
            pickle.dump(self.presence_notes_history, open(storage_path, "wb+"))
        except (pickle.PickleError, IOError):
            pass

    @objc.IBAction
    def setPresenceOfflineNote_(self, sender):
        self.setLastPresenceActivity()
        NSApp.activateIgnoringOtherApps_(True)
        controller = OfflineNoteController()
        controller.runModal()

    @objc.IBAction
    def setPresenceActivityFromHistory_(self, sender):
        object = sender.representedObject()

        settings = SIPSimpleSettings()

        settings.presence_state.timestamp = ISOTimestamp.now()

        status = object['title']
        self.changeMyPresenceFromStatus(status)
        settings.presence_state.status = status

        note = object['note']
        if note.lower() == on_the_phone_activity['note'].lower():
            self.presenceNoteText.setStringValue_(on_the_phone_activity['localized_note'])
        else:
            self.presenceNoteText.setStringValue_(note)
        settings.presence_state.note = note

        settings.save()

        self.savePresenceActivityToHistory(object)

    @objc.IBAction
    def presenceNoteChanged_(self, sender):
        settings = SIPSimpleSettings()
        presence_note = str(self.presenceNoteText.stringValue())

        if presence_note == on_the_phone_activity['localized_note']:
            presence_note = on_the_phone_activity['note']

        if settings.presence_state.note != presence_note:
            settings.presence_state.note = presence_note
            settings.presence_state.timestamp = ISOTimestamp.now()
            settings.save()

            if presence_note:
                item = self.presenceActivityPopUp.selectedItem()
                if item is None:
                    return

                selected_presence_activity = item.representedObject()
                if selected_presence_activity is None:
                    return

                object = dict(selected_presence_activity)
                object['note'] = presence_note
                self.savePresenceActivityToHistory(object)

    @objc.IBAction
    def presenceActivityChanged_(self, sender):
        settings = SIPSimpleSettings()
        settings.presence_state.timestamp = ISOTimestamp.now()

        object = sender.representedObject()
        status = object['title']
        self.changeMyPresenceFromStatus(status)

        presence_note = None

        if settings.presence_state.status == status:
            # if is the same status, delete existing note
            presence_note = ''

        if status == 'Invisible':
            presence_note = ''

        if settings.presence_state.status != status:
            settings.presence_state.status = status

        if presence_note is not None and settings.presence_state.note != presence_note:
            if presence_note:
                if presence_note.lower() == on_the_phone_activity['note'].lower():
                    self.presenceNoteText.setStringValue_(on_the_phone_activity['localized_note'])
                else:
                    self.presenceNoteText.setStringValue_(presence_note or '')
            else:
                self.presenceNoteText.setStringValue_('')
            settings.presence_state.note = presence_note

        settings.save()

        if presence_note:
            object['note'] = presence_note
            self.savePresenceActivityToHistory(object)

    @objc.IBAction
    def showHelp_(self, sender):
        self.showHelp()

    @objc.IBAction
    def dialPadButtonClicked_(self, sender):
        self.playSilence()

        if sender:
            tag = sender.tag()
            if tag == 10:
                key = '*'
            elif tag == 11:
                key = '#'
            else:
                key = str(tag)

            if key in string.digits+'#*':
                first_responder = self.window().firstResponder()

                if isinstance(first_responder, AudioSession) and first_responder.delegate is not None:
                    first_responder.delegate.send_dtmf(key)
                else:
                    self.searchBox.setStringValue_(str(self.searchBox.stringValue())+str(key))
                    search_box_editor = self.window().fieldEditor_forObject_(True, self.searchBox)
                    search_box_editor.setSelectedRange_(NSMakeRange(len(self.searchBox.stringValue()), 0))
                    search_box_editor.setNeedsDisplay_(True)

                    self.addContactButtonDialPad.setEnabled_(True)
                    self.play_dtmf(key)

                    if key == '#':
                        target = str(self.searchBox.stringValue()).strip()[:-1]
                        if not target:
                            return

                        self.startSessionWithTarget(target)
                        self.resetWidgets()

                    self.updateStartSessionButtons()

    @objc.IBAction
    def showAccountSettings_(self, sender):
        account = self.activeAccount()
        if account not in self.accountSettingsPanels:
            self.accountSettingsPanels[account] = AccountSettings.createWithOwner_(self)
        self.accountSettingsPanels[account].showSettingsForAccount_(account)

    @objc.IBAction
    def showPSTNAccess_(self, sender):
        account = self.activeAccount()
        if account not in self.accountSettingsPanels:
            self.accountSettingsPanels[account] = AccountSettings.createWithOwner_(self)
        self.accountSettingsPanels[account].showPSTNAccessforAccount_(account)

    @objc.IBAction
    def close_(self, sender):
        self.window().close()

    def menuWillOpen_(self, menu):
        def setupAudioDeviceMenu(menu, tag, devices, option_name, selector):
            settings = SIPSimpleSettings()

            for i in range(100):
                old = menu.itemWithTag_(tag*100+i)
                if old:
                    menu.removeItem_(old)
                else:
                    break

            value = getattr(settings.audio, option_name)

            # With the lid closed the built-in mic is listed but records
            # silence. An item without an action is disabled by the menu's
            # auto-enabling, which is how it is greyed out here.
            monitor = ClamshellMonitor()
            dead_inputs = monitor.builtin_inputs() if tag == 402 and monitor.builtin_microphone_disabled else set()
            unavailable = NSLocalizedString("unavailable, lid is closed", "Menu item")

            index = menu.indexOfItem_(menu.itemWithTag_(tag))+1

            item = menu.insertItemWithTitle_action_keyEquivalent_atIndex_(NSLocalizedString("None", "Menu item"), selector, "", index)
            item.setRepresentedObject_(None)
            item.setTarget_(self)
            item.setTag_(tag*100)
            item.setIndentationLevel_(2)
            item.setState_(NSOnState if value in (None, "None") else NSOffState)
            index += 1

            default_device = self.backend._app.engine.default_output_device if tag in (401, 403) else self.backend._app.engine.default_input_device

            default_dead = default_device is not None and default_device.strip() in dead_inputs
            title = NSLocalizedString("System Default (%s)", "Menu item") % (default_device or '').strip()
            if default_dead:
                title = "%s \u2014 %s" % (title, unavailable)
            item = menu.insertItemWithTitle_action_keyEquivalent_atIndex_(title, None if default_dead else selector, "", index)
            item.setRepresentedObject_("system_default")
            item.setTarget_(self)
            item.setTag_(tag*100+1)
            item.setIndentationLevel_(2)
            item.setState_(NSOnState if value in ("default", "system_default") else NSOffState)
            index += 1

            i = 2
            for dev in devices:
                dev_title = dev.strip()
                dead = dev_title in dead_inputs
                if dead:
                    dev_title = "%s \u2014 %s" % (dev_title, unavailable)
                item = menu.insertItemWithTitle_action_keyEquivalent_atIndex_(dev_title, None if dead else selector, "", index)
                item.setRepresentedObject_(dev)
                item.setTarget_(self)
                item.setTag_(tag * 100 + i)
                item.setIndentationLevel_(2)
                i += 1
                item.setState_(NSOnState if value == dev else NSOffState)
                index += 1

        def setupVideoDeviceMenu(menu, tag, devices, option_name, selector):
            self.backend._app.engine.refresh_video_devices()
            settings = SIPSimpleSettings()

            for i in range(100):
                old = menu.itemWithTag_(tag*100+i)
                if old:
                    menu.removeItem_(old)
                else:
                    break

            value = getattr(settings.video, option_name)

            index = menu.indexOfItem_(menu.itemWithTag_(tag))+1

            item = menu.insertItemWithTitle_action_keyEquivalent_atIndex_(NSLocalizedString("None", "Menu item"), selector, "", index)
            item.setRepresentedObject_(None)
            item.setTarget_(self)
            item.setTag_(tag*100)
            item.setIndentationLevel_(2)
            item.setState_(NSOnState if value in (None, "None") else NSOffState)
            index += 1
            
            if not self.sessionControllersManager.isMediaTypeSupported('video'):
                return

            if value == 'system_default':
                value = self.backend._app.video_device.real_name

            if not self.backend._app.engine.video_devices:
                BlinkLogger().log_info("No video cameras available or the camera software is stuck. Try this command in Terminal application: sudo killall VDCAssistant")
                return

            i = 1
            for dev in devices:
                if 'null' in dev.lower():
                    continue
                item = menu.insertItemWithTitle_action_keyEquivalent_atIndex_(dev, selector, "", index)
                item.setRepresentedObject_(dev)
                item.setTarget_(self)
                item.setTag_(tag * 100 + i)
                item.setIndentationLevel_(2)
                i += 1
                item.setState_(NSOnState if value == dev else NSOffState)
                index += 1

        def setupAudioInputOutputDeviceMenu(menu, tag, devices, selector):
            settings = SIPSimpleSettings()
            for i in range(100):
                old = menu.itemWithTag_(tag*100+i)
                if old:
                    menu.removeItem_(old)
                else:
                    break

            # Always shown: System Default is the way back to following the
            # Mac's own input and output after picking a device.
            menu.itemWithTag_(404).setHidden_(False)
            menu.itemWithTag_(405).setHidden_(False)
            index = menu.indexOfItem_(menu.itemWithTag_(tag))+1
            monitor = ClamshellMonitor()
            dead_inputs = monitor.builtin_inputs() if monitor.builtin_microphone_disabled else set()
            if dead_inputs:
                dead_inputs.add(NSLocalizedString("Built-in Microphone and Output", "Label"))

            engine = self.backend._app.engine
            default_in = (engine.default_input_device or '').strip()
            default_out = (engine.default_output_device or '').strip()
            default_dead = default_in in dead_inputs
            title = NSLocalizedString("System Default (%s / %s)", "Menu item") % (default_in, default_out)
            if default_dead:
                title = "%s \u2014 %s" % (title, NSLocalizedString("microphone unavailable, lid is closed", "Menu item"))
            item = menu.insertItemWithTitle_action_keyEquivalent_atIndex_(title, None if default_dead else selector, "", index)
            item.setState_(NSOnState if settings.audio.input_device in ("default", "system_default") and settings.audio.output_device in ("default", "system_default") else NSOffState)
            item.setRepresentedObject_("system_default")
            item.setTarget_(self)
            item.setTag_(tag*100)
            item.setIndentationLevel_(2)
            index += 1

            if devices:
                i = 1
                for dev in devices:
                    dead = dev.strip() in dead_inputs
                    item = menu.insertItemWithTitle_action_keyEquivalent_atIndex_(dev.strip(), None if dead else selector, "", index)
                    if settings.audio.input_device == dev and settings.audio.output_device == dev:
                        state = NSOnState
                    elif dev == NSLocalizedString("Built-in Microphone and Output", "Label") and settings.audio.input_device == NSLocalizedString("Built-in Microphone", "Label") and settings.audio.output_device == NSLocalizedString("Built-in Output", "Label"):
                        state = NSOnState
                    else:
                        state = NSOffState
                    item.setState_(state)
                    item.setRepresentedObject_(dev)
                    item.setTarget_(self)
                    item.setTag_(tag*100+i)
                    item.setIndentationLevel_(2)
                    i += 1
                    index += 1

        if menu == self.devicesMenu:
            self.refreshDevices_(None)
            
            in_out_devices = list(set(self.backend._app.engine.input_devices) & set(self.backend._app.engine.output_devices))
            if NSLocalizedString("Built-in Microphone", "Label") in self.backend._app.engine.input_devices and NSLocalizedString("Built-in Output", "Label") in self.backend._app.engine.output_devices:
                in_out_devices.append(NSLocalizedString("Built-in Microphone and Output", "Label"))
            setupAudioInputOutputDeviceMenu(menu, 404, in_out_devices, "selectInputOutputDevice:")
            setupAudioDeviceMenu(menu, 402, self.backend._app.engine.input_devices,  "input_device",  "selectInputDevice:")
            setupAudioDeviceMenu(menu, 401, self.backend._app.engine.output_devices, "output_device", "selectOutputDevice:")
            setupAudioDeviceMenu(menu, 403, self.backend._app.engine.output_devices, "alert_device",  "selectAlertDevice:")
            if not self.sessionControllersManager.isMediaTypeSupported('video'):
                old = menu.itemWithTag_(500)
                if old:
                    menu.removeItem_(old)
                old = menu.itemWithTag_(600)
                if old:
                    menu.removeItem_(old)
            else:
                if hasattr(self.backend._app.engine, "video_devices"):
                    setupVideoDeviceMenu(menu, 500, NSApp.delegate().video_devices, "device",  "selectVideoDevice:")

        elif menu == self.contactsMenu:
            self.updateKeyEscrowMenuItem()
        elif menu == self.blinkMenu:
            self.updateBlinkMenu()
        elif menu == self.historyMenu:
            self.updateHistoryMenu()
        elif menu == self.recordingsMenu:
            self.updateRecordingsMenu()
        elif menu == self.contactContextMenu:
            self.updateContactContextMenu()
            self.logContactContextMenu(menu)
        elif menu == self.presenceWatchersMenu:
            self.updatePresenceWatchersMenu(menu)
        elif menu == self.presenceMenu:
            self.updatePresenceActivityMenu(menu)
        elif menu == self.presencePopUpMenu:
            self.updatePresenceActivityMenu(menu)
        elif menu == self.statusBarMenu:
            self.window().orderFront_(None)
            settings = SIPSimpleSettings()
            item = menu.itemWithTag_(50)  # answering machine
            item.setState_(settings.answering_machine.enabled and NSOnState or NSOffState)

            item = menu.itemWithTag_(300)  # mute
            item.setState_(NSOnState if self.backend.is_muted() else NSOffState)

            item = menu.itemWithTag_(301)  # silent
            settings = SIPSimpleSettings()
            item.setState_(NSOnState if settings.audio.silent else NSOffState)

            item = menu.itemWithTag_(302)  # dnd
            account = AccountManager().default_account
            item.setState_(NSOnState if account is not None and account.audio.do_not_disturb else NSOffState)
            item.setEnabled_(True)
            self.updateHistoryMenu()

            item = menu.itemWithTag_(25)  # redial
            if self.sessionControllersManager.redial_uri is not None:
                item.setEnabled_(True)
                from ContactMangler import mangled_text
                item.setTitle_(NSLocalizedString("Redial %s", "Status bar menu item") % mangled_text(str(self.sessionControllersManager.redial_uri)))
            else:
                item.setTitle_(NSLocalizedString("Redial", "Status bar menu item"))
                item.setEnabled_(False)

        elif menu == self.callMenu:
            self.updateCallMenu()
        elif menu == self.groupMenu:
            self.updateGroupMenu()
        elif menu == self.chatMenu:
            self.updateChatMenu()
        elif menu == self.windowMenu:
            self.updateWindowMenu()
        elif menu == self.restoreContactsMenu:
            self.updateRestoreContactsMenu()
        elif menu == self.screenShareMenu:
            try:
                contact = self.getSelectedContacts()[0]
            except IndexError:
                pass
            else:
                # TODO: use presence for this ?
                aor_supports_screen_sharing_client = is_sip_aor_format(contact.uri)
                aor_supports_screen_sharing_server = is_sip_aor_format(contact.uri)
                if isinstance(contact, BlinkPresenceContact):
                    settings = SIPSimpleSettings()
                    if settings.gui.media_support_detection:
                        aor_supports_screen_sharing_server = self.contactSupportsMedia('screen-sharing-server', contact, contact.uri)
                        aor_supports_screen_sharing_client = self.contactSupportsMedia('screen-sharing-client', contact, contact.uri)
                elif isinstance(contact, BonjourBlinkContact):
                    aor_supports_screen_sharing_client = True
                    aor_supports_screen_sharing_server = True

                item = self.screenShareMenu.itemWithTag_(1)
                from ContactMangler import mangled_name
                item.setTitle_(NSLocalizedString("Request Screen Sharing from %s", "Menu item") % mangled_name(contact.name, uri=getattr(contact, "uri", None)))
                item.setEnabled_(self.sessionControllersManager.isMediaTypeSupported('screen-sharing-client') and aor_supports_screen_sharing_client)

                item = self.screenShareMenu.itemWithTag_(2)
                from ContactMangler import mangled_name
                item.setTitle_(NSLocalizedString("Share My Screen with %s", "Menu item") % mangled_name(contact.name, uri=getattr(contact, "uri", None)))
                item.setEnabled_(self.sessionControllersManager.isMediaTypeSupported('screen-sharing-server') and aor_supports_screen_sharing_server)

        elif menu == self.contactsMenu:
            row = self.contactOutline.selectedRow()
            selected_contact = None
            selected_group = None
            selected = self.contactOutline.itemAtRow_(row) if row >= 0 else None
            has_presence_info = False
            if selected:
                if isinstance(selected, BlinkContact):
                    selected_contact = selected
                    selected_group = self.contactOutline.parentForItem_(selected)
                    has_presence_info = isinstance(selected, BlinkPresenceContact) and selected.pidfs_map
                elif isinstance(selected, BlinkGroup):
                    selected_contact = None
                    selected_group = selected

            item = self.contactsMenu.itemWithTag_(31)  # Edit Contact
            item.setEnabled_(selected_contact and selected_contact.editable)
            item.setRepresentedObject_(selected_contact)
            item = self.contactsMenu.itemWithTag_(32)  # Delete Contact
            item.setEnabled_(selected_contact and selected_contact.deletable)
            item = self.contactsMenu.itemWithTag_(50)  # Presence Info
            item.setEnabled_(True)
            item.setRepresentedObject_(selected if has_presence_info else None)
            item.setEnabled_(bool(has_presence_info))
            item = self.contactsMenu.itemWithTag_(51)  # Pending Requests
            item.setEnabled_(bool(self.model.pending_watchers_group.contacts))
            item = self.contactsMenu.itemWithTag_(68)  # Lookup Public Key
            if item is not None:
                item.setEnabled_(self.canLookupPublicKeyForSelection())
            item = self.contactsMenu.itemWithTag_(33)  # Add Group
            item.setEnabled_(True)
            item = self.contactsMenu.itemWithTag_(34)  # Edit Group
            item.setEnabled_(selected_group)
            item.setRepresentedObject_(selected_group)
            item = self.contactsMenu.itemWithTag_(35)  # Delete Group
            item.setEnabled_(selected_group and selected_group.deletable)
            item = self.contactsMenu.itemWithTag_(36)  # Expand Group
            item.setEnabled_(selected_group)
            if selected_group:
                item.setTitle_(NSLocalizedString("Expand Group", "Contacts menu item") if not selected_group.group.expanded else NSLocalizedString("Collapse Group", "Contacts menu item"))
            else:
                item.setTitle_(NSLocalizedString("Toggle Group Expansion", "Contacts menu item"))

            item = self.contactsMenu.itemWithTag_(42)  # Dialpad
            item.setEnabled_(True)
            item.setTitle_(NSLocalizedString("Show Dialpad", "Contacts menu item") if self.mainTabView.selectedTabViewItem().identifier() != "dialpad" else NSLocalizedString("Hide Dialpad", "Contacts menu item"))

            item = self.contactsMenu.itemWithTag_(67)  # Export PGP private key
            account = AccountManager().default_account
            pgp_possible = bool(account) and SMSWindowManager.pgp_messaging_possible(account)
            if not account:
                item.setEnabled_(False)
            else:
                keys_path = ApplicationData.get('keys')
                private_key_path = "%s/%s.privkey" % (keys_path, account.id)
                item.setEnabled_(account is not BonjourAccount() and pgp_possible and os.path.exists(private_key_path))

            # Generate PGP private key: untagged in the nibs, found by its action.
            # Disabled while it is too early: the addressbook, which may carry
            # the account's key, has not been fetched yet.
            too_early = pgp_possible and account is not BonjourAccount() and not SMSWindowManager.SMSWindowManager().addressbookAnswered(account)
            for entry in item.menu().itemArray():
                if entry.action() in ('generateKeyButtonClicked:', b'generateKeyButtonClicked:'):
                    entry.setEnabled_(pgp_possible and not too_early)
                    entry.setToolTip_(NSLocalizedString("Waiting for the addressbook of this account, it may already carry a key", "Tooltip") if too_early else "")

    def selectInputDevice_(self, sender):
        settings = SIPSimpleSettings()
        settings.audio.input_device = sender.representedObject()
        settings.save()

    def selectOutputDevice_(self, sender):
        settings = SIPSimpleSettings()
        settings.audio.output_device = sender.representedObject()
        settings.save()

    def selectInputOutputDevice_(self, sender):
        settings = SIPSimpleSettings()
        dev = sender.representedObject()
        if dev == NSLocalizedString("Built-in Microphone and Output", "Label"):
            if NSLocalizedString("Built-in Microphone", "Label") in self.backend._app.engine.input_devices:
                settings.audio.input_device = str(NSLocalizedString("Built-in Microphone", "Label"))
            if NSLocalizedString("Built-in Output", "Label") in self.backend._app.engine.output_devices:
                settings.audio.output_device = str(NSLocalizedString("Built-in Output", "Label"))
        else:
            settings.audio.output_device = dev
            settings.audio.input_device = dev
        settings.save()

    def selectAlertDevice_(self, sender):
        settings = SIPSimpleSettings()
        settings.audio.alert_device = sender.representedObject()
        settings.save()

    def selectVideoDevice_(self, sender):
        settings = SIPSimpleSettings()
        device = sender.representedObject()
        if device:
            BlinkLogger().log_info('Switching to %s video camera' % device)
        else:
           BlinkLogger().log_info('Video sessions are disabled')

        try:
            # the screen is also captured at the screen's resolution
            from ScreenPointerController import select_video_device
        except ImportError:
            settings.video.device = device
            settings.save()
        else:
            select_video_device(device)
        self.updateStartSessionButtons()

    def participantSelectionChanged_(self, notification):
        contact = self.getSelectedParticipant()
        session = self.getSelectedAudioSession()

        if not session or contact is None:
            self.participantMenu.itemWithTag_(PARTICIPANTS_MENU_ADD_CONTACT).setEnabled_(False)
            self.participantMenu.itemWithTag_(PARTICIPANTS_MENU_REMOVE_FROM_CONFERENCE).setEnabled_(False)
            self.participantMenu.itemWithTag_(PARTICIPANTS_MENU_MUTE).setEnabled_(False)
            self.participantMenu.itemWithTag_(PARTICIPANTS_MENU_START_AUDIO_SESSION).setEnabled_(False)
            self.participantMenu.itemWithTag_(PARTICIPANTS_MENU_START_CHAT_SESSION).setEnabled_(False)
            self.participantMenu.itemWithTag_(PARTICIPANTS_MENU_START_VIDEO_SESSION).setEnabled_(False)
            self.participantMenu.itemWithTag_(PARTICIPANTS_MENU_SEND_FILES).setEnabled_(False)
        else:
            own_uri = '%s@%s' % (session.account.id.username, session.account.id.domain)
            # remote_uri = format_identity_to_string(session.remoteIdentity)

            self.participantMenu.itemWithTag_(PARTICIPANTS_MENU_ADD_CONTACT).setEnabled_(False if (self.hasContactMatchingURI(contact.uri) or contact.uri == own_uri or isinstance(session.account, BonjourAccount)) else True)
            self.participantMenu.itemWithTag_(PARTICIPANTS_MENU_REMOVE_FROM_CONFERENCE).setEnabled_(True if self.canBeRemovedFromConference(contact.uri) else False)
            self.participantMenu.itemWithTag_(PARTICIPANTS_MENU_START_AUDIO_SESSION).setEnabled_(True if contact.uri != own_uri and not isinstance(session.account, BonjourAccount) else False)
            self.participantMenu.itemWithTag_(PARTICIPANTS_MENU_START_CHAT_SESSION).setEnabled_(True if contact.uri != own_uri and not isinstance(session.account, BonjourAccount) else False)
            self.participantMenu.itemWithTag_(PARTICIPANTS_MENU_START_VIDEO_SESSION).setEnabled_(False)
            self.participantMenu.itemWithTag_(PARTICIPANTS_MENU_SEND_FILES).setEnabled_(True if contact.uri != own_uri and not isinstance(session.account, BonjourAccount) else False)

    # TableView dataSource
    def numberOfRowsInTableView_(self, tableView):
        if tableView == self.participantsTableView:
            try:
                return len(self.participants)
            except Exception:
                log_gui_exception('the audio participants row count')

        return 0

    def tableView_objectValueForTableColumn_row_(self, tableView, tableColumn, row):
        if tableView == self.participantsTableView:
            try:
                if row < len(self.participants):
                    if type(self.participants[row]) in (str, str):
                        return self.participants[row]
                    else:
                        return self.participants[row].name
            except Exception:
                log_gui_exception('the audio participants data source (row %s)' % row)
        return None

    def tableView_willDisplayCell_forTableColumn_row_(self, tableView, cell, tableColumn, row):
        if tableView == self.participantsTableView:
            try:
                if row < len(self.participants):
                    if type(self.participants[row]) in (str, str):
                        cell.setContact_(None)
                    else:
                        cell.setContact_(self.participants[row])
            except Exception:
                log_gui_exception('the audio participants cell display (row %s)' % row)

    # drag/drop
    def tableView_validateDrop_proposedRow_proposedDropOperation_(self, table, info, row, oper):
        session = self.getSelectedAudioSession()
        if session:
            if session.remote_focus:
                # do not allow drag if remote party is not conference focus
                pboard = info.draggingPasteboard()
                if pboard.availableTypeFromArray_(["x-blink-sip-uri"]):
                    uri = str(pboard.stringForType_("x-blink-sip-uri"))
                    if uri:
                        uri = sip_prefix_pattern.sub("", str(uri))
                    try:
                        table.setDropRow_dropOperation_(self.numberOfRowsInTableView_(table), NSTableViewDropAbove)

                        # do not invite remote party itself
                        remote_uri = format_identity_to_string(session.remoteIdentity)
                        if uri == remote_uri:
                            return NSDragOperationNone
                        # do not invite users already invited
                        for contact in session.invited_participants:
                            if uri == contact.uri:
                                return NSDragOperationNone
                        # do not invite users already present in the conference
                        if session.conference_info is not None:
                            for user in session.conference_info.users:
                                if uri == sip_prefix_pattern.sub("", user.entity):
                                    return NSDragOperationNone
                    except:
                        return NSDragOperationNone
                    return NSDragOperationAll
                elif pboard.types().containsObject_(NSFilenamesPboardType):
                    return NSDragOperationAll
            elif not isinstance(session.account, BonjourAccount):
                return NSDragOperationAll

        return NSDragOperationNone

    def tableView_acceptDrop_row_dropOperation_(self, table, info, row, dropOperation):
        pboard = info.draggingPasteboard()
        session = self.getSelectedAudioSession()

        if not session or not session.remote_focus:
            return False

        if not pboard.availableTypeFromArray_(["x-blink-sip-uri"]):
            return False

        uri = str(pboard.stringForType_("x-blink-sip-uri"))
        if uri:
            uri = sip_prefix_pattern.sub("", str(uri))
            if "@" not in uri:
                uri = '%s@%s' % (uri, session.account.id.domain)

        try:
            SIPURI.parse('sip:%s' % uri if not uri.startswith("sip:") else uri)
        except SIPCoreError:
            session.log_info("Error inviting to conference: invalid URI %s" % uri)
            return False

        presence_contact = self.getFirstContactFromAllContactsGroupMatchingURI(uri)
        if presence_contact:
            contact = BlinkConferenceContact(uri, name=presence_contact.name, icon=presence_contact.icon, presence_contact=presence_contact)
        else:
            contact = BlinkConferenceContact(uri=uri, name=uri)

        contact.detail = 'Invitation sent...'
        session.invited_participants.append(contact)
        session.participants_log.add(uri)
        session.log_info("Invite %s to conference" % uri)
        session.session.conference.add_participant(uri)
        return True

    @objc.IBAction
    def userClickedParticipantMenu_(self, sender):
        session = self.getSelectedAudioSession()
        if session:
            tag = sender.tag()

            row = self.participantsTableView.selectedRow()
            try:
                object = self.participants[row]
            except IndexError:
                return

            uri = object.uri
            display_name = object.name

            if tag == PARTICIPANTS_MENU_ADD_CONTACT:
                self.addContact(uris=[(uri, 'sip')], name=display_name)
            elif tag == PARTICIPANTS_MENU_ADD_CONFERENCE_CONTACT:
                remote_uri = format_identity_to_string(session.remoteIdentity)
                display_name = None
                if session.conference_info is not None:
                    conf_desc = session.conference_info.conference_description
                    display_name = str(conf_desc.display_text)
                self.addContact(uris=[(remote_uri, 'sip')], name=display_name)
            elif tag == PARTICIPANTS_MENU_REMOVE_FROM_CONFERENCE:
                message = NSLocalizedString("You will request the conference server to remove %s from the room. Are your sure?", "Label") % display_name
                message = re.sub("%", "%%", message)
                ret = NSRunAlertPanel(NSLocalizedString("Remove from conference", "Window title"), message, NSLocalizedString("Remove", "Button title"), NSLocalizedString("Cancel", "Button title"), None)
                if ret == NSAlertDefaultReturn:
                    self.removeParticipant(uri)
            elif tag == PARTICIPANTS_MENU_INVITE_TO_CONFERENCE:
                self.addParticipants()
            elif tag == PARTICIPANTS_MENU_GOTO_CONFERENCE_WEBSITE:
                NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(session.conference_info.host_info.web_page.value))
            elif tag == PARTICIPANTS_MENU_START_AUDIO_SESSION:
                self.startSessionWithTarget(uri, media_type="audio", local_uri=session.account.id)
            elif tag == PARTICIPANTS_MENU_START_VIDEO_SESSION:
                self.startSessionWithTarget(uri, media_type="video", local_uri=session.account.id)
            elif tag == PARTICIPANTS_MENU_START_CHAT_SESSION:
                self.startSessionWithTarget(uri, media_type="chat", local_uri=session.account.id)
            elif tag == PARTICIPANTS_MENU_SEND_FILES:
                openFileTransferSelectionDialog(session.account, uri)

    def drawerSplitViewDidResize_(self, notification):
        if notification.userInfo() is not None:
            self.recalculateDrawerSplitter()

    def conferenceHistoryClicked_(self, sender):
        item = sender.representedObject()
        target = item["target_uri"]
        participants = item["participants"] or []
        media_type = item["streams"] or []

        account = self.activeAccount()
        if account is None:
            return
        conference = self.showJoinConferenceWindow(target=target, participants=participants, media_type=media_type, default_domain=account.id.domain)
        if conference is not None:
            self.joinConference(conference.target, conference.media_type, conference.participants)

        self.joinConferenceWindow.release()
        self.joinConferenceWindow = None

    def goToGroup_(self, sender):
        group = sender.representedObject()
        row = self.contactOutline.rowForItem_(group)
        if row >= 0:
            frame = self.contactOutline.frameOfOutlineCellAtRow_(row)
            self.contactOutline.selectRowIndexes_byExtendingSelection_(NSIndexSet.indexSetWithIndex_(row), False)
            self.contactOutline.scrollPoint_(frame.origin)

            self.contactOutline.expandItem_expandChildren_(group, False)
            if group.group is not None:
                group.group.expanded = True
                group.group.save()

    @objc.IBAction
    def toggleGroupVisibility_(self, sender):
        settings = SIPSimpleSettings()
        group = sender.representedObject()
        if group == self.model.missed_calls_group:
            settings.contacts.enable_missed_calls_group = not settings.contacts.enable_missed_calls_group
        elif group == self.model.incoming_calls_group:
            settings.contacts.enable_incoming_calls_group = not settings.contacts.enable_incoming_calls_group
        elif group == self.model.outgoing_calls_group:
            settings.contacts.enable_outgoing_calls_group = not settings.contacts.enable_outgoing_calls_group
        elif group == self.model.online_contacts_group:
            settings.contacts.enable_online_group = not settings.contacts.enable_online_group
        elif group == self.model.addressbook_group:
            settings.contacts.enable_address_book = not settings.contacts.enable_address_book
        elif group == self.model.blocked_contacts_group:
            settings.contacts.enable_blocked_group = not settings.contacts.enable_blocked_group
        elif group == self.model.no_group:
            settings.contacts.enable_no_group = not settings.contacts.enable_no_group
        settings.save()

    @objc.IBAction
    def showChatWindowForAccountWithTargetUri_(self, sender):
        object = sender.representedObject()
        if object is not None:
            self.startSessionWithTarget(object['target_uri'], media_type="chat", local_uri=object['account'])

    @objc.IBAction
    def showMyVideo_(self, sender):
        if self.myvideo is None:
            self.myvideo = MyVideoWindowController()
        self.myvideo.show()

    @objc.IBAction
    def showVideoMirror_(self, sender):
        # Devices -> Video Mirror. Singleton window that displays the
        # local camera mirrored, backed by the Metal VideoWidget. Toggles
        # on each click.
        if getattr(self, 'video_mirror', None) is None:
            self.video_mirror = VideoMirrorController.alloc().init()
        self.video_mirror.toggle()

    def historyClicked_(self, sender):
        NSApp.activateIgnoringOtherApps_(True)
        if sender.tag() == 444:
            SessionHistory().delete_entries()
        elif sender.tag() == 555:
            # Voicemail
            account = sender.representedObject()
            BlinkLogger().log_info("Voicemail option pressed for account %s" % account.id)
            if account.voicemail_uri is None:
                return
            target_uri = normalize_sip_uri_for_outgoing_session(account.voicemail_uri, account)
            session_controller = self.sessionControllersManager.addControllerWithAccount_target_displayName_contact_(account, target_uri, None, None)
            session_controller.startAudioSession()
        else:
            item = sender.representedObject()
            target_uri = item["target_uri"]
            _search_text = target_uri
            if item["account"] == 'bonjour@local':
                _search_text = item['display_name']
                account = BonjourAccount()
            else:
                try:
                    account = AccountManager().get_account(item["account"])
                except:
                    account = None

            if account and account.enabled:
                # auto-select the account
                AccountManager().default_account = account
                self.refreshAccountList()

            self.searchBox.setStringValue_(_search_text)
            self.searchContacts()
            self.focusSearchTextField()

    @objc.IBAction
    def selectOutboundProxyClicked_(self, sender):
        account = sender.representedObject()
        if sender.tag() == 700:
            account.sip.always_use_my_proxy = False
            account.save()

        elif sender.tag() == 701:
            account.sip.outbound_proxy = account.sip.primary_proxy
            account.sip.always_use_my_proxy = True
            account.sip.selected_proxy = 0
            account.save()

        elif sender.tag() == 702:
            account.sip.outbound_proxy = account.sip.alternative_proxy
            account.sip.always_use_my_proxy = True
            account.sip.selected_proxy = 1
            account.save()

    @objc.IBAction
    def focusSearchTextField_(self, sender):
        NSApp.activateIgnoringOtherApps_(True)
        self.focusSearchTextField()

    @objc.IBAction
    def redialLast_(self, sender):
        self.get_last_outgoing_session_from_history()

    @objc.IBAction
    def tellMeWhenContactBecomesAvailable_(self, sender):
        contact = sender.representedObject()
        if contact in self.tellMeWhenContactBecomesAvailableList:
            self.tellMeWhenContactBecomesAvailableList.discard(contact)
        else:
            self.tellMeWhenContactBecomesAvailableList.add(contact)

    @objc.IBAction
    def sendFile_(self, sender):
        uri = sender.representedObject()
        account = self.activeAccount()
        if not account:
            NSRunAlertPanel(NSLocalizedString("Cannot Send File", "Window title"), NSLocalizedString("There are currently no active accounts", "Label"), NSLocalizedString("OK", "Button title"), None, None)
            return
        try:
            contact = self.getSelectedContacts()[0]
        except IndexError:
            pass
        else:
            if contact in self.model.bonjour_group.contacts:
                account = BonjourAccount()
            openFileTransferSelectionDialog(account, uri or contact.uri)

    @objc.IBAction
    def viewHistory_(self, sender):
        self.showHistoryViewer_(None)
        item = sender.representedObject()
        if isinstance(item, BlinkPresenceContact):
            all_uris = []
            for uri in sorted(item.uris, key=lambda uri: uri.position if uri.position is not None else sys.maxsize):
                all_uris.append(str(uri.uri))
            self.historyViewer.filterByURIs(all_uris)
        elif isinstance(item, BonjourBlinkContact):
            self.historyViewer.filterByURIs([item.id])

    @objc.IBAction
    def viewHistoryForContact_(self, sender):
        self.showHistoryViewer_(None)
        object = sender.representedObject()
        self.historyViewer.setPeriod(object['days'])
        self.historyViewer.filterByURIs(object['uris'])

    @objc.IBAction
    def recordingClicked_(self, sender):
        NSWorkspace.sharedWorkspace().openFile_(sender.representedObject())

    @objc.IBAction
    def restoreContactsClicked_(self, sender):
        self.model.restore_contacts(sender.representedObject())

    @objc.IBAction
    def showInFavoritesGroup_(self, sender):
        contact = sender.representedObject()
        contact.favorite = not contact.favorite

    @objc.IBAction
    def goToBackupContactsFolderClicked_(self, sender):
        NSWorkspace.sharedWorkspace().openFile_(sender.representedObject())

    @property
    def pgp_keys_directory(self):
        path = ApplicationData.get('keys')
        makedirs(path)
        return path

    @objc.IBAction
    def goToPGPKeyFolderClicked_(self, sender):
        NSWorkspace.sharedWorkspace().openFile_(self.pgp_keys_directory)

    @objc.IBAction
    def toggleDialPadClicked_(self, sender):
        identifier = "dialpad" if self.mainTabView.selectedTabViewItem().identifier() != "dialpad" else "contacts"
        NSUserDefaults.standardUserDefaults().setValue_forKey_(identifier, "MainWindowSelectedTabView")
        self.setMainTabView(identifier)
        self.window().makeKeyWindow()

    def startConferenceTimer_(self, timer):
        for conference in self.scheduled_conferences.copy():
            start_now = True
            label = ''
            i = 1
            for uri in conference.participants:
                presence_contact = self.getFirstContactFromAllContactsGroupMatchingURI(uri)
                if 1 < i <= len(conference.participants):
                    if i == len(conference.participants):
                        label += ' and '
                    else:
                        label += ', '

                if presence_contact:
                    status = presence_status_for_contact(presence_contact)
                    if status != 'available':
                        start_now = False
                    label += presence_contact.name
                else:
                    label += uri
                    start_now = False

                i += 1

            label = label.rstrip(",")
            if not start_now:
                continue

            settings = SIPSimpleSettings()
            if not self.speech_synthesizer_active and not self.has_audio and not settings.audio.silent:

                settings = SIPSimpleSettings()
                this_hour = int(datetime.datetime.now(tzlocal()).strftime("%H"))
                volume = 0.8

                if settings.sounds.night_volume.start_hour < settings.sounds.night_volume.end_hour:
                    if settings.sounds.night_volume.start_hour <= this_hour < settings.sounds.night_volume.end_hour:
                        volume = settings.sounds.night_volume.volume/100.0
                elif settings.sounds.night_volume.start_hour > settings.sounds.night_volume.end_hour:
                    if this_hour < settings.sounds.night_volume.end_hour:
                        volume = settings.sounds.night_volume.volume/100.0
                    elif this_hour >= settings.sounds.night_volume.start_hour:
                        volume = settings.sounds.night_volume.volume/100.0
                self.speech_synthesizer.setVolume_(volume)

                speak_text = NSLocalizedString("%s are now available. Start conference now?", "Spoken text by synthesiser") % label
                self.speech_synthesizer_active = True
                self.speech_synthesizer.startSpeakingString_(speak_text)

            NSApp.activateIgnoringOtherApps_(True)
            ret = NSRunAlertPanel(NSLocalizedString("Start Scheduled Conference", "Window title"), label, NSLocalizedString("Start Now", "Button title"), NSLocalizedString("Cancel", "Button title"), None)

            if ret == NSAlertDefaultReturn:
                self.joinConference(conference.target, conference.media_type, conference.participants, conference.nickname)

            self.scheduled_conferences.discard(conference)

    def speechSynthesizer_didFinishSpeaking_(self, sender, success):
        self.speech_synthesizer_active = False

    @objc.python_method
    @run_in_gui_thread
    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    @objc.python_method
    def _NH_BlinkContactBecameAvailable(self, notification):
        contact = notification.sender
        if contact in self.tellMeWhenContactBecomesAvailableList:
            self.tellMeWhenContactBecomesAvailableList.discard(contact)
            settings = SIPSimpleSettings()
            if not self.speech_synthesizer_active and contact.name and not self.has_audio and not settings.audio.silent:
                settings = SIPSimpleSettings()
                this_hour = int(datetime.datetime.now(tzlocal()).strftime("%H"))
                volume = 0.8

                if settings.sounds.night_volume.start_hour < settings.sounds.night_volume.end_hour:
                    if settings.sounds.night_volume.start_hour <= this_hour < settings.sounds.night_volume.end_hour:
                        volume = settings.sounds.night_volume.volume/100.0
                elif settings.sounds.night_volume.start_hour > settings.sounds.night_volume.end_hour:
                    if this_hour < settings.sounds.night_volume.end_hour:
                        volume = settings.sounds.night_volume.volume/100.0
                    elif this_hour >= settings.sounds.night_volume.start_hour:
                        volume = settings.sounds.night_volume.volume/100.0
                self.speech_synthesizer.setVolume_(volume)

                self.speech_synthesizer_active = True
                speak_text = NSLocalizedString("%s is now available", "Spoken text by synthesiser") % contact.name
                self.speech_synthesizer.startSpeakingString_(speak_text)

    @objc.python_method
    def _NH_HistoryEntriesVisibilityChanged(self, notification):
        self.model.reload_history_groups(force_reload=True)

    @objc.python_method
    def _NH_PresenceSubscriptionDidFail(self, notification):
        try:
            position = self.accounts.index(notification.sender.account)
        except ValueError:
            pass
        else:
            if self.accounts[position].subscribe_presence_state != 'failed':
                BlinkLogger().log_debug("Presence subscriptions for account %s failed" % notification.sender.account.id)
            self.accounts[position].subscribe_presence_state = 'failed'

    @objc.python_method
    def _NH_PresenceSubscriptionDidEnd(self, notification):
        try:
            position = self.accounts.index(notification.sender.account)
        except ValueError:
            pass
        else:
            if self.accounts[position].subscribe_presence_state != 'ended':
                BlinkLogger().log_debug("Presence subscriptions for account %s ended" % notification.sender.account.id)
            self.accounts[position].subscribe_presence_state = 'ended'
            self.accounts[position].subscribe_presence_timestamp = None

    @objc.python_method
    @run_in_thread('addressbook')
    def _NH_SIPAccountGotPresenceState(self, notification):
        #BlinkLogger().log_info("Got presence update for account %s" % notification.sender.id)
        try:
            position = self.accounts.index(notification.sender)
        except ValueError:
            pass
        else:
            if self.accounts[position].subscribe_presence_state != 'active':
                BlinkLogger().log_debug("Presence subscriptions for account %s are active" % notification.sender.id)
            self.accounts[position].subscribe_presence_timestamp = time.time()
            self.accounts[position].subscribe_presence_state = 'active'
            self.accounts[position].subscribe_presence_purged = False

        resource_map = notification.data.resource_map
        for key, value in resource_map.items():
            resources = {key: value}
            (main_contacts, other_contacts) = self.model.getBlinkContactsAndGroupsForURI(key)
            for blink_contact in main_contacts:
                blink_contact.update_presence(resources, notification.sender.id, notification.data.full_state, other_contacts)

    @objc.python_method
    def _NH_AddressbookGroupWasActivated(self, notification):
        self.updateGroupMenu()

    @objc.python_method
    def _NH_AddressbookGroupWasDeleted(self, notification):
        self.updateGroupMenu()

    @objc.python_method
    def _NH_AddressbookGroupDidChange(self, notification):
        self.updateGroupMenu()

    _NH_VirtualGroupWasActivated = _NH_AddressbookGroupWasActivated
    _NH_VirtualGroupWasDeleted = _NH_AddressbookGroupWasDeleted
    _NH_VirtualGroupDidChange = _NH_AddressbookGroupDidChange

    @objc.python_method
    def _NH_SIPAccountManagerDidAddAccount(self, notification):
        account = notification.data.account
        self.accounts.insert(account.order, AccountInfo(account))
        self.refreshAccountList()

    @objc.python_method
    def _NH_SIPAccountManagerDidRemoveAccount(self, notification):
        position = self.accounts.index(notification.data.account)
        del self.accounts[position]
        self.refreshAccountList()

    @objc.python_method
    def _NH_SIPAccountDidActivate(self, notification):
        account = notification.sender
        self.refreshAccountList()
        if account is not BonjourAccount() and account.id.domain in ('sip2sip.info', 'sylk.link'):
            if not account.server.settings_url:
                account.server.settings_url = "https://blink.sipthor.net/settings.phtml"
                account.ldap.hostname = "ldap.sipthor.net"
                account.ldap.dn = "ou=addressbook, dc=sip2sip, dc=info"
                account.ldap.enabled = True
                account.save()

    @objc.python_method
    def _NH_BonjourAccountDidAddNeighbour(self, notification):
        """A neighbour is back: now the messages we owe them can be retried.

        This is the other half of the startup skip. Reopening at launch for
        somebody who is not on the network achieves nothing except a route
        lookup that fails for the life of the process; reopening here happens
        exactly once, when there is somewhere for the messages to go.
        """
        record = getattr(notification.data, 'record', None)
        device_id = getattr(record, 'id', None)
        if not device_id:
            return
        self.retry_unsent_bonjour_messages(device_id)

    @objc.python_method
    @run_in_green_thread
    def retry_unsent_bonjour_messages(self, device_id):
        try:
            conversations = [conversation for conversation in SessionHistory().get_last_unsent_messages()
                             if str(conversation.get('remote_uri') or '') == device_id]
        except Exception as e:
            BlinkLogger().log_error('Cannot look for unsent messages to Bonjour neighbour %s: %s'
                                    % (device_id, e))
            return
        if not conversations:
            return
        BlinkLogger().log_info('Bonjour neighbour %s is back: reopening %d conversation(s) with '
                               'unsent messages' % (device_id, len(conversations)))
        # raise_window=False: the neighbour appearing is not the user asking to
        # look at the conversation.
        self.open_last_sms_conversations(conversations, raise_window=False)

    @objc.python_method
    def _NH_BonjourGroupWasActivated(self, notification):
        self.updateGroupMenu()

    @objc.python_method
    def _NH_BonjourGroupWasDeactivated(self, notification):
        self.updateGroupMenu()

    @objc.python_method
    def _NH_SIPAccountDidDeactivate(self, notification):
        self.refreshAccountList()
        if notification.sender is BonjourAccount():
            self.updateGroupMenu()

    @objc.python_method
    def _NH_SIPAccountManagerDidChangeDefaultAccount(self, notification):
        self.refreshAccountList()
        self.refreshLdapDirectory()

        settings = SIPSimpleSettings()
        status = settings.presence_state.status
        self.setStatusBarIcon(status)

    @objc.python_method
    def _NH_SIPAccountWillRegister(self, notification):
        # BlinkLogger().log_info('startup: ContactWindowController._NH_SIPAccountWillRegister enter (%s)' % notification.sender.id)
        try:
            position = self.accounts.index(notification.sender)
        except ValueError:
            # BlinkLogger().log_info('startup: ContactWindowController._NH_SIPAccountWillRegister exit (no match)')
            return
        self.accounts[position].register_state = 'started'
        self.refreshAccountList()
        # BlinkLogger().log_info('startup: ContactWindowController._NH_SIPAccountWillRegister exit')

    @objc.python_method
    def _NH_SIPAccountRegistrationDidSucceed(self, notification):
        # BlinkLogger().log_info('startup: ContactWindowController._NH_SIPAccountRegistrationDidSucceed enter (%s)' % notification.sender.id)
        try:
            position = self.accounts.index(notification.sender)
        except ValueError:
            # BlinkLogger().log_info('startup: ContactWindowController._NH_SIPAccountRegistrationDidSucceed exit (no match)')
            return
        self.accounts[position].register_state = 'succeeded'

        if notification.sender is not BonjourAccount():
            registrar = '%s:%s:%s' % (notification.data.registrar.transport, notification.data.registrar.address, notification.data.registrar.port)
            self.accounts[position].route = notification.data.registrar
            self.accounts[position].registrar = registrar
            self.accounts[position].register_expires = notification.data.expires
            self.accounts[position].register_timestamp = time.time()

            BlinkLogger().log_info('Account %s registration succeeded' % notification.sender.id)
        else:
            BlinkLogger().log_info('Published Bonjour %s neighbour %s <%s>' % (notification.data.transport.upper(), notification.sender.display_name, notification.data.name))

        # BlinkLogger().log_info('startup: ContactWindowController.refreshAccountList enter')
        self.refreshAccountList()
        # BlinkLogger().log_info('startup: ContactWindowController._NH_SIPAccountRegistrationDidSucceed exit')

    @objc.python_method
    def _NH_SIPAccountRegistrationGotAnswer(self, notification):
        if hasattr(notification.data, 'error'):
            reason = notification.data.error.decode() if isinstance(notification.data.error, bytes) else notification.data.error
        elif hasattr(notification.data, 'reason'):
            reason = notification.data.reason.decode() if isinstance(notification.data.reason, bytes) else notification.data.reason
        else:
            reason = 'Unknown reason'

        if notification.data.code > 200:
            reason = NSLocalizedString("Connection failed", "Label") if reason == 'Unknown error 61' else reason
            BlinkLogger().log_debug("Account %s failed to register at %s: %s (%s)" % (notification.sender.id, notification.data.registrar, reason, notification.data.code))

        try:
            position = self.accounts.index(notification.sender)
        except ValueError:
            return

        if notification.data.code > 200:
            self.accounts[position].route = None
            self.accounts[position].register_failure_code = notification.data.code
            self.accounts[position].register_failure_reason = NSLocalizedString("Connection failed", "Label") if reason == 'Unknown error 61' else reason
            # Terminal = a final response from the registrar that retrying will not fix.
            # 408 and 503 are also what PJSIP synthesizes locally for transaction timeouts and
            # transport errors (e.g. ECONNREFUSED, EOF), and 423 is retried by the Registrar itself.
            if notification.data.code >= 300 and notification.data.code not in REGISTER_TRANSIENT_CODES:
                self.accounts[position].register_terminal_reason = '%s %s' % (notification.data.code, reason)
            else:
                self.accounts[position].register_terminal_reason = None
        else:
            self.accounts[position].register_failure_code = None
            self.accounts[position].register_failure_reason = None
            self.accounts[position].register_terminal_reason = None

    @objc.python_method
    def _NH_SIPAccountRegistrationDidFail(self, notification):
        if hasattr(notification.data, 'error'):
            reason = notification.data.error.decode() if isinstance(notification.data.error, bytes) else notification.data.error
        elif hasattr(notification.data, 'reason'):
            reason = notification.data.reason.decode() if isinstance(notification.data.reason, bytes) else notification.data.reason
        else:
            reason = 'Unknown reason'
            
        self.last_failure_reason = reason

        if notification.sender is not BonjourAccount():
            BlinkLogger().log_info('Account %s registration failed: %s' % (notification.sender.id, reason))

        try:
            position = self.accounts.index(notification.sender)
        except ValueError:
            return

        self.accounts[position].register_state = 'failed'
        if self.accounts[position].register_failure_reason is None:
            if host is None or host.default_ip is None:
                self.accounts[position].register_failure_reason = NSLocalizedString("No Internet connection", "Label")
            elif hasattr(notification.data, 'error'):
                if notification.data.error.startswith('DNS'):
                    self.accounts[position].register_failure_reason = NSLocalizedString("DNS Lookup failed", "Label")
                elif notification.data.error == 'Unknown error 61' or 'PJ_EEOF' in reason:
                    self.accounts[position].register_failure_reason = NSLocalizedString("Connection failed", "Label")
                else:
                    self.accounts[position].register_failure_reason = reason

        elif 'timeout' in reason:
            self.accounts[position].register_failure_reason = NSLocalizedString("Connection failed", "Label")

        elif 'failed' in reason:
            self.accounts[position].register_failure_reason = NSLocalizedString("Connection failed", "Label")

        self.refreshAccountList()
        if isinstance(notification.sender, Account):
            if not self.authFailPopupShown and self.accounts[position].register_state != 'failed':
                self.authFailPopupShown = True
                if notification.data.error == 'Authentication failed':
                    nc_title = NSLocalizedString("Account", "Label") + " " + notification.sender.id
                    nc_body = NSLocalizedString("Authentication failed", "Label")
                    NSApp.delegate().gui_notify(nc_title, nc_body)
                    # NSRunAlertPanel(u"SIP Registration Error", u"The account %s could not be registered because of an authentication error. Please check if your account credentials are correctly entered. \n\nFor help on this matter you should contact your SIP service provider." % notification.sender.id, u"OK", None, None)
                else:
                    nc_title = NSLocalizedString("Account", "Label") + " " + notification.sender.id
                    nc_body = NSLocalizedString("Registration failed", "Label") + " (%s)" % notification.data.error
                    NSApp.delegate().gui_notify(nc_title, nc_body)
                    # NSRunAlertPanel(u"SIP Registration Error", u"The account %s could not be registered at this time: %s" % (notification.sender.id, notification.data.error),  u"OK", None, None)
                self.authFailPopupShown = False

    @objc.python_method
    def _NH_SystemDidWakeUpFromSleep(self, notification):
        settings = SIPSimpleSettings()
        last_video_device = settings.video.device
        last_audio_input_device = settings.audio.input_device
        last_audio_output_device = settings.audio.output_device
        last_audio_alert_device = settings.audio.alert_device
        settings.video.device = None
        settings.audio.alert_device = None
        settings.audio.input_device = None
        settings.audio.output_device = None
        settings.save()

        def wakeup():
            BlinkLogger().log_info('Running _wakeup actions')

            SIPApplication.engine.refresh_sound_devices()
            SIPApplication.engine.refresh_video_devices()

            settings.video.device = last_video_device or 'system_default'
            settings.audio.input_device = last_audio_input_device or 'system_default'
            settings.audio.output_device = last_audio_output_device or 'system_default'
            settings.audio.alert_device = last_audio_alert_device or 'system_default'
            settings.save()

            for account_info in self.accounts:
                account = account_info.account

                if account is BonjourAccount():
                    continue

                if not account.enabled:
                    continue

                account_info.register_state = 'failed'

                if host is None or host.default_ip is None:
                    account_info.register_failure_reason = NSLocalizedString("No Internet connection", "Label")
                else:
                    account_info.register_failure_reason = NSLocalizedString("Connection failed", "Label")
                    account_info.register_state = 'failed'

                self.refreshAccountList()
                BlinkLogger().log_info('Re-register account %s' % account.id)

                account.reregister()
                account.resubscribe()

        # wait for system to stabilize
        BlinkLogger().log_info('Will run _wakeup actions in 5 seconds')
        d = task.deferLater(reactor, 5, wakeup)

        def called(result):
            BlinkLogger().log_info(result)

        d.addCallback(called)

        #reactor.callLater(5, wakeup)

    @objc.python_method
    def _NH_SystemIPAddressDidChange(self, notification):
        for account in self.accounts:
            if notification.data.new_ip_address is not None:
                if account.register_failure_reason == NSLocalizedString("No Internet connection", "Label"):
                    account.register_failure_reason = None
            else:
                account.register_failure_reason = NSLocalizedString("No Internet connection", "Label")

        self.refreshAccountList()

    @objc.python_method
    def _NH_SIPAccountRegistrationDidEnd(self, notification):
        try:
            position = self.accounts.index(notification.sender)
        except ValueError:
            return
        self.accounts[position].register_state = 'ended'
        self.refreshAccountList()

    _NH_BonjourAccountWillRegister = _NH_SIPAccountWillRegister
    _NH_BonjourAccountRegistrationDidSucceed = _NH_SIPAccountRegistrationDidSucceed
    _NH_BonjourAccountRegistrationDidFail = _NH_SIPAccountRegistrationDidFail
    _NH_BonjourAccountRegistrationDidEnd = _NH_SIPAccountRegistrationDidEnd

    @objc.python_method
    def _NH_AudioDevicesDidChange(self, notification):
        old_devices = notification.data.old_devices
        new_devices = notification.data.new_devices
        diff = set(new_devices).difference(set(old_devices))
        if diff:
            new_device = diff.pop()
            BlinkLogger().log_info("New audio device %s detected" % new_device.strip())
            in_devices = list(set(self.backend._app.engine.input_devices))
            out_devices = list(set(self.backend._app.engine.output_devices))
            if new_device in in_devices and new_device in out_devices:
                self.switchAudioDevice(new_device)
            else:
                pass
                #self.menuWillOpen_(self.devicesMenu)
        #else:
        #    self.menuWillOpen_(self.devicesMenu)

        self.checkBuiltinMicrophone()

    @objc.python_method
    def _NH_DefaultAudioDeviceDidChange(self, notification):
        self.checkBuiltinMicrophone()

    @objc.python_method
    def _NH_BlinkBuiltinMicrophoneAvailabilityDidChange(self, notification):
        self.checkBuiltinMicrophone()

    @objc.python_method
    def effectiveInputDevice(self):
        settings = SIPSimpleSettings()
        value = settings.audio.input_device
        if value in ('system_default', 'default'):
            return self.backend._app.engine.default_input_device
        return value

    @objc.python_method
    def checkBuiltinMicrophone(self):
        """React to the lid closing on the built-in microphone.

        Lid closed and the mic in use is the built-in one: never switch on our
        own -- ask. The alert lists the other inputs to pick from, or says
        there is none. It is shown once per lid close and set of available
        inputs, so plugging in a microphone with the lid closed asks again.
        """
        if not self.builtinMicrophoneIsDead():
            self._lid_prompted_for = None
            self.dismissNoMicrophoneAlert()
            return

        alternatives = self.alternativeInputDevices()
        if self._lid_alert_is_open():
            if tuple(alternatives) != getattr(self, '_lid_alert_devices', None):
                self._lid_alert_refresh = True
                NSApp.abortModal()
            return
        if tuple(alternatives) == getattr(self, '_lid_prompted_for', None):
            return
        self.warnNoMicrophone()

    @objc.python_method
    def alternativeInputDevices(self):
        builtin = ClamshellMonitor().builtin_inputs()
        return [dev for dev in self.backend._app.engine.input_devices if dev.strip() not in builtin]

    @objc.python_method
    def _lid_alert_is_open(self):
        return getattr(self, '_lid_alert', None) is not None

    @objc.python_method
    def warnNoMicrophone(self, force=False):
        alternatives = self.alternativeInputDevices()
        if not force and tuple(alternatives) == getattr(self, '_lid_prompted_for', None):
            return
        self._lid_prompted_for = tuple(alternatives)
        if alternatives:
            BlinkLogger().log_warning('Lid closed, built-in microphone is disconnected, asking which input to use')
        else:
            BlinkLogger().log_warning('Lid closed and no other audio input is available, the other party will not hear you')
            NSApp.delegate().gui_notify(NSLocalizedString("Microphone not available", "Label"),
                                        NSLocalizedString("The built-in microphone does not work while the lid is closed. Open the lid or connect a headset or external microphone.", "Label"))
        # Queued rather than run here: runModal would block the notification
        # handler that got us here, and every observer after it.
        NSApp.delegate().performSelectorOnMainThread_withObject_waitUntilDone_("callObject:", self.showNoMicrophoneAlert, False)

    @objc.python_method
    def showNoMicrophoneAlert(self):
        if not self._lid_alert_is_open():
            self.runNoMicrophoneAlert(for_call=False)

    @objc.python_method
    def warnAcceptedCallWithoutMicrophone(self):
        if self.builtinMicrophoneIsDead():
            BlinkLogger().log_warning('Incoming call accepted with the lid closed on the built-in microphone')
            self.warnNoMicrophone(force=True)

    @objc.python_method
    def ensureMicrophoneForCall(self):
        """Gate for starting a call: True when there is a working microphone.

        With the lid closed on the built-in mic the same alert is shown, and
        the call goes ahead only if the user picks a device that works (or
        opens the lid while it is up).
        """
        if not self.builtinMicrophoneIsDead():
            return True
        if self._lid_alert_is_open():
            return False
        BlinkLogger().log_warning('Lid closed and the built-in microphone is selected, asking for a microphone before starting the call')
        self._lid_prompted_for = tuple(self.alternativeInputDevices())
        ok = self.runNoMicrophoneAlert(for_call=True)
        if not ok:
            BlinkLogger().log_info('Call not started, no microphone selected')
        return ok

    @objc.python_method
    def runNoMicrophoneAlert(self, for_call=False):
        """Run the alert modally; True when a working microphone is in use afterwards."""
        from AppKit import NSAlert, NSPopUpButton
        try:
            from AppKit import NSAlertStyleWarning as warning_style
        except ImportError:
            from AppKit import NSWarningAlertStyle as warning_style
        NSAlertFirstButtonReturn = 1000

        while self.builtinMicrophoneIsDead():
            alternatives = self.alternativeInputDevices()
            alert = NSAlert.alloc().init()
            alert.setAlertStyle_(warning_style)
            alert.setMessageText_(NSLocalizedString("Microphone not available", "Window title"))
            cancel_title = NSLocalizedString("Cancel Call", "Button title") if for_call else NSLocalizedString("Cancel", "Button title")
            popup = None
            if alternatives:
                alert.setInformativeText_(NSLocalizedString("The built-in microphone is disconnected while the lid is closed. The other party will not hear you.\n\nSelect another microphone to use:", "Label"))
                popup = NSPopUpButton.alloc().initWithFrame_pullsDown_(NSMakeRect(0, 0, 300, 26), False)
                settings = SIPSimpleSettings()
                for dev in alternatives:
                    popup.addItemWithTitle_(dev.strip())
                    popup.lastItem().setRepresentedObject_(dev)
                # preselect, not select: a headset that is also the output
                if settings.audio.output_device in alternatives:
                    popup.selectItemAtIndex_(alternatives.index(settings.audio.output_device))
                alert.setAccessoryView_(popup)
                alert.addButtonWithTitle_(NSLocalizedString("Use This Microphone", "Button title"))
                alert.addButtonWithTitle_(cancel_title)
            else:
                if for_call:
                    alert.setInformativeText_(NSLocalizedString("The built-in microphone is disconnected while the lid is closed, and no other microphone is connected. The call cannot be started.\n\nOpen the lid or connect a headset or external microphone.", "Label"))
                    alert.addButtonWithTitle_(cancel_title)
                else:
                    alert.setInformativeText_(NSLocalizedString("The built-in microphone is disconnected while the lid is closed, and no other microphone is connected. The other party will not hear you.\n\nOpen the lid or connect a headset or external microphone.", "Label"))
                    alert.addButtonWithTitle_(NSLocalizedString("OK", "Button title"))

            self._lid_alert = alert
            self._lid_alert_devices = tuple(alternatives)
            self._lid_alert_refresh = False
            NSApp.activateIgnoringOtherApps_(True)
            try:
                response = alert.runModal()
            finally:
                self._lid_alert = None

            if popup is not None and response == NSAlertFirstButtonReturn:
                dev = popup.selectedItem().representedObject()
                BlinkLogger().log_info('Lid closed, user selected audio input %s' % dev.strip())
                settings = SIPSimpleSettings()
                settings.audio.input_device = dev
                settings.save()
                return True
            if not self._lid_alert_refresh:
                break
            # aborted because the set of inputs changed: ask again with the new list

        # left by the user, or aborted because the lid opened
        return not self.builtinMicrophoneIsDead()

    @objc.python_method
    @run_in_gui_thread
    def dismissNoMicrophoneAlert(self):
        # The lid opened or the user picked another input while the alert is
        # up. The lid poll timer runs in the common modes, so this does arrive
        # while the modal session is running.
        if self._lid_alert_is_open():
            self._lid_alert_refresh = False
            NSApp.abortModal()

    @objc.python_method
    def builtinMicrophoneIsDead(self):
        monitor = ClamshellMonitor()
        if not monitor.builtin_microphone_disabled:
            return False
        if SIPSimpleSettings().audio.input_device is None:
            return False
        current = self.effectiveInputDevice()
        return current is not None and current.strip() in monitor.builtin_inputs()
        #self.menuWillOpen_(self.devicesMenu)

    @objc.python_method
    def _NH_MediaStreamDidInitialize(self, notification):
        if notification.sender.type == "audio":
            self.updateAudioButtons()

    @objc.python_method
    def _NH_MediaStreamDidEnd(self, notification):
        if notification.sender.type == "audio":
            self.updateAudioButtons()

    @objc.python_method
    def _NH_SIPApplicationWillEnd(self, notification):
        self.conference_timer.invalidate()
        self.purge_presence_timer.invalidate()

    @objc.python_method
    def _NH_SIPApplicationWillStart(self, notification):
        # BlinkLogger().log_info('startup: SIPApplicationWillStart enter')
        self.alertPanel = AlertPanel.alloc().init()
        # BlinkLogger().log_info('startup: loadUserIcon')
        self.loadUserIcon()
        # BlinkLogger().log_info('startup: loadPresenceStates')
        self.loadPresenceStates()
        # BlinkLogger().log_info('startup: setSpeechSynthesis')
        self.setSpeechSynthesis()

        if not NSApp.delegate().wait_for_enrollment:
            BlinkLogger().log_debug('Starting main user interface')
            # BlinkLogger().log_info('startup: showWindow')
            self.showWindow_(None)
        # BlinkLogger().log_info('startup: SIPApplicationWillStart exit')

    @objc.python_method
    def _NH_SIPApplicationDidStart(self, notification):
        # BlinkLogger().log_info('startup: SIPApplicationDidStart enter')
        self.ready = True
        # Which Blink this is. Two builds share one log file and one
        # bundle id -- the one running from source and the one installed
        # in /Applications -- and a banner clicked while neither is
        # running launches whichever LaunchServices prefers, which is not
        # the one being worked on. Runs of the two are then interleaved in
        # activity.txt with nothing to tell them apart, and a fix that is
        # plainly in the source reads as a fix that does not work.
        try:
            import sys
            bundle = NSBundle.mainBundle()
            BlinkLogger().log_info('Application is ready: %s (%s), python %s'
                                   % (bundle.bundlePath(),
                                      bundle.infoDictionary().get(
                                          'CFBundleShortVersionString', '?'),
                                      sys.executable))
        except Exception:
            BlinkLogger().log_info('Application is ready')
        # BlinkLogger().log_info('startup: callPendingURIs')
        self.callPendingURIs()
        # BlinkLogger().log_info('startup: refreshLdapDirectory')
        self.refreshLdapDirectory()
        #self.updateHistoryMenu()
        # BlinkLogger().log_info('startup: updateStartSessionButtons')
        self.updateStartSessionButtons()
        ClamshellMonitor().start()
        self.checkBuiltinMicrophone()
        # BlinkLogger().log_info('startup: removePresenceContactForOurselves')
        self.removePresenceContactForOurselves()
        # BlinkLogger().log_info('startup: FileTransferWindowController()')
        self.fileTransfersWindow = FileTransferWindowController()

        # A second pass, in case the autosaved frame was restored after
        # awakeFromNib ran. It returns immediately when the width is already
        # right, which is the normal case -- so it costs nothing and is not
        # what the user sees.
        self.restoreContactListWidthAtLaunch()

        # Seed the Messages group's order again now that the contact list
        # actually exists. The manager seeds itself at its own init, which
        # can land before any group is built -- the reorder notification
        # then has nothing to reorder, and the group stays alphabetical
        # until the next message arrives. Seeding twice is harmless: the
        # map only ever moves timestamps forward.
        try:
            from SMSWindowManager import SMSWindowManager
            SMSWindowManager().loadLastMessageTimes()
        except Exception as e:
            BlinkLogger().log_error('Cannot seed the conversation order: %s' % e)
        # BlinkLogger().log_info('startup: SIPApplicationDidStart exit')

        # The merge of duplicate Contacts in the 'Messages' group no longer
        # runs here: at launch the addressbook is still the cached document,
        # and deleting from it deletes on the strength of stale data. It runs
        # after each document is applied -- ContactListModel._callsGroupAfterReload.
        
    @objc.python_method
    def _NH_BlinkShouldTerminate(self, notification):
        NotificationCenter().remove_observer(self, name="BlinkContactsHaveChanged")
        self.model.groupsList = []
        self.refreshContactsList()
        self.window().orderOut_(self)

    @objc.python_method
    def _NH_BlinkMuteChangedState(self, notification):
        if self.backend.is_muted():
            self.muteButton.setState_(NSOnState)
            self.muteButton.setImage_(NSImage.imageNamed_("muted"))
        else:
            self.muteButton.setState_(NSOffState)
            self.muteButton.setImage_(NSImage.imageNamed_("mute"))

    @objc.python_method
    def _NH_BlinkChatWindowClosed(self, notification):
        session = self.getSelectedAudioSession()
        if not session:
            return

        if session.hasStreamOfType("video") and session.video_consumer == "audio":
            self.showAudioDrawer()
        elif session.hasStreamOfType("audio"):
            self.showAudioDrawer()

    @objc.python_method
    def _NH_BlinkVideoWindowClosed(self, notification):
        session = self.getSelectedAudioSession()
        if not session:
            return

        if session.hasStreamOfType("audio"):
            audio_stream = session.streamHandlerOfType("audio")
            if audio_stream.status in (STREAM_CONNECTED, STREAM_RINGING, STREAM_PROPOSING):
                self.showAudioDrawer()

    @objc.python_method
    def _NH_BlinkContactsHaveChanged(self, notification):
        # Mark the contact tree as dirty and schedule a single refresh on
        # the next run-loop tick. Multiple notifications arriving in the
        # same 50 ms window collapse into one NSOutlineView.reloadData
        # instead of one per sender — the difference between 8 redundant
        # full reloads during a Bonjour storm and a single one.
        # BlinkLogger().log_info('startup: _NH_BlinkContactsHaveChanged enter (sender=%s)' % type(notification.sender).__name__)
        if self._contacts_dirty_senders is None:
            self._contacts_dirty_senders = set()
        self._contacts_dirty_senders.add(notification.sender)
        if self._contacts_refresh_timer is None:
            self._contacts_refresh_timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
                0.05, self, 'flushContactsRefresh:', None, False
            )
        # BlinkLogger().log_info('startup: _NH_BlinkContactsHaveChanged exit (queued)')

    def flushContactsRefresh_(self, timer):
        # Drain the accumulated dirty-sender set into a single
        # refreshContactsList call (full reload when multiple senders
        # touched the tree, subtree reload when only one did) plus a
        # single searchContacts pass.
        senders = self._contacts_dirty_senders or set()
        self._contacts_dirty_senders = None
        self._contacts_refresh_timer = None
        # BlinkLogger().log_info('startup: flushContactsRefresh enter (n=%d)' % len(senders))

        if len(senders) > 1 or self.model in senders or not senders:
            # Full reload covers every accumulated subtree change.
            self.refreshContactsList()
        else:
            # Single sender — reload just its subtree (cheaper than a
            # full reloadData when only one group/contact changed).
            (sender,) = tuple(senders)
            self.refreshContactsList(sender)

        # BlinkLogger().log_info('startup: searchContacts enter')
        self.searchContacts()
        # BlinkLogger().log_info('startup: flushContactsRefresh exit')

    @objc.python_method
    def _NH_BlinkSessionChangedState(self, notification):
        self.toggleOnThePhonePresenceActivity()

    @objc.python_method
    def _NH_BlinkStreamHandlersChanged(self, notification):
        self.toggleOnThePhonePresenceActivity()

    @objc.python_method
    def _NH_BlinkConferenceGotUpdate(self, notification):
        self.updateParticipantsView()

    @objc.python_method
    def _NH_BlinkDidRenegotiateStreams(self, notification):
        self.recalculateDrawerSplitter()

    @objc.python_method
    def _NH_ActiveAudioSessionChanged(self, notification):
        self.updateParticipantsView()
        session = self.getSelectedAudioSession()
        if session:
            session.setVideoConsumer(session.video_consumer)

    @objc.python_method
    def _NH_CFGSettingsObjectDidChange(self, notification):
        settings = SIPSimpleSettings()
        if "audio.silent" in notification.data.modified:
            if self.backend.is_silent():
                self.silentButton.setImage_(NSImage.imageNamed_("belloff"))
                self.silentButton.setState_(NSOnState)
            else:
                self.silentButton.setImage_(NSImage.imageNamed_("bellon"))
                self.silentButton.setState_(NSOffState)

        if "gui.mangle_contacts" in notification.data.modified:
            # Everything that draws a name or an address, redrawn: the
            # rows, the account menu and my own label above it, and every
            # open conversation. Nothing is re-read from disk -- only what
            # is on the screen changes.
            try:
                from ContactMangler import invalidate
                invalidate()
                self.refreshAccountList()
                self.contactOutline.reloadData()
                try:
                    self.searchOutline.reloadData()
                except Exception:
                    pass            # no search results on screen
                if self.messagePaneController is not None:
                    self.messagePaneController.refreshMangling()
            except Exception as e:
                BlinkLogger().log_error('Cannot redraw after the contact mangling changed: %s' % e)

        if "ldap.enabled" in notification.data.modified:
            self.refreshLdapDirectory()

        if "ldap.hostname" in notification.data.modified:
            self.refreshLdapDirectory()

        if "ldap.protocol"in notification.data.modified:
            self.refreshLdapDirectory()

        if "ldap.port" in notification.data.modified:
            self.refreshLdapDirectory()

        if "ldap.username" in notification.data.modified:
            self.refreshLdapDirectory()

        if "ldap.password" in notification.data.modified:
            self.refreshLdapDirectory()

        if isinstance(notification.sender, (Account, BonjourAccount)) and 'order' in notification.data.modified:
            self.refreshAccountList()

        if isinstance(notification.sender, (Account, BonjourAccount)) and 'gui.account_label' in notification.data.modified:
            self.refreshAccountList()

        if isinstance(notification.sender, Account) and 'sip.register' in notification.data.modified:
            self.refreshAccountList()

        if "sounds.enable_speech_synthesizer" in notification.data.modified:
            self.setSpeechSynthesis()

        if 'presence_state.icon' in notification.data.modified:
            self.loadUserIcon()

        if "gui.language" in notification.data.modified:
            lang = None if settings.gui.language == "system_default" else settings.gui.language
            NSUserDefaults.standardUserDefaults().setObject_forKey_(NSArray.arrayWithObjects_(lang), "AppleLanguages")
            NSUserDefaults.standardUserDefaults().synchronize()
            NSRunAlertPanel(NSLocalizedString("Restart Required", "Window title"), NSLocalizedString("You must restart the software to apply this change", "Label"), NSLocalizedString("OK", "Button title"), None, None)

    @objc.python_method
    def _NH_LDAPDirectorySearchFoundContact(self, notification):
        if notification.sender == self.ldap_search:
            for type, uri in notification.data.uris:
                if uri:
                    exists = uri in (contact.uri for contact in self.searchResultsModel.groupsList)
                    if not exists:
                        contact = LdapSearchResultContact(str(uri), uri_type=format_uri_type(type), name=notification.data.name, icon=NSImage.imageNamed_("ldap"))
                        contact.detail = '%s (%s)' % (str(uri), format_uri_type(type))
                        self.ldap_found_contacts.append(contact)

            if self.ldap_found_contacts:
                self.searchResultsModel.groupsList = self.local_found_contacts + self.ldap_found_contacts
                self.searchOutline.reloadData()

    @objc.python_method
    def _NH_BlinkProposalDidFail(self, notification):
        media_type = notification.data.proposed_streams[0].type
        if media_type == "video":
            return
        target_uri = notification.sender.target_uri
        local_uri = notification.sender.account.id
        BlinkLogger().log_info("Starting new %s session to %s because adding stream failed" % (media_type, target_uri))
        self.startSessionWithTarget(target_uri, media_type=media_type, local_uri=local_uri)

    @objc.python_method
    def _NH_SIPSessionLoggedToHistory(self, notification):
        self.updateHistoryMenu()
        if self.new_audio_sample_rate and not self.has_audio:
            settings = SIPSimpleSettings()
            settings.audio.sample_rate = self.new_audio_sample_rate
            self.new_audio_sample_rate = None
            settings.save()

        NotificationCenter().post_notification('HistoryEntriesVisibilityChanged')

    @objc.python_method
    def _NH_SIPAccountGotSelfPresenceState(self, notification):
        settings = SIPSimpleSettings()
        own_service_id = 'SID-%s' % str(uuid.UUID(settings.instance_id))
        pidf = notification.data.pidf
        for service in pidf.services:
            if own_service_id == service.id:
                continue

            if service.timestamp is None:
                continue

            device_description = 'unknown'
            if service.device_info is not None and service.device_info.description is not None:
                device_description = service.device_info.description.value

            status = str(service.status.extended)
            try:
                selected_presence_activity = next((item['represented_object'] for item in PresenceActivityList if item['represented_object']['extended_status'] == status))
            except (StopIteration, KeyError):
                continue

            notes = sorted([str(note) for note in service.notes if note])
            try:
                note = notes[0]
            except IndexError:
                note = ''

            change = False
            must_publish = False

            try:
                last_published_timestamp = self.presencePublisher.last_service_timestamp[notification.sender.id]
            except KeyError:
                if self.my_device_is_active:
                    if not self.sync_presence_at_start:
                        BlinkLogger().log_info('Another device of mine (%s) is active' % device_description)
                    else:
                        BlinkLogger().log_info('Another device of mine (%s) became active' % device_description)
                self.my_device_is_active = False
                return
            else:
                if last_published_timestamp.value >= service.timestamp.value:
                    self.my_device_is_active = True
                    self.sync_presence_at_start = True
                    break
                else:
                    try:
                        last_status = self.last_status_per_device[device_description]
                    except KeyError:
                        BlinkLogger().log_info('My availability changed on device %s to %s' % (device_description, status))
                    else:
                        if last_status != status:
                            BlinkLogger().log_info('My availability changed on device %s to %s' % (device_description, status))

                    self.last_status_per_device[device_description] = status

                    if not self.sync_presence_at_start:
                        BlinkLogger().log_info('Will become active at start')
                        settings.presence_state.timestamp = ISOTimestamp.now()
                        self.sync_presence_at_start = True
                        must_publish = True
                        change = True

                    if self.my_device_is_active:
                        BlinkLogger().log_info('Another device of mine (%s) is active' % device_description)
                    self.my_device_is_active = False

            if note != settings.presence_state.note:
                if note.lower() == on_the_phone_activity['note'].lower():
                    self.presenceNoteText.setStringValue_(on_the_phone_activity['localized_note'])
                else:
                    self.presenceNoteText.setStringValue_(note)

                settings.presence_state.note = note
                change = True

            must_change_state = False
            if self.presencePublisher.idle_mode:
                if status == 'offline':
                    must_change_state = True
                else:
                    self.presencePublisher.presenceStateBeforeIdle = selected_presence_activity
                    self.presencePublisher.presenceStateBeforeIdle['note'] = note
            else:
                must_change_state = True

            if must_change_state:
                title = selected_presence_activity['title']
                if title != settings.presence_state.status:
                    change = True
                    self.changeMyPresenceFromStatus(title)
                    settings.presence_state.status = title

            if change:
                settings.save()
                object = dict(selected_presence_activity)
                object['note'] = note
                self.savePresenceActivityToHistory(object)

            if must_publish:
                self.presencePublisher.publish()

        try:
            my_last_status = self.last_status_per_device[own_service_id]
        except KeyError:
            BlinkLogger().log_debug('My device is now %s' % ('active' if self.my_device_is_active else 'passive'))
        else:
            if my_last_status != self.my_device_is_active:
                BlinkLogger().log_debug('My device is now %s' % ('active' if self.my_device_is_active else 'passive'))
        self.last_status_per_device[own_service_id] = self.my_device_is_active


class LdapDirectory(object):
    def __init__(self, ldap_settings):
        self.connected = False
        self.server = '%s://%s:%d' % ('ldap' if ldap_settings.transport == 'tcp' else 'ldaps', ldap_settings.hostname, ldap_settings.port)
        self.username = ldap_settings.username or ''
        self.password = ldap_settings.password or ''
        self.dn = ldap_settings.dn or ''
        self.extra_fields = ldap_settings.extra_fields or ''

        self.l = ldap.initialize(self.server)
        tls_folder = ApplicationData.get('tls')
        ca_path = os.path.join(tls_folder, 'ca.crt')
        #self.l.set_option(ldap.OPT_X_TLS_CACERTFILE, ca_path)
        self.l.set_option(ldap.OPT_NETWORK_TIMEOUT, 5)
        self.l.set_option(ldap.OPT_TIMEOUT, 5)
        self.l.set_option(ldap.OPT_TIMELIMIT, 10)
        self.l.set_option(ldap.OPT_SIZELIMIT, 100)

    def connect(self):
        if self.connected is False:
            try:
                self.l.simple_bind_s(self.username, self.password)
                BlinkLogger().log_info('Connected to LDAP server %s' % self.server)
                self.connected = True
            except ldap.LDAPError as e:
                BlinkLogger().log_info('Connection to LDAP server %s failed: %s' % (self.server, e))
                self.connected = False

    def disconnect(self):
        if self.l is not None and self.connected:
            try:
                self.l.unbind_ext_s()
                BlinkLogger().log_info('Disconnected from LDAP server %s' % self.server)
            except ldap.LDAPError:
                pass


class LdapSearch(object):
    def __init__(self, ldap_directory):
        self.ldap_directory = ldap_directory
        self.ldap_query_id = None

    def cancel(self):
        if self.ldap_query_id and self.ldap_directory.connected:
            try:
                self.ldap_directory.l.cancel(self.ldap_query_id)
            except ldap.LDAPError:
                return

    @run_in_thread('ldap-query')
    def search(self, keyword):
        if not self.ldap_directory:
            return

        self.ldap_directory.connect()
        if self.ldap_directory.connected:
            extra_fields = self.ldap_directory.extra_fields.split(",")

            filter = "cn=" + "*" + keyword + "*"
            try:
                self.ldap_query_id = self.ldap_directory.l.search(self.ldap_directory.dn, ldap.SCOPE_SUBTREE, filter)
            except ldap.LDAPError:
                return

            while True:
                try:
                    q = self.ldap_directory.l.result(self.ldap_query_id, all=0)
                    result_type, result_data = q
                except Exception as e:
                    print('LDAP error: %s' % str(e))
                    return

                if not result_data:
                    break

                if result_type == ldap.RES_SEARCH_ENTRY:
                    i = 1
                    for dn, entry in result_data:
                        if i % 10 == 0:
                            time.sleep(0.01)
                        uris = []
                        i += 1
                        
                        try:
                            name = entry['cn'][0].decode()
                        except (KeyError, IndexError, ValueError):
                            name = None
                        
                        for _entry in entry.get('telephoneNumber', []):
                            uris.append(('telephone', _entry.decode()))
                        for _entry in entry.get('workNumber', []):
                            uris.append(('work', _entry.decode()))
                        for _entry in entry.get('mobile', []):
                            uris.append(('mobile', _entry.decode()))
                        for _entry in entry.get('SIPIdentitySIPURI', []):
                            uris.append(('sip', sip_prefix_pattern.sub("", _entry.decode())))
                        for f in extra_fields:
                            f = f.strip()
                            for _entry in entry.get(f, []):
                                uris.append(('other', sip_prefix_pattern.sub("", _entry.decode())))
                        if uris:
                            name = name or uris[0]
                            data = NotificationData(name=name, uris=uris)
                            NotificationCenter().post_notification("LDAPDirectorySearchFoundContact", sender=self, data=data)

            self.ldap_query_id = None
