Compare commits

...

2 Commits

Author SHA1 Message Date
Tobias Fella
3a0916866c Work 2025-08-19 15:50:24 +02:00
Tobias Fella
0f5955ae34 Implement call ringing 2025-08-19 13:21:10 +02:00
14 changed files with 550 additions and 3 deletions

View File

@@ -24,8 +24,6 @@ add_library(neochat STATIC
models/notificationsmodel.h
proxycontroller.cpp
proxycontroller.h
mediamanager.cpp
mediamanager.h
sharehandler.cpp
sharehandler.h
foreigntypes.h
@@ -101,6 +99,7 @@ ecm_add_qml_module(neochat URI org.kde.neochat GENERATE_PLUGIN_SOURCE
qml/ReasonDialog.qml
qml/NewPollDialog.qml
qml/UserMenu.qml
qml/IncomingCallDialog.qml
DEPENDENCIES
QtCore
QtQuick

View File

@@ -18,6 +18,7 @@
#include <Quotient/settings.h>
#include "accountmanager.h"
#include "callmanager.h"
#include "enums/roomsortparameter.h"
#include "mediasizehelper.h"
#include "models/actionsmodel.h"
@@ -72,6 +73,11 @@ Controller::Controller(QObject *parent)
{
Connection::setRoomType<NeoChatRoom>();
CallManager::instance().setCallsEnabled(NeoChatConfig::calls());
connect(NeoChatConfig::self(), &NeoChatConfig::CallsChanged, this, []() {
CallManager::instance().setCallsEnabled(NeoChatConfig::calls());
});
Connection::setDirectChatEncryptionDefault(NeoChatConfig::preferUsingEncryption());
connect(NeoChatConfig::self(), &NeoChatConfig::PreferUsingEncryptionChanged, this, [] {
Connection::setDirectChatEncryptionDefault(NeoChatConfig::preferUsingEncryption());

View File

@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2025 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick
import QtQuick.Controls as QQC2
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.formcard as FormCard
import org.kde.kirigamiaddons.components as Components
import org.kde.neochat.libneochat
FormCard.FormCardPage {
id: root
title: i18nc("@title:dialog", "Incoming Call")
FormCard.FormCard {
topPadding: Kirigami.Units.largeSpacing
FormCard.AbstractFormDelegate {
contentItem: Components.Avatar {
name: CallManager.room.displayName
source: CallManager.room.avatarMediaUrl
}
}
FormCard.FormTextDelegate {
text: i18nc("@info", "%1 is calling you.", CallManager.callingMember.htmlSafeDisplayName)
}
FormCard.FormButtonDelegate {
text: i18nc("@action:button", "Accept Call")
onClicked: console.warn("unimplemented")
}
FormCard.FormButtonDelegate {
text: i18nc("@action:button", "Decline Call")
onClicked: CallManager.declineCall()
}
}
}

View File

@@ -83,6 +83,16 @@ Kirigami.ApplicationWindow {
}
}
Connections {
target: CallManager
property IncomingCallDialog dialog
function onIsRingingChanged(): void {
if (CallManager.isRinging) {
root.pageStack.pushDialogLayer(Qt.createComponent("org.kde.neochat", "IncomingCallDialog"));
}
}
}
Loader {
active: Kirigami.Settings.hasPlatformMenuBar && !Kirigami.Settings.isMobile
sourceComponent: GlobalMenu {

View File

@@ -8,6 +8,7 @@ target_sources(LibNeoChat PRIVATE
neochatroom.cpp
neochatroommember.cpp
accountmanager.cpp
callmanager.cpp
chatbarcache.cpp
chatdocumenthandler.cpp
clipboard.cpp
@@ -23,6 +24,8 @@ target_sources(LibNeoChat PRIVATE
urlhelper.cpp
utils.cpp
enums/chatbartype.h
mediamanager.cpp
mediamanager.h
enums/messagecomponenttype.h
enums/messagetype.h
enums/powerlevel.cpp
@@ -32,6 +35,7 @@ target_sources(LibNeoChat PRIVATE
enums/timelinemarkreadcondition.h
events/imagepackevent.cpp
events/pollevent.cpp
events/callmemberevent.cpp
jobs/neochatgetcommonroomsjob.cpp
models/actionsmodel.cpp
models/completionmodel.cpp
@@ -51,6 +55,9 @@ if (TARGET KF6::KIOWidgets)
target_compile_definitions(LibNeoChat PUBLIC -DHAVE_KIO)
endif()
qt_add_dbus_interface(MediaPlayer_SRCS org.mpris.MediaPlayer2.Player.xml mediaplayer2player)
target_sources(LibNeoChat PRIVATE ${MediaPlayer_SRCS})
ecm_add_qml_module(LibNeoChat GENERATE_PLUGIN_SOURCE
URI org.kde.neochat.libneochat
OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/src/org/kde/neochat/libneochat

View File

@@ -0,0 +1,166 @@
// SPDX-FileCopyrightText: 2025 Tobias Fella <tobias.fella@kde.org
// SPDX-License-Identifier: GPL-2.0-or-later
#include "callmanager.h"
#include "events/callmemberevent.h"
#include "mediamanager.h"
#include "mediaplayer2player.h"
#include "neochatroom.h"
#include <QAudioOutput>
#include <QDBusConnection>
#include <QMediaPlayer>
#include <QMimeDatabase>
#include <QTimer>
#include <Quotient/qt_connection_util.h>
using namespace Quotient;
using namespace Qt::Literals::StringLiterals;
void CallManager::ring(const QJsonObject &json, NeoChatRoom *room)
{
if (!m_callsEnabled) {
return;
}
// TODO: check sender != us
// Consider multiple accounts being logged in
if (json["content"_L1]["application"_L1].toString() != "m.call"_L1) {
return;
}
if (!json["content"_L1]["m.mentions"_L1]["room"_L1].toBool() || json["sender"_L1].toString() == room->connection()->userId()) {
if (std::ranges::none_of(json["content"_L1]["m.mentions"_L1]["user_ids"_L1].toArray(), [room](const auto &user) {
return user.toString() == room->connection()->userId();
})) {
return;
}
}
if (json["content"_L1]["notify_type"_L1].toString() != "ring"_L1) {
return;
}
if (room->pushNotificationState() == PushNotificationState::Mute) {
return;
}
if (isRinging()) {
return;
}
if (const auto &event = room->currentState().get<CallMemberEvent>(room->connection()->userId())) {
if (event) {
auto memberships = event->contentJson()["memberships"_L1].toArray();
for (const auto &m : memberships) {
const auto &membership = m.toObject();
if (membership["application"_L1] == "m.call"_L1 && membership["call_id"_L1].toString().isEmpty()) {
qWarning() << "already in a call";
return;
}
}
}
}
connectUntil(room, &NeoChatRoom::changed, this, [this, room]() {
if (const auto &event = room->currentState().get<CallMemberEvent>(room->connection()->userId())) {
auto memberships = event->contentJson()["memberships"_L1].toArray();
for (const auto &m : memberships) {
const auto &membership = m.toObject();
if (membership["application"_L1] == "m.call"_L1 && membership["call_id"_L1].toString().isEmpty()) {
stopRinging();
return true;
}
}
}
return false;
});
if (json["unsigned"_L1]["age"_L1].toInt() > 10000) {
return;
}
m_room = room;
m_callingMember = json["sender"_L1].toString();
Q_EMIT roomChanged();
QTimer::singleShot(60000, this, &CallManager::stopRinging);
ringUnchecked();
}
void CallManager::ringUnchecked()
{
MediaManager::instance().startPlayback();
// Pause all media players registered with the system
for (const auto &iface : QDBusConnection::sessionBus().interface()->registeredServiceNames().value()) {
if (iface.startsWith("org.mpris.MediaPlayer2"_L1)) {
OrgMprisMediaPlayer2PlayerInterface mprisInterface(iface, "/org/mpris/MediaPlayer2"_L1, QDBusConnection::sessionBus());
QString status = mprisInterface.playbackStatus();
if (status == "Playing"_L1) {
if (mprisInterface.canPause()) {
mprisInterface.Pause();
} else {
mprisInterface.Stop();
}
}
}
}
static QString path;
if (path.isEmpty()) {
for (const auto &dir : QString::fromUtf8(qgetenv("XDG_DATA_DIRS")).split(u':')) {
if (QFileInfo(dir + QStringLiteral("/sounds/freedesktop/stereo/phone-incoming-call.oga")).exists()) {
path = dir + QStringLiteral("/sounds/freedesktop/stereo/phone-incoming-call.oga");
break;
}
}
}
if (path.isEmpty()) {
return;
}
m_player->setSource(QUrl::fromLocalFile(path));
m_player->play();
m_ringing = true;
Q_EMIT isRingingChanged();
}
bool CallManager::isRinging() const
{
return m_ringing;
}
void CallManager::stopRinging()
{
m_ringing = false;
m_player->pause();
m_timer.stop();
Q_EMIT isRingingChanged();
}
void CallManager::setCallsEnabled(bool enabled)
{
m_callsEnabled = enabled;
}
CallManager::CallManager()
: QObject(nullptr)
, m_player(new QMediaPlayer())
, m_output(new QAudioOutput())
{
m_player->setAudioOutput(m_output);
m_timer.setInterval(1000);
m_timer.setSingleShot(true);
connect(&m_timer, &QTimer::timeout, this, [this]() {
m_player->play();
});
connect(m_player, &QMediaPlayer::playbackStateChanged, this, [this]() {
if (m_player->playbackState() == QMediaPlayer::StoppedState) {
m_timer.start();
}
});
}
NeoChatRoom *CallManager::room() const
{
return m_room.get();
}
NeochatRoomMember *CallManager::callingMember() const
{
return m_room->qmlSafeMember(m_callingMember);
}

View File

@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: 2025 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#pragma once
#include <QObject>
#include <QQmlEngine>
#include <QTimer>
#include "neochatroom.h"
#include "neochatroommember.h"
class QAudioOutput;
class QMediaPlayer;
/**
* @class CallManager
*
* Manages calls.
*/
class CallManager : public QObject
{
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(bool isRinging READ isRinging NOTIFY isRingingChanged)
Q_PROPERTY(NeoChatRoom *room READ room NOTIFY roomChanged)
Q_PROPERTY(NeochatRoomMember *callingMember READ callingMember NOTIFY roomChanged)
public:
static CallManager &instance()
{
static CallManager _instance;
return _instance;
}
static CallManager *create(QQmlEngine *, QJSEngine *)
{
QQmlEngine::setObjectOwnership(&instance(), QQmlEngine::CppOwnership);
return &instance();
}
void ring(const QJsonObject &json, NeoChatRoom *room);
void stopRinging();
bool isRinging() const;
void setCallsEnabled(bool enabled);
NeoChatRoom *room() const;
NeochatRoomMember *callingMember() const;
Q_SIGNALS:
void isRingingChanged();
void roomChanged();
private:
CallManager();
void ringUnchecked();
bool m_ringing = false;
QMediaPlayer *m_player;
QAudioOutput *m_output;
QTimer m_timer;
bool m_callsEnabled = false;
QPointer<NeoChatRoom> m_room;
QString m_callingMember;
};

View File

@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2024 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "callmemberevent.h"
#include <QString>
using namespace Quotient;
using namespace Qt::Literals::StringLiterals;
CallMemberEventContent::CallMemberEventContent(const QJsonObject &json)
{
for (const auto &membership : json["memberships"_L1].toArray()) {
QList<Focus> foci;
for (const auto &focus : membership["foci_active"_L1].toArray()) {
foci.append(Focus{
.livekitAlias = focus["livekit_alias"_L1].toString(),
.livekitServiceUrl = focus["livekit_service_url"_L1].toString(),
.type = focus["livekit"_L1].toString(),
});
}
memberships.append(CallMembership{
.application = membership["application"_L1].toString(),
.callId = membership["call_id"_L1].toString(),
.deviceId = membership["device_id"_L1].toString(),
.expires = membership["expires"_L1].toInt(),
.expiresTs = membership["expires"_L1].toVariant().value<uint64_t>(),
.fociActive = foci,
.membershipId = membership["membershipID"_L1].toString(),
.scope = membership["scope"_L1].toString(),
});
}
}
QJsonObject CallMemberEventContent::toJson() const
{
return {};
}

View File

@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: 2024 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once
#include <Quotient/events/stateevent.h>
namespace Quotient
{
struct Focus {
QString livekitAlias;
QString livekitServiceUrl;
QString type;
};
struct CallMembership {
QString application;
QString callId;
QString deviceId;
int expires;
uint64_t expiresTs;
QList<Focus> fociActive;
QString membershipId;
QString scope;
};
class CallMemberEventContent
{
public:
explicit CallMemberEventContent(const QJsonObject &json);
QJsonObject toJson() const;
QList<CallMembership> memberships;
};
/**
* @class CallMemberEvent
*
* Class to define a call member event.
*
* @sa Quotient::StateEvent
*/
class CallMemberEvent : public KeyedStateEventBase<CallMemberEvent, CallMemberEventContent>
{
public:
QUO_EVENT(CallMemberEvent, "org.matrix.msc3401.call.member")
explicit CallMemberEvent(const QJsonObject &obj)
: KeyedStateEventBase(obj)
{
}
QJsonArray memberships() const
{
return contentJson()["memberships"_L1].toArray();
}
};
}

View File

@@ -0,0 +1,21 @@
// SPDX-FileCopyrightText: 2024 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once
#include <Quotient/events/roomevent.h>
namespace Quotient
{
class CallNotifyEvent : public RoomEvent
{
public:
QUO_EVENT(CallNotifyEvent, "org.matrix.msc4075.call.notify");
explicit CallNotifyEvent(const QJsonObject &obj)
: RoomEvent(obj)
{
}
};
}

View File

@@ -1,11 +1,18 @@
// SPDX-FileCopyrightText: 2023 Tobias Fella <tobias.fella@kde.org>
// SPDX-FileCopyrightText: 2023-2025 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later
#include "mediamanager.h"
#include <Quotient/qt_connection_util.h>
void MediaManager::startPlayback()
{
Q_EMIT playbackStarted();
}
MediaManager::MediaManager()
: QObject(nullptr)
{
}
#include "moc_mediamanager.cpp"

View File

@@ -39,4 +39,7 @@ Q_SIGNALS:
* @brief Emitted when any media player starts playing. Other objects should stop / pause playback.
*/
void playbackStarted();
private:
MediaManager();
};

View File

@@ -44,8 +44,10 @@
#include <Quotient/thread.h>
#endif
#include "callmanager.h"
#include "chatbarcache.h"
#include "clipboard.h"
#include "events/callnotifyevent.h"
#include "events/pollevent.h"
#include "filetransferpseudojob.h"
#include "neochatconnection.h"
@@ -117,6 +119,14 @@ NeoChatRoom::NeoChatRoom(Connection *connection, QString roomId, JoinState joinS
}
connect(this, &Room::addedMessages, this, &NeoChatRoom::cacheLastEvent);
connect(this, &NeoChatRoom::aboutToAddNewMessages, this, [this](const auto &events) {
for (const auto &event : events) {
if (event->template is<CallNotifyEvent>()) {
CallManager::instance().ring(event->fullJson(), this);
}
}
});
connect(this, &Quotient::Room::eventsHistoryJobChanged, this, &NeoChatRoom::lastActiveTimeChanged);
connect(this, &Room::joinStateChanged, this, [this](JoinState oldState, JoinState newState) {

View File

@@ -0,0 +1,114 @@
<?xml version="1.0" ?>
<!--
SPDX-FileCopyrightText: 2006-2012 the VideoLAN team (Mirsal Ennaime, Rafaël Carré, Jean-Paul Saman)
SPDX-FileCopyrightText: 2005-2008 Milosz Derezynski
SPDX-FileCopyrightText: 2008 Nick Welch
SPDX-FileCopyrightText: 2010-2012 Alex Merry
SPDX-License-Identifier: LGPL-2.1-or-later
-->
<node xmlns:tp="http://telepathy.freedesktop.org/wiki/DbusSpec#extensions-v0">
<interface name="org.mpris.MediaPlayer2.Player">
<method name="Next" tp:name-for-bindings="Next">
</method>
<method name="Previous" tp:name-for-bindings="Previous">
</method>
<method name="Pause" tp:name-for-bindings="Pause">
</method>
<method name="PlayPause" tp:name-for-bindings="PlayPause">
</method>
<method name="Stop" tp:name-for-bindings="Stop">
</method>
<method name="Play" tp:name-for-bindings="Play">
</method>
<method name="Seek" tp:name-for-bindings="Seek">
<arg direction="in" type="x" name="Offset" tp:type="Time_In_Us">
</arg>
</method>
<method name="SetPosition" tp:name-for-bindings="Set_Position">
<arg direction="in" type="o" tp:type="Track_Id" name="TrackId">
</arg>
<arg direction="in" type="x" tp:type="Time_In_Us" name="Position">
</arg>
</method>
<method name="OpenUri" tp:name-for-bindings="Open_Uri">
<arg direction="in" type="s" tp:type="Uri" name="Uri">
</arg>
</method>
<property name="PlaybackStatus" tp:name-for-bindings="Playback_Status" type="s" tp:type="Playback_Status" access="read">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
</property>
<property name="LoopStatus" type="s" access="readwrite"
tp:name-for-bindings="Loop_Status" tp:type="Loop_Status">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
</property>
<property name="Rate" tp:name-for-bindings="Rate" type="d" tp:type="Playback_Rate" access="readwrite">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
</property>
<property name="Shuffle" tp:name-for-bindings="Shuffle" type="b" access="readwrite">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
</property>
<property name="Metadata" tp:name-for-bindings="Metadata" type="a{sv}" tp:type="Metadata_Map" access="read">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
<annotation name="org.qtproject.QtDBus.QtTypeName" value="QVariantMap"/>
</property>
<property name="Volume" type="d" tp:type="Volume" tp:name-for-bindings="Volume" access="readwrite">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true" />
</property>
<property name="Position" type="x" tp:type="Time_In_Us" tp:name-for-bindings="Position" access="read">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="false"/>
</property>
<property name="MinimumRate" tp:name-for-bindings="Minimum_Rate" type="d" tp:type="Playback_Rate" access="read">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
</property>
<property name="MaximumRate" tp:name-for-bindings="Maximum_Rate" type="d" tp:type="Playback_Rate" access="read">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
</property>
<property name="CanGoNext" tp:name-for-bindings="Can_Go_Next" type="b" access="read">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
</property>
<property name="CanGoPrevious" tp:name-for-bindings="Can_Go_Previous" type="b" access="read">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
</property>
<property name="CanPlay" tp:name-for-bindings="Can_Play" type="b" access="read">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
</property>
<property name="CanPause" tp:name-for-bindings="Can_Pause" type="b" access="read">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
</property>
<property name="CanSeek" tp:name-for-bindings="Can_Seek" type="b" access="read">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="true"/>
</property>
<property name="CanControl" tp:name-for-bindings="Can_Control" type="b" access="read">
<annotation name="org.freedesktop.DBus.Property.EmitsChangedSignal" value="false"/>
</property>
<signal name="Seeked" tp:name-for-bindings="Seeked">
<arg name="Position" type="x" tp:type="Time_In_Us">
</arg>
</signal>
</interface>
</node>