diff --git a/resources/artwork/icons/im-user-away.svg b/resources/artwork/icons/im-user-away.svg new file mode 100644 index 00000000..90703f4d --- /dev/null +++ b/resources/artwork/icons/im-user-away.svg @@ -0,0 +1,6 @@ + + + diff --git a/resources/artwork/icons/im-user-busy.svg b/resources/artwork/icons/im-user-busy.svg new file mode 100644 index 00000000..55d6c422 --- /dev/null +++ b/resources/artwork/icons/im-user-busy.svg @@ -0,0 +1,7 @@ + + + diff --git a/resources/artwork/icons/im-user-online.svg b/resources/artwork/icons/im-user-online.svg new file mode 100644 index 00000000..ea50dd84 --- /dev/null +++ b/resources/artwork/icons/im-user-online.svg @@ -0,0 +1,10 @@ + + + + + + + diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 86baa5b0..696e9b99 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -369,10 +369,12 @@ qt_add_qml_module(gonnect ui/components/popups/AudioDeviceMenu.qml ui/components/popups/BurgerMenu.qml ui/components/popups/DialInInfo.qml + ui/components/popups/EditStatusText.qml ui/components/popups/FirstAid.qml ui/components/popups/HistoryListContextMenu.qml ui/components/popups/JitsiHistoryListContextMenu.qml ui/components/popups/Menu.qml + ui/components/popups/OwnAvatarContextMenu.qml ui/components/popups/SearchResultPopup.qml ui/components/popups/VideoDeviceMenu.qml ui/components/popups/StreamingLightPopup.qml @@ -410,6 +412,7 @@ qt_add_qml_module(gonnect ui/components/HistoryList.qml ui/components/MainTabBar.qml ui/components/ParticipantsList.qml + ui/components/PresenceStatusIndicator.qml ui/components/RTTDisplay.qml ui/components/SettingsPage.qml ui/components/TogglerList.qml @@ -619,6 +622,10 @@ qt_add_qml_module(gonnect platform/SearchProvider.h ${PLATFORM_SOURCES} + presence/GlobalStateAggregator.h + presence/GlobalStateAggregator.cpp + presence/PresenceState.h + rtt/RTTProvider.h rtt/RTTProvider.cpp rtt/RTTMessage.h @@ -674,6 +681,8 @@ qt_add_qml_module(gonnect ui/RandomRoomNameGenerator.cpp ui/ParticipantsModel.h ui/ParticipantsModel.cpp + ui/PersonCoinProvider.h + ui/PersonCoinProvider.cpp ui/SearchListModel.h ui/SearchListModel.cpp ui/SearchListProxyModel.h @@ -792,6 +801,7 @@ target_include_directories(gonnect calendar contacts conference + presence ui ui/chat usb diff --git a/src/contacts/akonadi/CMakeLists.txt b/src/contacts/akonadi/CMakeLists.txt index 6ece5cf3..c32b41c1 100644 --- a/src/contacts/akonadi/CMakeLists.txt +++ b/src/contacts/akonadi/CMakeLists.txt @@ -20,6 +20,7 @@ if(ENABLE_AKONADI) target_include_directories(AkonadiAddressBookFactory PRIVATE ${PROJECT_SOURCE_DIR}/src/contacts + ${PROJECT_SOURCE_DIR}/src/presence ${PROJECT_SOURCE_DIR}/src/ui ${PROJECT_SOURCE_DIR}/src/sip ${PROJECT_SOURCE_DIR}/src diff --git a/src/contacts/carddav/CMakeLists.txt b/src/contacts/carddav/CMakeLists.txt index a83cd9f5..47a47215 100644 --- a/src/contacts/carddav/CMakeLists.txt +++ b/src/contacts/carddav/CMakeLists.txt @@ -16,6 +16,7 @@ if(ENABLE_DAV) target_include_directories(CardDAVAddressBookFactory PRIVATE ${PROJECT_SOURCE_DIR}/src/contacts + ${PROJECT_SOURCE_DIR}/src/presence ${PROJECT_SOURCE_DIR}/src/dbus/portal ${PROJECT_SOURCE_DIR}/src/ui ${PROJECT_SOURCE_DIR}/src/sip diff --git a/src/contacts/csv/CMakeLists.txt b/src/contacts/csv/CMakeLists.txt index 5da8880d..f6191e3f 100644 --- a/src/contacts/csv/CMakeLists.txt +++ b/src/contacts/csv/CMakeLists.txt @@ -11,6 +11,7 @@ if(ENABLE_CSV) target_include_directories(CSVAddressBookFactory PRIVATE ${PROJECT_SOURCE_DIR}/src/contacts + ${PROJECT_SOURCE_DIR}/src/presence ${PROJECT_SOURCE_DIR}/src/ui ${PROJECT_SOURCE_DIR}/src/sip ${PROJECT_SOURCE_DIR}/src diff --git a/src/contacts/eds/CMakeLists.txt b/src/contacts/eds/CMakeLists.txt index d8062f55..f4793d4a 100644 --- a/src/contacts/eds/CMakeLists.txt +++ b/src/contacts/eds/CMakeLists.txt @@ -14,6 +14,7 @@ if(ENABLE_EDS) target_include_directories(EDSAddressBookFactory PRIVATE ${PROJECT_SOURCE_DIR}/src/contacts + ${PROJECT_SOURCE_DIR}/src/presence ${PROJECT_SOURCE_DIR}/src/dbus/portal ${PROJECT_SOURCE_DIR}/src/ui ${PROJECT_SOURCE_DIR}/src/sip diff --git a/src/contacts/ldap/CMakeLists.txt b/src/contacts/ldap/CMakeLists.txt index 5d9079fe..f8cf65b4 100644 --- a/src/contacts/ldap/CMakeLists.txt +++ b/src/contacts/ldap/CMakeLists.txt @@ -29,6 +29,7 @@ if(ENABLE_LDAP) target_include_directories(LDAPAddressBookFactory PRIVATE ${PROJECT_SOURCE_DIR}/src/contacts + ${PROJECT_SOURCE_DIR}/src/presence ${PROJECT_SOURCE_DIR}/src/dbus/portal ${PROJECT_SOURCE_DIR}/src/ui ${PROJECT_SOURCE_DIR}/src/sip diff --git a/src/contacts/msgraph/CMakeLists.txt b/src/contacts/msgraph/CMakeLists.txt index 41f1eb09..5b725bda 100644 --- a/src/contacts/msgraph/CMakeLists.txt +++ b/src/contacts/msgraph/CMakeLists.txt @@ -11,6 +11,7 @@ if(ENABLE_MSGRAPH) target_include_directories(MSGraphAddressBookFactory PRIVATE ${PROJECT_SOURCE_DIR}/src/contacts + ${PROJECT_SOURCE_DIR}/src/presence ${PROJECT_SOURCE_DIR}/src/ui ${PROJECT_SOURCE_DIR}/src ${PROJECT_SOURCE_DIR} diff --git a/src/main.cpp b/src/main.cpp index 31bb8844..999fedfd 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,6 +3,7 @@ #include #include "Application.h" #include "GlobalInfo.h" +#include "PersonCoinProvider.h" #ifdef Q_OS_LINUX # include @@ -116,6 +117,8 @@ int main(int argc, char *argv[]) QObject::connect( &engine, &QQmlApplicationEngine::objectCreationFailed, &app, []() { QCoreApplication::exit(-1); }, Qt::QueuedConnection); + + engine.addImageProvider(QLatin1String("personcoin"), new PersonCoinProvider); engine.loadFromModule("base", "Main"); const auto &objs = engine.rootObjects(); diff --git a/src/presence/GlobalStateAggregator.cpp b/src/presence/GlobalStateAggregator.cpp new file mode 100644 index 00000000..a844190d --- /dev/null +++ b/src/presence/GlobalStateAggregator.cpp @@ -0,0 +1,3 @@ +#include "GlobalStateAggregator.h" + +GlobalStateAggregator::GlobalStateAggregator(QObject *parent) : QObject{ parent } { } diff --git a/src/presence/GlobalStateAggregator.h b/src/presence/GlobalStateAggregator.h new file mode 100644 index 00000000..db3bea76 --- /dev/null +++ b/src/presence/GlobalStateAggregator.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include "PresenceState.h" + +class GlobalStateAggregator : public QObject +{ + Q_OBJECT + + Q_PROPERTY(PresenceState::State presenceState MEMBER m_presenceState NOTIFY presenceStateChanged + FINAL) + Q_PROPERTY(QString statusText MEMBER m_statusText NOTIFY statusTextChanged FINAL) + +public: + static GlobalStateAggregator &instance() + { + static GlobalStateAggregator *_instance = nullptr; + if (!_instance) { + _instance = new GlobalStateAggregator; + } + return *_instance; + }; + + PresenceState::State presenceState() const { return m_presenceState; } + QString statusText() const { return m_statusText; } + +private: + explicit GlobalStateAggregator(QObject *parent = nullptr); + + PresenceState::State m_presenceState = PresenceState::State::Available; + QString m_statusText; + +Q_SIGNALS: + void presenceStateChanged(); + void statusTextChanged(); +}; + +class GlobalStateAggregatorWrapper +{ + Q_GADGET + QML_FOREIGN(GlobalStateAggregator) + QML_NAMED_ELEMENT(GlobalStateAggregator) + QML_SINGLETON + +public: + static GlobalStateAggregator *create(QQmlEngine *, QJSEngine *) + { + return &GlobalStateAggregator::instance(); + } + +private: + GlobalStateAggregatorWrapper() = default; +}; diff --git a/src/presence/PresenceState.h b/src/presence/PresenceState.h new file mode 100644 index 00000000..9434b94b --- /dev/null +++ b/src/presence/PresenceState.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +class PresenceState : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("") + +public: + explicit PresenceState(QObject *parent = nullptr) : QObject{ parent } { }; + + enum class State { Unknown, Offline, Away, Busy, Available, Ringing }; + Q_ENUM(State) +}; diff --git a/src/sip/SIPAccount.cpp b/src/sip/SIPAccount.cpp index c8bde695..2f0b4606 100644 --- a/src/sip/SIPAccount.cpp +++ b/src/sip/SIPAccount.cpp @@ -9,6 +9,7 @@ #include "ErrorBus.h" #include "Credentials.h" #include "EnumTranslation.h" +#include "GlobalStateAggregator.h" #include @@ -19,10 +20,14 @@ intptr_t SIPAccount::runningMessageIndex = 0; SIPAccount::SIPAccount(const QString &group, QObject *parent) : QObject(parent), Account(), m_account(group) { + connect(this, &SIPAccount::isRegisteredChanged, this, + &SIPAccount::updatePresenceStateForwarding); } void SIPAccount::initialize() { + m_accountConfig.presConfig.publishEnabled = true; + bool ok = false; static QRegularExpression sipURI = QRegularExpression("^(sips?):([^@]+)(?:@(.+))?$"); @@ -1037,3 +1042,74 @@ SIPAccount::~SIPAccount() qDeleteAll(m_calls); m_calls.clear(); } + +void SIPAccount::updatePresenceStateForwarding() +{ + if (isRegistered() && !m_globalStateConnectionContext) { + // Establish + m_globalStateConnectionContext = new QObject(this); + auto &glob = GlobalStateAggregator::instance(); + + connect(&glob, &GlobalStateAggregator::presenceStateChanged, m_globalStateConnectionContext, + [this]() { forwardPresenceState(); }); + connect(&glob, &GlobalStateAggregator::statusTextChanged, m_globalStateConnectionContext, + [this]() { forwardPresenceState(); }); + forwardPresenceState(); + + } else if (!isRegistered() && m_globalStateConnectionContext) { + // Disconnect + m_globalStateConnectionContext->deleteLater(); + m_globalStateConnectionContext = nullptr; + } +} + +void SIPAccount::forwardPresenceState() +{ + if (!isRegistered()) { + return; + } + + setOnlineStatus(createPresenceStatusFromGlobal()); +} + +pj::PresenceStatus SIPAccount::createPresenceStatusFromGlobal() const +{ + auto &glob = GlobalStateAggregator::instance(); + + pj::PresenceStatus pjStatus; + pjStatus.note = glob.statusText().toStdString(); + + switch (glob.presenceState()) { + + case PresenceState::State::Unknown: + pjStatus.status = PJSUA_BUDDY_STATUS_UNKNOWN; + pjStatus.activity = PJRPID_ACTIVITY_UNKNOWN; + break; + + case PresenceState::State::Offline: + pjStatus.status = PJSUA_BUDDY_STATUS_OFFLINE; + pjStatus.activity = PJRPID_ACTIVITY_UNKNOWN; + break; + case PresenceState::State::Away: + pjStatus.status = PJSUA_BUDDY_STATUS_ONLINE; + pjStatus.activity = PJRPID_ACTIVITY_AWAY; + break; + + case PresenceState::State::Busy: + pjStatus.status = PJSUA_BUDDY_STATUS_ONLINE; + pjStatus.activity = PJRPID_ACTIVITY_BUSY; + break; + + case PresenceState::State::Available: + pjStatus.status = PJSUA_BUDDY_STATUS_ONLINE; + pjStatus.activity = PJRPID_ACTIVITY_UNKNOWN; + break; + + case PresenceState::State::Ringing: + pjStatus.status = PJSUA_BUDDY_STATUS_ONLINE; + pjStatus.activity = PJRPID_ACTIVITY_UNKNOWN; + break; + } + + return pjStatus; +} diff --git a/src/sip/SIPAccount.h b/src/sip/SIPAccount.h index 3f2ad1e9..67f40226 100644 --- a/src/sip/SIPAccount.h +++ b/src/sip/SIPAccount.h @@ -84,6 +84,10 @@ class SIPAccount : public QObject, public pj::Account ~SIPAccount(); +private Q_SLOTS: + void updatePresenceStateForwarding(); + void forwardPresenceState(); + private: void finalizeInitialization(); @@ -98,6 +102,8 @@ class SIPAccount : public QObject, public pj::Account void reinitBuddies(); + pj::PresenceStatus createPresenceStatusFromGlobal() const; + QList m_calls; QList m_buddies; QList m_transportIds; @@ -114,6 +120,7 @@ class SIPAccount : public QObject, public pj::Account bool m_useInstantMessagingWithoutCheck = true; bool m_rttEnabled = true; bool m_afterResume = false; + QObject *m_globalStateConnectionContext = nullptr; QString m_account; QString m_domain; diff --git a/src/ui/GonnectWindow.qml b/src/ui/GonnectWindow.qml index 26491dd5..61dc53ec 100644 --- a/src/ui/GonnectWindow.qml +++ b/src/ui/GonnectWindow.qml @@ -447,6 +447,9 @@ BaseWindow { control.ensureVisible() control.updateTabSelection(control.conferencePageId, GonnectWindow.PageType.Conference) } + function onShowStatusTextEditDialog() { + drawerStackView.push("qrc:/qt/qml/base/ui/components/popups/EditStatusText.qml") + } function onFullscreenToggle() { if (control.visibility === Window.FullScreen) { control.showNormal() diff --git a/src/ui/PersonCoinProvider.cpp b/src/ui/PersonCoinProvider.cpp new file mode 100644 index 00000000..30c5996d --- /dev/null +++ b/src/ui/PersonCoinProvider.cpp @@ -0,0 +1,78 @@ +#include "PersonCoinProvider.h" +#include "Theme.h" +#include +#include +#include + +PersonCoinProvider::PersonCoinProvider() : QQuickImageProvider{ QQuickImageProvider::Image } { } + +QImage PersonCoinProvider::requestImage(const QString &id, QSize *size, const QSize &requestedSize) +{ + if (id.isEmpty()) { + size->setWidth(0); + size->setHeight(0); + return QImage(); + } + + // Determine size + int w = 100, h = 100; + + if (requestedSize.isValid()) { + w = requestedSize.width(); + h = requestedSize.height(); + + if (w != h) { + const int minValue = std::min(w, h); + w = minValue; + h = minValue; + } + } + + size->setWidth(w); + size->setHeight(h); + + const auto path = makePath(id, w); + if (QFileInfo::exists(path)) { + return QImage(path); + + } else { + + // Draw image + QImage image(*size, QImage::Format_ARGB32); + QPainter p(&image); + Theme &theme = Theme::instance(); + + p.setRenderHint(QPainter::Antialiasing); + p.setBrush(theme.backgroundInitials()); + p.setPen(Qt::NoPen); + p.drawEllipse(0, 0, w, h); + + // Initials + Q_UNUSED(id); + QFont font("Noto Sans"); + font.setPixelSize(0.4 * h); + p.setFont(font); + p.setPen(theme.foregroundInitials()); + p.drawText(image.rect(), Qt::AlignCenter, id); + + QFileInfo info(path); + QDir dir; + dir.mkpath(info.path()); + image.save(path); + + return image; + } +} + +QString PersonCoinProvider::makePath(const QString &id, const int size) const +{ + static QString basePath; + if (basePath.isEmpty()) { + basePath = QString("%1/coin").arg( + QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)); + QDir dir; + dir.mkpath(basePath); + } + + return QString("%1/%2/%3.png").arg(basePath).arg(size).arg(id); +} diff --git a/src/ui/PersonCoinProvider.h b/src/ui/PersonCoinProvider.h new file mode 100644 index 00000000..fab216d7 --- /dev/null +++ b/src/ui/PersonCoinProvider.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +class PersonCoinProvider : public QQuickImageProvider +{ +public: + PersonCoinProvider(); + + virtual QImage requestImage(const QString &id, QSize *size, + const QSize &requestedSize) override; + + QString makePath(const QString &id, const int size) const; +}; diff --git a/src/ui/Theme.cpp b/src/ui/Theme.cpp index e37ce5ed..c8fa029c 100644 --- a/src/ui/Theme.cpp +++ b/src/ui/Theme.cpp @@ -127,6 +127,7 @@ void Theme::updateColorPalette() m_backgroundInitials = QColor(214, 212, 233); m_shadowColor = QColor(0, 0, 0, 32); m_redColor = QColor(224, 27, 36); + m_yellowColor = QColor(217, 176, 114); m_emergencyColor = QColor(0, 136, 85); m_greenColor = QColor(36, 181, 27); m_darkGreenColor = QColor(128, 128, 0); diff --git a/src/ui/Theme.h b/src/ui/Theme.h index 011409af..3ac22328 100644 --- a/src/ui/Theme.h +++ b/src/ui/Theme.h @@ -52,6 +52,7 @@ class Theme : public QObject Q_PROPERTY( QColor activeIndicatorColor READ activeIndicatorColor NOTIFY colorPaletteChanged FINAL) Q_PROPERTY(QColor greenColor READ greenColor NOTIFY colorPaletteChanged FINAL) + Q_PROPERTY(QColor yellowColor READ yellowColor NOTIFY colorPaletteChanged FINAL) Q_PROPERTY(QColor darkGreenColor READ darkGreenColor NOTIFY colorPaletteChanged FINAL) Q_PROPERTY(QColor paneColor READ paneColor NOTIFY colorPaletteChanged FINAL) Q_PROPERTY(QColor highContrastColor READ highContrastColor NOTIFY colorPaletteChanged FINAL) @@ -136,6 +137,7 @@ class Theme : public QObject QColor backgroundInitials() const { return m_backgroundInitials; } QColor shadowColor() const { return m_shadowColor; } QColor redColor() const { return m_redColor; } + QColor yellowColor() const { return m_yellowColor; } QColor emergencyColor() const { return m_emergencyColor; } QColor activeIndicatorColor() const { return m_activeIndicatorColor; } QColor greenColor() const { return m_greenColor; } @@ -245,6 +247,7 @@ private Q_SLOTS: QColor m_backgroundInitials; QColor m_shadowColor; QColor m_redColor; + QColor m_yellowColor; QColor m_emergencyColor; QColor m_greenColor; QColor m_darkGreenColor; diff --git a/src/ui/ViewHelper.h b/src/ui/ViewHelper.h index 250e0a65..b7e7db53 100644 --- a/src/ui/ViewHelper.h +++ b/src/ui/ViewHelper.h @@ -180,6 +180,7 @@ private Q_SLOTS: void showDialPad(); void showFirstAid(); void showQuitConfirm(); + void showStatusTextEditDialog(); void showEmergency(QString accountId, int callId, QString displayName); void hideEmergency(); void showConferenceChat(); diff --git a/src/ui/components/AvatarImage.qml b/src/ui/components/AvatarImage.qml index eadffbd6..25eb1a99 100644 --- a/src/ui/components/AvatarImage.qml +++ b/src/ui/components/AvatarImage.qml @@ -12,54 +12,31 @@ Item { implicitWidth: control.size implicitHeight: control.size - property alias initials: initialsLabel.text - property alias source: img.source + property string initials + property url source property int size: 24 - property alias showBuddyStatus: buddyStatusIndicatorContainer.visible - property alias buddyStatus: buddyStatusIndicator.status - property alias isBlocked: buddyStatusIndicator.isBlocked - property alias isUnregistered: buddyStatusIndicator.isUnregistered - - states: [ - State { - when: control.source.toString() !== "" // toString() is necessary, see QTBUG-63629 - PropertyChanges { - initialBackground.visible: false - initialsLabel.visible: false - opacityMask.visible: true - } - } - ] + property alias showPresenceStatus: statusIndicatorContainer.visible + property int presenceStatus + property bool isBlocked + property bool isUnregistered + property alias indicatorComponent: statusIndicatorLoader.sourceComponent - Rectangle { - id: initialBackground - anchors.fill: parent - color: Theme.backgroundInitials - radius: initialBackground.width / 2 - - Accessible.ignored: true - } - - Label { - id: initialsLabel - anchors.centerIn: parent - font.pixelSize: 0.4 * control.size - color: Theme.foregroundInitials - - Accessible.role: Accessible.StaticText - Accessible.name: initialsLabel.text - Accessible.description: qsTr("Initials of this contact") - } + readonly property bool hasSource: control.source.toString() !== "" // toString() is necessary, see QTBUG-63629 Image { id: img - cache: false + cache: true visible: false anchors.fill: parent - fillMode: Image.PreserveAspectFit + fillMode: Image.PreserveAspectCrop sourceSize.width: control.size sourceSize.height: control.size + source: control.hasSource + ? control.source + : (control.initials + ? `image://personcoin/${control.initials}` + : "") } Rectangle { @@ -74,26 +51,48 @@ Item { OpacityMask { id: opacityMask - visible: false anchors.fill: parent source: img maskSource: mask } Rectangle { - id: buddyStatusIndicatorContainer + id: statusIndicatorContainer color: Theme.backgroundColor - x: control.width / 2 + Math.sqrt(((control.width / 2) * (control.width / 2)) / 2) - buddyStatusIndicatorContainer.width / 2 - y: buddyStatusIndicatorContainer.x - width: 10 - height: buddyStatusIndicatorContainer.width - radius: buddyStatusIndicatorContainer.width / 2 + x: control.width / 2 + Math.sqrt(((control.width / 2) * (control.width / 2)) / 2) - statusIndicatorContainer.width / 2 + y: statusIndicatorContainer.x + width: 8/24 * control.size + height: statusIndicatorContainer.width + radius: statusIndicatorContainer.width / 2 visible: false - BuddyStatusIndicator { - id: buddyStatusIndicator + Loader { + id: statusIndicatorLoader width: parent.width - 2 + height: statusIndicatorLoader.width + active: control.showPresenceStatus anchors.centerIn: parent + sourceComponent: Component { + PresenceStatusIndicator {} + } + + onItemChanged: () => { + const item = statusIndicatorLoader.item + + if (item) { + if (item.hasOwnProperty("status")) { + item.status = Qt.binding(() => control.presenceStatus) + } + + if (item.hasOwnProperty("isBlocked")) { + item.isBlocked = Qt.binding(() => control.isBlocked) + } + + if (item.hasOwnProperty("isUnregistered")) { + item.isUnregistered = Qt.binding(() => control.isUnregistered) + } + } + } } Accessible.ignored: true diff --git a/src/ui/components/CustomWindowHeader.qml b/src/ui/components/CustomWindowHeader.qml index 603913e0..933ac2ce 100644 --- a/src/ui/components/CustomWindowHeader.qml +++ b/src/ui/components/CustomWindowHeader.qml @@ -235,30 +235,10 @@ Rectangle { size: 28 initials: ViewHelper.initials(ViewHelper.currentUserName) source: ViewHelper.currentUser?.hasAvatar ? ("file://" + ViewHelper.currentUser.avatarPath) : "" - showBuddyStatus: avatarImage.hasBuddyState || avatarImage.isUnregistered - buddyStatus: SIPBuddyState.UNKNOWN + showPresenceStatus: true + presenceStatus: GlobalStateAggregator.presenceState isUnregistered: true - property bool hasBuddyState: ViewHelper.currentUser?.hasBuddyState ?? false - - Component.onCompleted: () => { - avatarImage.updateBuddyStatus() - } - - function updateBuddyStatus() { - avatarImage.buddyStatus = ViewHelper.currentUser?.hasBuddyState - ? SIPManager.buddyStatus(ViewHelper.currentUser.subscriptableNumber) - : SIPBuddyState.UNKNOWN - } - - Connections { - target: SIPManager - enabled: ViewHelper.currentUser?.hasBuddyState ?? false - function onBuddyStateChanged(url : string, status : int) { - avatarImage.updateBuddyStatus() - } - } - Connections { target: SIPAccountManager function onSipRegisteredChanged(status : bool) { @@ -269,6 +249,24 @@ Rectangle { } } } + + TapHandler { + gesturePolicy: TapHandler.WithinBounds + grabPermissions: PointerHandler.ApprovesTakeOverByAnything + exclusiveSignals: TapHandler.SingleTap + acceptedButtons: Qt.LeftButton | Qt.RightButton + onTapped: () => { + ownAvatarContextMenuComponent.createObject(avatarImage).popup() + } + } + + Component { + id: ownAvatarContextMenuComponent + + OwnAvatarContextMenu { + id: avatarContextMenu + } + } } HeaderIconButton { diff --git a/src/ui/components/HistoryList.qml b/src/ui/components/HistoryList.qml index 31e79f54..bb854c63 100644 --- a/src/ui/components/HistoryList.qml +++ b/src/ui/components/HistoryList.qml @@ -156,10 +156,11 @@ Item { initials: ViewHelper.initials(delg.contactName) source: delg.hasAvatar ? ("file://" + delg.avatarPath) : "" visible: delg.hasAvatar || delg.name !== "" - showBuddyStatus: delg.hasBuddyState || delg.isBlocked - buddyStatus: delg.buddyStatus + showPresenceStatus: delg.hasBuddyState || delg.isBlocked + presenceStatus: delg.buddyStatus isBlocked: delg.isBlocked size: 40 + indicatorComponent: Component { BuddyStatusIndicator {} } Layout.preferredWidth: 40 Layout.preferredHeight: 40 diff --git a/src/ui/components/PresenceStatusIndicator.qml b/src/ui/components/PresenceStatusIndicator.qml new file mode 100644 index 00000000..6a886f90 --- /dev/null +++ b/src/ui/components/PresenceStatusIndicator.qml @@ -0,0 +1,124 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls.impl +import base + +/// Colored circle showing a presence status +Rectangle { + id: control + + width: 10 + height: control.width + radius: control.width / 2 + color: 'transparent' + border.width: 0 + border.color: Theme.borderColor + + /// The status to show (as in PresenceState) + property int status: PresenceState.Unknown + + property bool isBlocked: false + property bool isUnregistered: false + + Accessible.ignored: true + + SequentialAnimation { + id: ringingAnimation + running: false + loops: Animation.Infinite + + ColorAnimation { + target: control + property: "color" + from: 'transparent' + to: Theme.greenColor + duration: 1000 + } + ColorAnimation { + target: control + property: "color" + from: Theme.greenColor + to: 'transparent' + duration: 1000 + } + } + + IconLabel { + id: blockedIcon + visible: false + height: 10 + width: 10 + anchors.centerIn: parent + icon { + width: 10 + height: 10 + source: Icons.dialogCancel + } + + Accessible.ignored: true + } + + states: [ + State { + when: control.isUnregistered + + PropertyChanges { + control.color: Theme.borderColor + } + }, + State { + when: control.isBlocked + + PropertyChanges { + control.width: 12 + control.height: 12 + control.color: Theme.backgroundColor + blockedIcon.visible: true + } + }, + State { + when: control.status === PresenceState.Unknown + + PropertyChanges { + control.border.width: 1 + } + }, + State { + when: control.status === PresenceState.Offline + + PropertyChanges { + control.color: Theme.borderColor + } + }, + State { + when: control.status === PresenceState.Available + + PropertyChanges { + control.color: Theme.greenColor + } + }, + State { + when: control.status === PresenceState.Ringing + + PropertyChanges { + ringingAnimation.running: true + } + + }, + State { + when: control.status === PresenceState.Away + + PropertyChanges { + control.color: Theme.yellowColor + } + }, + State { + when: control.status === PresenceState.Busy + + PropertyChanges { + control.color: Theme.redColor + } + } + ] +} diff --git a/src/ui/components/controls/ControlBar.qml b/src/ui/components/controls/ControlBar.qml index 53b802e6..5a6c36f2 100644 --- a/src/ui/components/controls/ControlBar.qml +++ b/src/ui/components/controls/ControlBar.qml @@ -98,30 +98,10 @@ Item { size: 28 initials: ViewHelper.initials(ViewHelper.currentUserName) source: ViewHelper.currentUser?.hasAvatar ? ("file://" + ViewHelper.currentUser.avatarPath) : "" - showBuddyStatus: avatarImage.hasBuddyState || avatarImage.isUnregistered - buddyStatus: SIPBuddyState.UNKNOWN + showPresenceStatus: true + presenceStatus: GlobalStateAggregator.presenceState isUnregistered: true - property bool hasBuddyState: ViewHelper.currentUser?.hasBuddyState ?? false - - Component.onCompleted: () => { - avatarImage.updateBuddyStatus() - } - - function updateBuddyStatus() { - avatarImage.buddyStatus = ViewHelper.currentUser?.hasBuddyState - ? SIPManager.buddyStatus(ViewHelper.currentUser.subscriptableNumber) - : SIPBuddyState.UNKNOWN - } - - Connections { - target: SIPManager - enabled: ViewHelper.currentUser?.hasBuddyState ?? false - function onBuddyStateChanged(url : string, status : int) { - avatarImage.updateBuddyStatus() - } - } - Connections { target: SIPAccountManager function onSipRegisteredChanged(status : bool) { @@ -132,6 +112,26 @@ Item { } } } + + TapHandler { + gesturePolicy: TapHandler.WithinBounds + grabPermissions: PointerHandler.ApprovesTakeOverByAnything + exclusiveSignals: TapHandler.SingleTap + acceptedButtons: Qt.LeftButton | Qt.RightButton + onTapped: () => { + ownAvatarContextMenuComponent.createObject(avatarImage).popup() + } + } + + Component { + id: ownAvatarContextMenuComponent + + OwnAvatarContextMenu { + id: avatarContextMenu + x: -avatarContextMenu.implicitWidth + } + } + } } } diff --git a/src/ui/components/controls/FavoriteListItemBig.qml b/src/ui/components/controls/FavoriteListItemBig.qml index a380e1e7..70b570d8 100644 --- a/src/ui/components/controls/FavoriteListItemBig.qml +++ b/src/ui/components/controls/FavoriteListItemBig.qml @@ -87,8 +87,9 @@ Item { size: 40 initials: ViewHelper.initials(delg.name || delg.phoneNumber) source: delg.hasAvatar ? ("file://" + delg.avatarPath) : "" - showBuddyStatus: delg.hasBuddyState - buddyStatus: delg.buddyStatus + showPresenceStatus: delg.hasBuddyState + presenceStatus: delg.buddyStatus + indicatorComponent: Component { BuddyStatusIndicator {} } anchors { left: parent.left leftMargin: 10 diff --git a/src/ui/components/controls/FavoriteListItemSmall.qml b/src/ui/components/controls/FavoriteListItemSmall.qml index 9b833bb0..2533bfe2 100644 --- a/src/ui/components/controls/FavoriteListItemSmall.qml +++ b/src/ui/components/controls/FavoriteListItemSmall.qml @@ -71,8 +71,9 @@ Item { id: avatarImage initials: ViewHelper.initials(delg.name || delg.phoneNumber) source: delg.hasAvatar ? ("file://" + delg.avatarPath) : "" - showBuddyStatus: delg.hasBuddyState - buddyStatus: delg.buddyStatus + showPresenceStatus: delg.hasBuddyState + presenceStatus: delg.buddyStatus + indicatorComponent: Component { BuddyStatusIndicator {} } anchors { left: parent.left verticalCenter: parent.verticalCenter diff --git a/src/ui/components/popups/EditStatusText.qml b/src/ui/components/popups/EditStatusText.qml new file mode 100644 index 00000000..ee273915 --- /dev/null +++ b/src/ui/components/popups/EditStatusText.qml @@ -0,0 +1,90 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls.Material +import base + +Item { + id: control + implicitWidth: 400 + implicitHeight: 260 + + Keys.onReturnPressed: () => internal.commitChanges() + Keys.onEnterPressed: () => internal.commitChanges() + + QtObject { + id: internal + + readonly property string trimmedText: contentTextField.text.trim() + readonly property bool isModified: internal.trimmedText !== control.text + + function commitChanges() { + if (saveButton.enabled && internal.isModified) { + GlobalStateAggregator.statusText = internal.trimmedText + internal.close() + } + } + + function close() { + if (control.StackView.view) { + control.StackView.view.popCurrentItem(StackView.Immediate) + } + } + } + + HeaderIconButton { + id: closeButton + iconSource: Icons.mobileCloseApp + anchors { + top: parent.top + right: parent.right + } + + onClicked: () => internal.close() + } + + TextArea { + id: contentTextField + placeholderText: qsTr("Your status message...") + text: GlobalStateAggregator.statusText + wrapMode: TextEdit.Wrap + anchors { + top: parent.top + left: parent.left + right: closeButton.left + bottom: saveButton.top + margins: 20 + } + + Timer { + id: initialFocusTimer + interval: 20 + onTriggered: () => { + contentTextField.forceActiveFocus() + contentTextField.selectAll() + } + } + + Component.onCompleted: initialFocusTimer.start() + + Keys.onPressed: keyEvent => { + if ((keyEvent.modifiers & Qt.ControlModifier) && keyEvent.key === Qt.Key_Return) { + saveButton.click() + } + } + } + + Button { + id: saveButton + text: internal.trimmedText ? qsTr("Set") : qsTr("Remove") + highlighted: true + enabled: internal.isModified + anchors { + horizontalCenter: parent.horizontalCenter + bottom: parent.bottom + bottomMargin: 20 + } + + onClicked: () => internal.commitChanges() + } +} diff --git a/src/ui/components/popups/OwnAvatarContextMenu.qml b/src/ui/components/popups/OwnAvatarContextMenu.qml new file mode 100644 index 00000000..9d62ef39 --- /dev/null +++ b/src/ui/components/popups/OwnAvatarContextMenu.qml @@ -0,0 +1,83 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls.Material +import base + +Menu { + id: control + + LoggingCategory { + id: category + name: "gonnect.qml.popup.OwnAvatarContextMenu" + defaultLogLevel: LoggingCategory.Info + } + + function setPresenceState(presenceState : int) { + GlobalStateAggregator.presenceState = presenceState + console.log(category, "User setting presence state to", presenceState) + } + + function openStatusTextEditPopup() { + ViewHelper.showStatusTextEditDialog() + } + + MenuItem { + id: availableAction + text: qsTr("Available") + icon { + source: Icons.imUserOnline + color: "transparent" + } + onTriggered: () => control.setPresenceState(PresenceState.Available) + + Accessible.role: Accessible.Button + Accessible.name: availableAction.text + Accessible.focusable: true + Accessible.onPressAction: () => control.setPresenceState(PresenceState.Available) + } + + MenuItem { + id: awayAction + text: qsTr("Away") + icon { + source: Icons.imUserAway + color: "transparent" + } + onTriggered: () => control.setPresenceState(PresenceState.Away) + + Accessible.role: Accessible.Button + Accessible.name: awayAction.text + Accessible.focusable: true + Accessible.onPressAction: () => control.setPresenceState(PresenceState.Away) + } + + MenuItem { + id: dndAction + text: qsTr("Do not disturb") + icon { + source: Icons.imUserBusy + color: "transparent" + } + onTriggered: () => control.setPresenceState(PresenceState.Busy) + + Accessible.role: Accessible.Button + Accessible.name: dndAction.text + Accessible.focusable: true + Accessible.onPressAction: () => control.setPresenceState(PresenceState.Busy) + } + + MenuSeparator { } + + MenuItem { + id: setStatusTextAction + text: qsTr("Set status text...") + icon.source: Icons.editor + onTriggered: () => control.openStatusTextEditPopup() + + Accessible.role: Accessible.Button + Accessible.name: setStatusTextAction.text + Accessible.focusable: true + Accessible.onPressAction: () => control.openStatusTextEditPopup() + } +} diff --git a/src/ui/components/popups/SearchResultPopup.qml b/src/ui/components/popups/SearchResultPopup.qml index 5c989d41..a55803a3 100644 --- a/src/ui/components/popups/SearchResultPopup.qml +++ b/src/ui/components/popups/SearchResultPopup.qml @@ -480,8 +480,9 @@ Popup { id: avatarImage initials: ViewHelper.initials(contactDelg.name) source: contactDelg.hasAvatar ? ("file://" + contactDelg.avatarPath) : "" - showBuddyStatus: contactDelg.subscriptableNumber !== "" - buddyStatus: contactDelg.buddyStatus + showPresenceStatus: contactDelg.subscriptableNumber !== "" + presenceStatus: contactDelg.buddyStatus + indicatorComponent: Component { BuddyStatusIndicator {} } } }