mirror of
https://github.com/Nheko-Reborn/nheko.git
synced 2024-10-30 17:40:47 +03:00
Merge pull request #335 from Nheko-Reborn/qml-text-input
Qml text input
This commit is contained in:
commit
54d75466c7
55 changed files with 1801 additions and 10153 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -57,6 +57,7 @@ ui_*.h
|
|||
# Vim
|
||||
*.swp
|
||||
*.swo
|
||||
*.swn
|
||||
|
||||
#####=== CMake ===#####
|
||||
|
||||
|
|
|
@ -241,17 +241,13 @@ set(SRC_FILES
|
|||
src/dialogs/RoomSettings.cpp
|
||||
|
||||
# Emoji
|
||||
src/emoji/Category.cpp
|
||||
src/emoji/EmojiModel.cpp
|
||||
src/emoji/ItemDelegate.cpp
|
||||
src/emoji/Panel.cpp
|
||||
src/emoji/PickButton.cpp
|
||||
src/emoji/Provider.cpp
|
||||
src/emoji/Provider_new.cpp
|
||||
|
||||
|
||||
# Timeline
|
||||
src/timeline/EventStore.cpp
|
||||
src/timeline/InputBar.cpp
|
||||
src/timeline/Reaction.cpp
|
||||
src/timeline/TimelineViewManager.cpp
|
||||
src/timeline/TimelineModel.cpp
|
||||
|
@ -261,22 +257,23 @@ set(SRC_FILES
|
|||
src/ui/Avatar.cpp
|
||||
src/ui/Badge.cpp
|
||||
src/ui/DropShadow.cpp
|
||||
src/ui/LoadingIndicator.cpp
|
||||
src/ui/InfoMessage.cpp
|
||||
src/ui/FlatButton.cpp
|
||||
src/ui/FloatingButton.cpp
|
||||
src/ui/InfoMessage.cpp
|
||||
src/ui/Label.cpp
|
||||
src/ui/LoadingIndicator.cpp
|
||||
src/ui/NhekoDropArea.cpp
|
||||
src/ui/OverlayModal.cpp
|
||||
src/ui/SnackBar.cpp
|
||||
src/ui/OverlayWidget.cpp
|
||||
src/ui/RaisedButton.cpp
|
||||
src/ui/Ripple.cpp
|
||||
src/ui/RippleOverlay.cpp
|
||||
src/ui/OverlayWidget.cpp
|
||||
src/ui/SnackBar.cpp
|
||||
src/ui/TextField.cpp
|
||||
src/ui/TextLabel.cpp
|
||||
src/ui/ToggleButton.cpp
|
||||
src/ui/Theme.cpp
|
||||
src/ui/ThemeManager.cpp
|
||||
src/ui/ToggleButton.cpp
|
||||
src/ui/UserProfile.cpp
|
||||
|
||||
src/AvatarProvider.cpp
|
||||
|
@ -287,6 +284,7 @@ set(SRC_FILES
|
|||
src/ColorImageProvider.cpp
|
||||
src/CommunitiesList.cpp
|
||||
src/CommunitiesListItem.cpp
|
||||
src/CompletionProxyModel.cpp
|
||||
src/DeviceVerificationFlow.cpp
|
||||
src/EventAccessors.cpp
|
||||
src/InviteeItem.cpp
|
||||
|
@ -303,10 +301,10 @@ set(SRC_FILES
|
|||
src/SSOHandler.cpp
|
||||
src/SideBarActions.cpp
|
||||
src/Splitter.cpp
|
||||
src/TextInputWidget.cpp
|
||||
src/TrayIcon.cpp
|
||||
src/UserInfoWidget.cpp
|
||||
src/UserSettingsPage.cpp
|
||||
src/UsersModel.cpp
|
||||
src/Utils.cpp
|
||||
src/WebRTCSession.cpp
|
||||
src/WelcomePage.cpp
|
||||
|
@ -454,15 +452,12 @@ qt5_wrap_cpp(MOC_HEADERS
|
|||
src/dialogs/RoomSettings.h
|
||||
|
||||
# Emoji
|
||||
src/emoji/Category.h
|
||||
src/emoji/EmojiModel.h
|
||||
src/emoji/ItemDelegate.h
|
||||
src/emoji/Panel.h
|
||||
src/emoji/PickButton.h
|
||||
src/emoji/Provider.h
|
||||
|
||||
# Timeline
|
||||
src/timeline/EventStore.h
|
||||
src/timeline/InputBar.h
|
||||
src/timeline/Reaction.h
|
||||
src/timeline/TimelineViewManager.h
|
||||
src/timeline/TimelineModel.h
|
||||
|
@ -477,6 +472,7 @@ qt5_wrap_cpp(MOC_HEADERS
|
|||
src/ui/Label.h
|
||||
src/ui/FloatingButton.h
|
||||
src/ui/Menu.h
|
||||
src/ui/NhekoDropArea.h
|
||||
src/ui/OverlayWidget.h
|
||||
src/ui/SnackBar.h
|
||||
src/ui/RaisedButton.h
|
||||
|
@ -498,6 +494,7 @@ qt5_wrap_cpp(MOC_HEADERS
|
|||
src/ChatPage.h
|
||||
src/CommunitiesList.h
|
||||
src/CommunitiesListItem.h
|
||||
src/CompletionProxyModel.h
|
||||
src/DeviceVerificationFlow.h
|
||||
src/InviteeItem.h
|
||||
src/LoginPage.h
|
||||
|
@ -510,10 +507,10 @@ qt5_wrap_cpp(MOC_HEADERS
|
|||
src/SSOHandler.h
|
||||
src/SideBarActions.h
|
||||
src/Splitter.h
|
||||
src/TextInputWidget.h
|
||||
src/TrayIcon.h
|
||||
src/UserInfoWidget.h
|
||||
src/UserSettingsPage.h
|
||||
src/UsersModel.h
|
||||
src/WebRTCSession.h
|
||||
src/WelcomePage.h
|
||||
src/popups/PopupItem.h
|
||||
|
|
|
@ -12,8 +12,11 @@ Rectangle {
|
|||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: if (TimelineManager.onVideoCall)
|
||||
stackLayout.currentIndex = stackLayout.currentIndex ? 0 : 1;
|
||||
onClicked: {
|
||||
if (TimelineManager.onVideoCall)
|
||||
stackLayout.currentIndex = stackLayout.currentIndex ? 0 : 1;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
|
@ -39,8 +42,7 @@ Rectangle {
|
|||
Image {
|
||||
Layout.preferredWidth: 24
|
||||
Layout.preferredHeight: 24
|
||||
source: TimelineManager.onVideoCall ?
|
||||
"qrc:/icons/icons/ui/video-call.png" : "qrc:/icons/icons/ui/place-call.png"
|
||||
source: TimelineManager.onVideoCall ? "qrc:/icons/icons/ui/video-call.png" : "qrc:/icons/icons/ui/place-call.png"
|
||||
}
|
||||
|
||||
Label {
|
||||
|
@ -51,7 +53,6 @@ Rectangle {
|
|||
|
||||
Connections {
|
||||
target: TimelineManager
|
||||
|
||||
onCallStateChanged: {
|
||||
switch (state) {
|
||||
case WebRTCState.INITIATING:
|
||||
|
@ -69,6 +70,7 @@ Rectangle {
|
|||
callTimer.startTime = Math.floor(d.getTime() / 1000);
|
||||
if (TimelineManager.onVideoCall)
|
||||
stackLayout.currentIndex = 1;
|
||||
|
||||
break;
|
||||
case WebRTCState.DISCONNECTED:
|
||||
callStateLabel.text = "";
|
||||
|
|
178
resources/qml/Completer.qml
Normal file
178
resources/qml/Completer.qml
Normal file
|
@ -0,0 +1,178 @@
|
|||
import QtQuick 2.9
|
||||
import QtQuick.Controls 2.3
|
||||
import QtQuick.Layouts 1.2
|
||||
import im.nheko 1.0
|
||||
|
||||
Popup {
|
||||
id: popup
|
||||
|
||||
property int currentIndex: -1
|
||||
property string completerName
|
||||
property var completer
|
||||
property bool bottomToTop: true
|
||||
|
||||
signal completionClicked(string completion)
|
||||
|
||||
function up() {
|
||||
if (bottomToTop)
|
||||
down_();
|
||||
else
|
||||
up_();
|
||||
}
|
||||
|
||||
function down() {
|
||||
if (bottomToTop)
|
||||
up_();
|
||||
else
|
||||
down_();
|
||||
}
|
||||
|
||||
function up_() {
|
||||
currentIndex = currentIndex - 1;
|
||||
if (currentIndex == -2)
|
||||
currentIndex = listView.count - 1;
|
||||
|
||||
}
|
||||
|
||||
function down_() {
|
||||
currentIndex = currentIndex + 1;
|
||||
if (currentIndex >= listView.count)
|
||||
currentIndex = -1;
|
||||
|
||||
}
|
||||
|
||||
function currentCompletion() {
|
||||
if (currentIndex > -1 && currentIndex < listView.count)
|
||||
return completer.completionAt(currentIndex);
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
onCompleterNameChanged: {
|
||||
if (completerName) {
|
||||
completer = TimelineManager.timeline.input.completerFor(completerName);
|
||||
completer.setSearchString("");
|
||||
} else {
|
||||
completer = undefined;
|
||||
}
|
||||
}
|
||||
padding: 0
|
||||
onAboutToShow: currentIndex = -1
|
||||
height: listView.contentHeight
|
||||
|
||||
Connections {
|
||||
onTimelineChanged: completer = null
|
||||
target: TimelineManager
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: listView
|
||||
|
||||
anchors.fill: parent
|
||||
implicitWidth: contentItem.childrenRect.width
|
||||
model: completer
|
||||
verticalLayoutDirection: popup.bottomToTop ? ListView.BottomToTop : ListView.TopToBottom
|
||||
|
||||
delegate: Rectangle {
|
||||
color: model.index == popup.currentIndex ? colors.highlight : colors.base
|
||||
height: chooser.childrenRect.height + 4
|
||||
implicitWidth: chooser.childrenRect.width + 4
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onEntered: popup.currentIndex = model.index
|
||||
onClicked: popup.completionClicked(completer.completionAt(model.index))
|
||||
}
|
||||
|
||||
DelegateChooser {
|
||||
id: chooser
|
||||
|
||||
roleValue: popup.completerName
|
||||
anchors.centerIn: parent
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: "user"
|
||||
|
||||
RowLayout {
|
||||
id: del
|
||||
|
||||
anchors.centerIn: parent
|
||||
|
||||
Avatar {
|
||||
height: 24
|
||||
width: 24
|
||||
displayName: model.displayName
|
||||
url: model.avatarUrl.replace("mxc://", "image://MxcImage/")
|
||||
}
|
||||
|
||||
Label {
|
||||
text: model.displayName
|
||||
color: model.index == popup.currentIndex ? colors.highlightedText : colors.text
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "(" + model.userid + ")"
|
||||
color: model.index == popup.currentIndex ? colors.highlightedText : colors.buttonText
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: "emoji"
|
||||
|
||||
RowLayout {
|
||||
id: del
|
||||
|
||||
anchors.centerIn: parent
|
||||
|
||||
Label {
|
||||
text: model.unicode
|
||||
color: model.index == popup.currentIndex ? colors.highlightedText : colors.text
|
||||
font: Settings.emojiFont
|
||||
}
|
||||
|
||||
Label {
|
||||
text: model.shortName
|
||||
color: model.index == popup.currentIndex ? colors.highlightedText : colors.text
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enter: Transition {
|
||||
NumberAnimation {
|
||||
property: "opacity"
|
||||
from: 0
|
||||
to: 1
|
||||
duration: 100
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
exit: Transition {
|
||||
NumberAnimation {
|
||||
property: "opacity"
|
||||
from: 1
|
||||
to: 0
|
||||
duration: 100
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: colors.base
|
||||
implicitHeight: popup.contentHeight
|
||||
implicitWidth: popup.contentWidth
|
||||
}
|
||||
|
||||
}
|
|
@ -8,6 +8,7 @@ AbstractButton {
|
|||
property color highlightColor: colors.highlight
|
||||
property color buttonTextColor: colors.buttonText
|
||||
|
||||
focusPolicy: Qt.NoFocus
|
||||
width: 16
|
||||
height: 16
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import im.nheko 1.0
|
|||
TextEdit {
|
||||
textFormat: TextEdit.RichText
|
||||
readOnly: true
|
||||
focus: false
|
||||
wrapMode: Text.Wrap
|
||||
selectByMouse: !Settings.mobileMode
|
||||
color: colors.text
|
||||
|
|
|
@ -2,6 +2,7 @@ import QtQuick 2.9
|
|||
import QtQuick.Controls 2.3
|
||||
import QtQuick.Layouts 1.2
|
||||
import QtQuick.Window 2.2
|
||||
import im.nheko 1.0
|
||||
|
||||
Rectangle {
|
||||
color: colors.window
|
||||
|
@ -16,14 +17,18 @@ Rectangle {
|
|||
spacing: 16
|
||||
|
||||
ImageButton {
|
||||
visible: TimelineManager.callsSupported
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
hoverEnabled: true
|
||||
width: 22
|
||||
height: 22
|
||||
image: ":/icons/icons/ui/place-call.png"
|
||||
image: TimelineManager.isOnCall ? ":/icons/icons/ui/end-call.png" : ":/icons/icons/ui/place-call.png"
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: TimelineManager.isOnCall ? qsTr("Hang up") : qsTr("Place a call")
|
||||
Layout.topMargin: 8
|
||||
Layout.bottomMargin: 8
|
||||
Layout.leftMargin: 16
|
||||
onClicked: TimelineManager.timeline.input.callButton()
|
||||
}
|
||||
|
||||
ImageButton {
|
||||
|
@ -34,6 +39,23 @@ Rectangle {
|
|||
image: ":/icons/icons/ui/paper-clip-outline.png"
|
||||
Layout.topMargin: 8
|
||||
Layout.bottomMargin: 8
|
||||
Layout.leftMargin: TimelineManager.callsSupported ? 0 : 16
|
||||
onClicked: TimelineManager.timeline.input.openFileSelection()
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: qsTr("Send a file")
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: colors.window
|
||||
visible: TimelineManager.timeline.input.uploading
|
||||
|
||||
NhekoBusyIndicator {
|
||||
anchors.fill: parent
|
||||
running: parent.visible
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
|
@ -44,16 +66,145 @@ Rectangle {
|
|||
Layout.fillWidth: true
|
||||
|
||||
TextArea {
|
||||
id: textArea
|
||||
|
||||
property int completerTriggeredAt: -1
|
||||
|
||||
function insertCompletion(completion) {
|
||||
textArea.remove(completerTriggeredAt, cursorPosition);
|
||||
textArea.insert(cursorPosition, completion);
|
||||
}
|
||||
|
||||
function openCompleter(pos, type) {
|
||||
completerTriggeredAt = pos;
|
||||
popup.completerName = type;
|
||||
popup.open();
|
||||
popup.completer.setSearchString(textArea.getText(completerTriggeredAt, cursorPosition));
|
||||
}
|
||||
|
||||
placeholderText: qsTr("Write a message...")
|
||||
placeholderTextColor: colors.buttonText
|
||||
color: colors.text
|
||||
wrapMode: TextEdit.Wrap
|
||||
focus: true
|
||||
onTextChanged: TimelineManager.timeline.input.updateState(selectionStart, selectionEnd, cursorPosition, text)
|
||||
onCursorPositionChanged: {
|
||||
TimelineManager.timeline.input.updateState(selectionStart, selectionEnd, cursorPosition, text);
|
||||
if (cursorPosition <= completerTriggeredAt) {
|
||||
completerTriggeredAt = -1;
|
||||
popup.close();
|
||||
}
|
||||
if (popup.opened)
|
||||
popup.completer.setSearchString(textArea.getText(completerTriggeredAt, cursorPosition));
|
||||
|
||||
}
|
||||
onSelectionStartChanged: TimelineManager.timeline.input.updateState(selectionStart, selectionEnd, cursorPosition, text)
|
||||
onSelectionEndChanged: TimelineManager.timeline.input.updateState(selectionStart, selectionEnd, cursorPosition, text)
|
||||
// Ensure that we get escape key press events first.
|
||||
Keys.onShortcutOverride: event.accepted = (completerTriggeredAt != -1 && (event.key === Qt.Key_Escape || event.key === Qt.Key_Tab || event.key === Qt.Key_Enter))
|
||||
Keys.onPressed: {
|
||||
if (event.matches(StandardKey.Paste)) {
|
||||
TimelineManager.timeline.input.paste(false);
|
||||
event.accepted = true;
|
||||
} else if (event.modifiers == Qt.ControlModifier && event.key == Qt.Key_U) {
|
||||
textArea.clear();
|
||||
} else if (event.modifiers == Qt.ControlModifier && event.key == Qt.Key_P) {
|
||||
textArea.text = TimelineManager.timeline.input.previousText();
|
||||
} else if (event.modifiers == Qt.ControlModifier && event.key == Qt.Key_N) {
|
||||
textArea.text = TimelineManager.timeline.input.nextText();
|
||||
} else if (event.key == Qt.Key_At) {
|
||||
textArea.openCompleter(cursorPosition, "user");
|
||||
popup.open();
|
||||
} else if (event.key == Qt.Key_Colon) {
|
||||
textArea.openCompleter(cursorPosition, "emoji");
|
||||
popup.open();
|
||||
} else if (event.key == Qt.Key_Escape && popup.opened) {
|
||||
completerTriggeredAt = -1;
|
||||
popup.completerName = "";
|
||||
event.accepted = true;
|
||||
popup.close();
|
||||
} else if (event.matches(StandardKey.InsertParagraphSeparator)) {
|
||||
if (popup.opened) {
|
||||
var currentCompletion = popup.currentCompletion();
|
||||
popup.completerName = "";
|
||||
popup.close();
|
||||
if (currentCompletion) {
|
||||
textArea.insertCompletion(currentCompletion);
|
||||
event.accepted = true;
|
||||
return ;
|
||||
}
|
||||
}
|
||||
TimelineManager.timeline.input.send();
|
||||
textArea.clear();
|
||||
event.accepted = true;
|
||||
} else if (event.key == Qt.Key_Tab) {
|
||||
event.accepted = true;
|
||||
if (popup.opened) {
|
||||
popup.up();
|
||||
} else {
|
||||
var pos = cursorPosition - 1;
|
||||
while (pos > -1) {
|
||||
var t = textArea.getText(pos, pos + 1);
|
||||
console.log('"' + t + '"');
|
||||
if (t == '@' || t == ' ' || t == '\t') {
|
||||
textArea.openCompleter(pos, "user");
|
||||
return ;
|
||||
} else if (t == ':') {
|
||||
textArea.openCompleter(pos, "emoji");
|
||||
return ;
|
||||
}
|
||||
pos = pos - 1;
|
||||
}
|
||||
// At start of input
|
||||
textArea.openCompleter(0, "user");
|
||||
}
|
||||
} else if (event.key == Qt.Key_Up && popup.opened) {
|
||||
event.accepted = true;
|
||||
popup.up();
|
||||
} else if (event.key == Qt.Key_Down && popup.opened) {
|
||||
event.accepted = true;
|
||||
popup.down();
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
onTimelineChanged: {
|
||||
textArea.clear();
|
||||
textArea.append(TimelineManager.timeline.input.text());
|
||||
textArea.completerTriggeredAt = -1;
|
||||
popup.completerName = "";
|
||||
}
|
||||
target: TimelineManager
|
||||
}
|
||||
|
||||
Connections {
|
||||
onCompletionClicked: textArea.insertCompletion(completion)
|
||||
target: popup
|
||||
}
|
||||
|
||||
Completer {
|
||||
id: popup
|
||||
|
||||
x: textArea.positionToRectangle(textArea.completerTriggeredAt).x
|
||||
y: textArea.positionToRectangle(textArea.completerTriggeredAt).y - height
|
||||
}
|
||||
|
||||
Connections {
|
||||
onInsertText: textArea.insert(textArea.cursorPosition, text)
|
||||
target: TimelineManager.timeline.input
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
// workaround for wrong cursor shape on some platforms
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.NoButton
|
||||
acceptedButtons: Qt.MiddleButton
|
||||
cursorShape: Qt.IBeamCursor
|
||||
onClicked: TimelineManager.timeline.input.paste(true)
|
||||
}
|
||||
|
||||
NhekoDropArea {
|
||||
anchors.fill: parent
|
||||
roomid: TimelineManager.timeline.roomId()
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
|
@ -65,6 +216,8 @@ Rectangle {
|
|||
}
|
||||
|
||||
ImageButton {
|
||||
id: emojiButton
|
||||
|
||||
Layout.alignment: Qt.AlignRight | Qt.AlignBottom
|
||||
hoverEnabled: true
|
||||
width: 22
|
||||
|
@ -72,6 +225,11 @@ Rectangle {
|
|||
image: ":/icons/icons/ui/smile.png"
|
||||
Layout.topMargin: 8
|
||||
Layout.bottomMargin: 8
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: qsTr("Emoji")
|
||||
onClicked: emojiPopup.visible ? emojiPopup.close() : emojiPopup.show(emojiButton, function(emoji) {
|
||||
textArea.insert(textArea.cursorPosition, emoji);
|
||||
})
|
||||
}
|
||||
|
||||
ImageButton {
|
||||
|
@ -83,6 +241,12 @@ Rectangle {
|
|||
Layout.topMargin: 8
|
||||
Layout.bottomMargin: 8
|
||||
Layout.rightMargin: 16
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: qsTr("Send")
|
||||
onClicked: {
|
||||
TimelineManager.timeline.input.send();
|
||||
textArea.clear();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@ ListView {
|
|||
ScrollHelper {
|
||||
flickable: parent
|
||||
anchors.fill: parent
|
||||
enabled: !Settings.mobileMode
|
||||
}
|
||||
|
||||
Shortcut {
|
||||
|
@ -181,7 +182,6 @@ ListView {
|
|||
|
||||
Connections {
|
||||
target: chat
|
||||
|
||||
onMovementEnded: {
|
||||
if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height)
|
||||
chat.model.currentIndex = index;
|
||||
|
|
64
resources/qml/NhekoBusyIndicator.qml
Normal file
64
resources/qml/NhekoBusyIndicator.qml
Normal file
|
@ -0,0 +1,64 @@
|
|||
import QtQuick 2.9
|
||||
import QtQuick.Controls 2.3
|
||||
import QtQuick.Layouts 1.2
|
||||
|
||||
BusyIndicator {
|
||||
id: control
|
||||
|
||||
contentItem: Item {
|
||||
implicitWidth: 64
|
||||
implicitHeight: 64
|
||||
|
||||
Item {
|
||||
id: item
|
||||
|
||||
height: Math.min(parent.height, parent.width)
|
||||
width: height
|
||||
opacity: control.running ? 1 : 0
|
||||
|
||||
RotationAnimator {
|
||||
target: item
|
||||
running: control.visible && control.running
|
||||
from: 0
|
||||
to: 360
|
||||
loops: Animation.Infinite
|
||||
duration: 2000
|
||||
}
|
||||
|
||||
Repeater {
|
||||
id: repeater
|
||||
|
||||
model: 6
|
||||
|
||||
Rectangle {
|
||||
implicitWidth: radius * 2
|
||||
implicitHeight: radius * 2
|
||||
radius: item.height / 6
|
||||
color: colors.text
|
||||
opacity: (index + 2) / (repeater.count + 2)
|
||||
transform: [
|
||||
Translate {
|
||||
y: -Math.min(item.width, item.height) * 0.5 + item.height / 6
|
||||
},
|
||||
Rotation {
|
||||
angle: index / repeater.count * 360
|
||||
origin.x: item.height / 2
|
||||
origin.y: item.height / 2
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Behavior on opacity {
|
||||
OpacityAnimator {
|
||||
duration: 250
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -72,7 +72,9 @@ Page {
|
|||
|
||||
MenuItem {
|
||||
text: qsTr("React")
|
||||
onClicked: emojiPopup.show(messageContextMenu.parent, messageContextMenu.eventId)
|
||||
onClicked: emojiPopup.show(messageContextMenu.parent, function(emoji) {
|
||||
TimelineManager.queueReactionMessage(messageContextMenu.eventId, emoji);
|
||||
})
|
||||
}
|
||||
|
||||
MenuItem {
|
||||
|
@ -95,6 +97,7 @@ Page {
|
|||
}
|
||||
|
||||
MenuItem {
|
||||
// TODO(Nico): Fix this still being iterated over, when using keyboard to select options
|
||||
visible: messageContextMenu.isEncrypted
|
||||
height: visible ? implicitHeight : 0
|
||||
text: qsTr("View decrypted raw message")
|
||||
|
@ -129,7 +132,6 @@ Page {
|
|||
|
||||
Connections {
|
||||
target: TimelineManager
|
||||
|
||||
onNewDeviceVerificationRequest: {
|
||||
var dialog = deviceVerificationDialog.createObject(timelineRoot, {
|
||||
"flow": flow
|
||||
|
@ -140,7 +142,6 @@ Page {
|
|||
|
||||
Connections {
|
||||
target: TimelineManager.timeline
|
||||
|
||||
onOpenProfile: {
|
||||
var userProfile = userProfileComponent.createObject(timelineRoot, {
|
||||
"profile": profile
|
||||
|
@ -192,13 +193,15 @@ Page {
|
|||
|
||||
StackLayout {
|
||||
id: stackLayout
|
||||
|
||||
currentIndex: 0
|
||||
|
||||
Connections {
|
||||
target: TimelineManager
|
||||
function onActiveTimelineChanged() {
|
||||
stackLayout.currentIndex = 0;
|
||||
}
|
||||
|
||||
target: TimelineManager
|
||||
}
|
||||
|
||||
MessageView {
|
||||
|
@ -210,6 +213,7 @@ Page {
|
|||
source: TimelineManager.onVideoCall ? "VideoCall.qml" : ""
|
||||
onLoaded: TimelineManager.setVideoCallItem()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
TypingIndicator {
|
||||
|
@ -234,8 +238,8 @@ Page {
|
|||
ReplyPopup {
|
||||
}
|
||||
|
||||
//MessageInput {
|
||||
//}
|
||||
MessageInput {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import QtQuick 2.9
|
||||
|
||||
import org.freedesktop.gstreamer.GLVideoItem 1.0
|
||||
|
||||
GstGLVideoItem {
|
||||
objectName: "videoCallItem"
|
||||
objectName: "videoCallItem"
|
||||
}
|
||||
|
|
|
@ -12,5 +12,7 @@ ImageButton {
|
|||
property string event_id
|
||||
|
||||
image: ":/icons/icons/ui/smile.png"
|
||||
onClicked: emojiPicker.visible ? emojiPicker.close() : emojiPicker.show(emojiButton, event_id)
|
||||
onClicked: emojiPicker.visible ? emojiPicker.close() : emojiPicker.show(emojiButton, function(emoji) {
|
||||
TimelineManager.queueReactionMessage(event_id, emoji);
|
||||
})
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import im.nheko.EmojiModel 1.0
|
|||
Popup {
|
||||
id: emojiPopup
|
||||
|
||||
property string event_id
|
||||
property var callback
|
||||
property var colors
|
||||
property alias model: gridView.model
|
||||
property var textArea
|
||||
|
@ -18,14 +18,14 @@ Popup {
|
|||
property real highlightSat: colors.highlight.hslSaturation
|
||||
property real highlightLight: colors.highlight.hslLightness
|
||||
|
||||
function show(showAt, event_id) {
|
||||
console.debug("Showing emojiPicker for " + event_id);
|
||||
function show(showAt, callback) {
|
||||
console.debug("Showing emojiPicker");
|
||||
if (showAt) {
|
||||
parent = showAt;
|
||||
x = Math.round((showAt.width - width) / 2);
|
||||
y = showAt.height;
|
||||
}
|
||||
emojiPopup.event_id = event_id;
|
||||
emojiPopup.callback = callback;
|
||||
open();
|
||||
}
|
||||
|
||||
|
@ -70,9 +70,9 @@ Popup {
|
|||
ToolTip.visible: hovered
|
||||
// TODO: maybe add favorites at some point?
|
||||
onClicked: {
|
||||
console.debug("Picked " + model.unicode + "in response to " + emojiPopup.event_id);
|
||||
console.debug("Picked " + model.unicode);
|
||||
emojiPopup.close();
|
||||
TimelineManager.queueReactionMessage(emojiPopup.event_id, model.unicode);
|
||||
callback(model.unicode);
|
||||
}
|
||||
|
||||
// give the emoji a little oomf
|
||||
|
|
|
@ -123,20 +123,22 @@
|
|||
<file>qtquickcontrols2.conf</file>
|
||||
|
||||
<file>qml/TimelineView.qml</file>
|
||||
<file>qml/TopBar.qml</file>
|
||||
<file>qml/MessageView.qml</file>
|
||||
<file>qml/MessageInput.qml</file>
|
||||
<file>qml/TypingIndicator.qml</file>
|
||||
<file>qml/ReplyPopup.qml</file>
|
||||
<file>qml/ActiveCallBar.qml</file>
|
||||
<file>qml/Avatar.qml</file>
|
||||
<file>qml/Completer.qml</file>
|
||||
<file>qml/EncryptionIndicator.qml</file>
|
||||
<file>qml/ImageButton.qml</file>
|
||||
<file>qml/MatrixText.qml</file>
|
||||
<file>qml/StatusIndicator.qml</file>
|
||||
<file>qml/EncryptionIndicator.qml</file>
|
||||
<file>qml/MessageInput.qml</file>
|
||||
<file>qml/MessageView.qml</file>
|
||||
<file>qml/NhekoBusyIndicator.qml</file>
|
||||
<file>qml/Reactions.qml</file>
|
||||
<file>qml/ReplyPopup.qml</file>
|
||||
<file>qml/ScrollHelper.qml</file>
|
||||
<file>qml/StatusIndicator.qml</file>
|
||||
<file>qml/TimelineRow.qml</file>
|
||||
<file>qml/TopBar.qml</file>
|
||||
<file>qml/TypingIndicator.qml</file>
|
||||
<file>qml/VideoCall.qml</file>
|
||||
<file>qml/emoji/EmojiButton.qml</file>
|
||||
<file>qml/emoji/EmojiPicker.qml</file>
|
||||
|
|
|
@ -85,8 +85,6 @@ constexpr auto OUTBOUND_MEGOLM_SESSIONS_DB("outbound_megolm_sessions");
|
|||
using CachedReceipts = std::multimap<uint64_t, std::string, std::greater<uint64_t>>;
|
||||
using Receipts = std::map<std::string, std::map<std::string, uint64_t>>;
|
||||
|
||||
Q_DECLARE_METATYPE(SearchResult)
|
||||
Q_DECLARE_METATYPE(std::vector<SearchResult>)
|
||||
Q_DECLARE_METATYPE(RoomMember)
|
||||
Q_DECLARE_METATYPE(mtx::responses::Timeline)
|
||||
Q_DECLARE_METATYPE(RoomSearchResult)
|
||||
|
@ -2334,39 +2332,6 @@ Cache::searchRooms(const std::string &query, std::uint8_t max_items)
|
|||
return results;
|
||||
}
|
||||
|
||||
std::vector<SearchResult>
|
||||
Cache::searchUsers(const std::string &room_id, const std::string &query, std::uint8_t max_items)
|
||||
{
|
||||
std::multimap<int, std::pair<std::string, std::string>> items;
|
||||
|
||||
auto txn = lmdb::txn::begin(env_, nullptr, MDB_RDONLY);
|
||||
auto cursor = lmdb::cursor::open(txn, getMembersDb(txn, room_id));
|
||||
|
||||
std::string user_id, user_data;
|
||||
while (cursor.get(user_id, user_data, MDB_NEXT)) {
|
||||
const auto display_name = displayName(room_id, user_id);
|
||||
const int score = utils::levenshtein_distance(query, display_name);
|
||||
|
||||
items.emplace(score, std::make_pair(user_id, display_name));
|
||||
}
|
||||
|
||||
auto end = items.begin();
|
||||
|
||||
if (items.size() >= max_items)
|
||||
std::advance(end, max_items);
|
||||
else if (items.size() > 0)
|
||||
std::advance(end, items.size());
|
||||
|
||||
std::vector<SearchResult> results;
|
||||
for (auto it = items.begin(); it != end; it++) {
|
||||
const auto user = it->second;
|
||||
results.push_back(SearchResult{QString::fromStdString(user.first),
|
||||
QString::fromStdString(user.second)});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
std::vector<RoomMember>
|
||||
Cache::getMembers(const std::string &room_id, std::size_t startIndex, std::size_t len)
|
||||
{
|
||||
|
@ -3762,8 +3727,6 @@ namespace cache {
|
|||
void
|
||||
init(const QString &user_id)
|
||||
{
|
||||
qRegisterMetaType<SearchResult>();
|
||||
qRegisterMetaType<std::vector<SearchResult>>();
|
||||
qRegisterMetaType<RoomMember>();
|
||||
qRegisterMetaType<RoomSearchResult>();
|
||||
qRegisterMetaType<RoomInfo>();
|
||||
|
@ -4075,11 +4038,6 @@ calculateRoomReadStatus()
|
|||
instance_->calculateRoomReadStatus();
|
||||
}
|
||||
|
||||
std::vector<SearchResult>
|
||||
searchUsers(const std::string &room_id, const std::string &query, std::uint8_t max_items)
|
||||
{
|
||||
return instance_->searchUsers(room_id, query, max_items);
|
||||
}
|
||||
std::vector<RoomSearchResult>
|
||||
searchRooms(const std::string &query, std::uint8_t max_items)
|
||||
{
|
||||
|
|
|
@ -194,8 +194,6 @@ calculateRoomReadStatus(const std::string &room_id);
|
|||
void
|
||||
calculateRoomReadStatus();
|
||||
|
||||
std::vector<SearchResult>
|
||||
searchUsers(const std::string &room_id, const std::string &query, std::uint8_t max_items = 5);
|
||||
std::vector<RoomSearchResult>
|
||||
searchRooms(const std::string &query, std::uint8_t max_items = 5);
|
||||
|
||||
|
|
|
@ -24,12 +24,6 @@ struct RoomMember
|
|||
QImage avatar;
|
||||
};
|
||||
|
||||
struct SearchResult
|
||||
{
|
||||
QString user_id;
|
||||
QString display_name;
|
||||
};
|
||||
|
||||
//! Used to uniquely identify a list of read receipts.
|
||||
struct ReadReceiptKey
|
||||
{
|
||||
|
|
|
@ -164,9 +164,6 @@ public:
|
|||
bool calculateRoomReadStatus(const std::string &room_id);
|
||||
void calculateRoomReadStatus();
|
||||
|
||||
std::vector<SearchResult> searchUsers(const std::string &room_id,
|
||||
const std::string &query,
|
||||
std::uint8_t max_items = 5);
|
||||
std::vector<RoomSearchResult> searchRooms(const std::string &query,
|
||||
std::uint8_t max_items = 5);
|
||||
|
||||
|
|
254
src/ChatPage.cpp
254
src/ChatPage.cpp
|
@ -39,7 +39,6 @@
|
|||
#include "RoomList.h"
|
||||
#include "SideBarActions.h"
|
||||
#include "Splitter.h"
|
||||
#include "TextInputWidget.h"
|
||||
#include "UserInfoWidget.h"
|
||||
#include "UserSettingsPage.h"
|
||||
#include "Utils.h"
|
||||
|
@ -138,21 +137,13 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
|
|||
splitter->addWidget(content_);
|
||||
splitter->restoreSizes(parent->width());
|
||||
|
||||
text_input_ = new TextInputWidget(this);
|
||||
contentLayout_->addWidget(text_input_);
|
||||
|
||||
typingRefresher_ = new QTimer(this);
|
||||
typingRefresher_->setInterval(TYPING_REFRESH_TIMEOUT);
|
||||
|
||||
connect(this, &ChatPage::connectionLost, this, [this]() {
|
||||
nhlog::net()->info("connectivity lost");
|
||||
isConnected_ = false;
|
||||
http::client()->shutdown();
|
||||
text_input_->disableInput();
|
||||
});
|
||||
connect(this, &ChatPage::connectionRestored, this, [this]() {
|
||||
nhlog::net()->info("trying to re-connect");
|
||||
text_input_->enableInput();
|
||||
isConnected_ = true;
|
||||
|
||||
// Drop all pending connections.
|
||||
|
@ -160,15 +151,6 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
|
|||
trySync();
|
||||
});
|
||||
|
||||
connect(text_input_,
|
||||
&TextInputWidget::clearRoomTimeline,
|
||||
view_manager_,
|
||||
&TimelineViewManager::clearCurrentRoomTimeline);
|
||||
|
||||
connect(text_input_, &TextInputWidget::rotateMegolmSession, this, [this]() {
|
||||
cache::dropOutboundMegolmSession(current_room_.toStdString());
|
||||
});
|
||||
|
||||
connect(
|
||||
new QShortcut(QKeySequence("Ctrl+Down"), this), &QShortcut::activated, this, [this]() {
|
||||
if (isVisible())
|
||||
|
@ -230,9 +212,7 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
|
|||
connect(room_list_, &RoomList::roomChanged, this, [this](QString room_id) {
|
||||
this->current_room_ = room_id;
|
||||
});
|
||||
connect(room_list_, &RoomList::roomChanged, text_input_, &TextInputWidget::stopTyping);
|
||||
connect(room_list_, &RoomList::roomChanged, splitter, &Splitter::showChatView);
|
||||
connect(room_list_, &RoomList::roomChanged, text_input_, &TextInputWidget::focusLineEdit);
|
||||
connect(
|
||||
room_list_, &RoomList::roomChanged, view_manager_, &TimelineViewManager::setHistoryView);
|
||||
|
||||
|
@ -246,27 +226,6 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
|
|||
room_list_->removeRoom(room_id, currentRoom() == room_id);
|
||||
});
|
||||
|
||||
connect(
|
||||
text_input_, &TextInputWidget::startedTyping, this, &ChatPage::sendTypingNotifications);
|
||||
connect(typingRefresher_, &QTimer::timeout, this, &ChatPage::sendTypingNotifications);
|
||||
connect(text_input_, &TextInputWidget::stoppedTyping, this, [this]() {
|
||||
if (!userSettings_->typingNotifications())
|
||||
return;
|
||||
|
||||
typingRefresher_->stop();
|
||||
|
||||
if (current_room_.isEmpty())
|
||||
return;
|
||||
|
||||
http::client()->stop_typing(
|
||||
current_room_.toStdString(), [](mtx::http::RequestErr err) {
|
||||
if (err) {
|
||||
nhlog::net()->warn("failed to stop typing notifications: {}",
|
||||
err->matrix_error.error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
connect(view_manager_,
|
||||
&TimelineViewManager::updateRoomsLastMessage,
|
||||
room_list_,
|
||||
|
@ -277,197 +236,6 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
|
|||
this,
|
||||
SIGNAL(unreadMessages(int)));
|
||||
|
||||
connect(text_input_,
|
||||
&TextInputWidget::sendTextMessage,
|
||||
view_manager_,
|
||||
&TimelineViewManager::queueTextMessage);
|
||||
|
||||
connect(text_input_,
|
||||
&TextInputWidget::sendEmoteMessage,
|
||||
view_manager_,
|
||||
&TimelineViewManager::queueEmoteMessage);
|
||||
|
||||
connect(text_input_, &TextInputWidget::sendJoinRoomRequest, this, &ChatPage::joinRoom);
|
||||
|
||||
// invites and bans via quick command
|
||||
connect(text_input_, &TextInputWidget::sendInviteRoomRequest, this, &ChatPage::inviteUser);
|
||||
connect(text_input_, &TextInputWidget::sendKickRoomRequest, this, &ChatPage::kickUser);
|
||||
connect(text_input_, &TextInputWidget::sendBanRoomRequest, this, &ChatPage::banUser);
|
||||
connect(text_input_, &TextInputWidget::sendUnbanRoomRequest, this, &ChatPage::unbanUser);
|
||||
|
||||
connect(
|
||||
text_input_, &TextInputWidget::changeRoomNick, this, [this](const QString &displayName) {
|
||||
mtx::events::state::Member member;
|
||||
member.display_name = displayName.toStdString();
|
||||
member.avatar_url =
|
||||
cache::avatarUrl(currentRoom(),
|
||||
QString::fromStdString(http::client()->user_id().to_string()))
|
||||
.toStdString();
|
||||
member.membership = mtx::events::state::Membership::Join;
|
||||
|
||||
http::client()->send_state_event(
|
||||
currentRoom().toStdString(),
|
||||
http::client()->user_id().to_string(),
|
||||
member,
|
||||
[](mtx::responses::EventId, mtx::http::RequestErr err) {
|
||||
if (err)
|
||||
nhlog::net()->error("Failed to set room displayname: {}",
|
||||
err->matrix_error.error);
|
||||
});
|
||||
});
|
||||
|
||||
connect(
|
||||
text_input_,
|
||||
&TextInputWidget::uploadMedia,
|
||||
this,
|
||||
[this](QSharedPointer<QIODevice> dev, QString mimeClass, const QString &fn) {
|
||||
if (!dev->open(QIODevice::ReadOnly)) {
|
||||
emit uploadFailed(
|
||||
QString("Error while reading media: %1").arg(dev->errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
auto bin = dev->readAll();
|
||||
QMimeDatabase db;
|
||||
QMimeType mime = db.mimeTypeForData(bin);
|
||||
|
||||
auto payload = std::string(bin.data(), bin.size());
|
||||
std::optional<mtx::crypto::EncryptedFile> encryptedFile;
|
||||
if (cache::isRoomEncrypted(current_room_.toStdString())) {
|
||||
mtx::crypto::BinaryBuf buf;
|
||||
std::tie(buf, encryptedFile) = mtx::crypto::encrypt_file(payload);
|
||||
payload = mtx::crypto::to_string(buf);
|
||||
}
|
||||
|
||||
QSize dimensions;
|
||||
QString blurhash;
|
||||
if (mimeClass == "image") {
|
||||
QImage img = utils::readImage(&bin);
|
||||
|
||||
dimensions = img.size();
|
||||
if (img.height() > 200 && img.width() > 360)
|
||||
img = img.scaled(360, 200, Qt::KeepAspectRatioByExpanding);
|
||||
std::vector<unsigned char> data;
|
||||
for (int y = 0; y < img.height(); y++) {
|
||||
for (int x = 0; x < img.width(); x++) {
|
||||
auto p = img.pixel(x, y);
|
||||
data.push_back(static_cast<unsigned char>(qRed(p)));
|
||||
data.push_back(static_cast<unsigned char>(qGreen(p)));
|
||||
data.push_back(static_cast<unsigned char>(qBlue(p)));
|
||||
}
|
||||
}
|
||||
blurhash = QString::fromStdString(
|
||||
blurhash::encode(data.data(), img.width(), img.height(), 4, 3));
|
||||
}
|
||||
|
||||
http::client()->upload(
|
||||
payload,
|
||||
encryptedFile ? "application/octet-stream" : mime.name().toStdString(),
|
||||
QFileInfo(fn).fileName().toStdString(),
|
||||
[this,
|
||||
room_id = current_room_,
|
||||
filename = fn,
|
||||
encryptedFile,
|
||||
mimeClass,
|
||||
mime = mime.name(),
|
||||
size = payload.size(),
|
||||
dimensions,
|
||||
blurhash](const mtx::responses::ContentURI &res, mtx::http::RequestErr err) {
|
||||
if (err) {
|
||||
emit uploadFailed(
|
||||
tr("Failed to upload media. Please try again."));
|
||||
nhlog::net()->warn("failed to upload media: {} {} ({})",
|
||||
err->matrix_error.error,
|
||||
to_string(err->matrix_error.errcode),
|
||||
static_cast<int>(err->status_code));
|
||||
return;
|
||||
}
|
||||
|
||||
emit mediaUploaded(room_id,
|
||||
filename,
|
||||
encryptedFile,
|
||||
QString::fromStdString(res.content_uri),
|
||||
mimeClass,
|
||||
mime,
|
||||
size,
|
||||
dimensions,
|
||||
blurhash);
|
||||
});
|
||||
});
|
||||
|
||||
connect(this, &ChatPage::uploadFailed, this, [this](const QString &msg) {
|
||||
text_input_->hideUploadSpinner();
|
||||
emit showNotification(msg);
|
||||
});
|
||||
connect(this,
|
||||
&ChatPage::mediaUploaded,
|
||||
this,
|
||||
[this](QString roomid,
|
||||
QString filename,
|
||||
std::optional<mtx::crypto::EncryptedFile> encryptedFile,
|
||||
QString url,
|
||||
QString mimeClass,
|
||||
QString mime,
|
||||
qint64 dsize,
|
||||
QSize dimensions,
|
||||
QString blurhash) {
|
||||
text_input_->hideUploadSpinner();
|
||||
|
||||
if (encryptedFile)
|
||||
encryptedFile->url = url.toStdString();
|
||||
|
||||
if (mimeClass == "image")
|
||||
view_manager_->queueImageMessage(roomid,
|
||||
filename,
|
||||
encryptedFile,
|
||||
url,
|
||||
mime,
|
||||
dsize,
|
||||
dimensions,
|
||||
blurhash);
|
||||
else if (mimeClass == "audio")
|
||||
view_manager_->queueAudioMessage(
|
||||
roomid, filename, encryptedFile, url, mime, dsize);
|
||||
else if (mimeClass == "video")
|
||||
view_manager_->queueVideoMessage(
|
||||
roomid, filename, encryptedFile, url, mime, dsize);
|
||||
else
|
||||
view_manager_->queueFileMessage(
|
||||
roomid, filename, encryptedFile, url, mime, dsize);
|
||||
});
|
||||
|
||||
connect(text_input_, &TextInputWidget::callButtonPress, this, [this]() {
|
||||
if (callManager_->onActiveCall()) {
|
||||
callManager_->hangUp();
|
||||
} else {
|
||||
if (auto roomInfo = cache::singleRoomInfo(current_room_.toStdString());
|
||||
roomInfo.member_count != 2) {
|
||||
showNotification("Calls are limited to 1:1 rooms.");
|
||||
} else {
|
||||
std::vector<RoomMember> members(
|
||||
cache::getMembers(current_room_.toStdString()));
|
||||
const RoomMember &callee =
|
||||
members.front().user_id == utils::localUser() ? members.back()
|
||||
: members.front();
|
||||
auto dialog = new dialogs::PlaceCall(
|
||||
callee.user_id,
|
||||
callee.display_name,
|
||||
QString::fromStdString(roomInfo.name),
|
||||
QString::fromStdString(roomInfo.avatar_url),
|
||||
userSettings_,
|
||||
MainWindow::instance());
|
||||
connect(dialog, &dialogs::PlaceCall::voice, this, [this]() {
|
||||
callManager_->sendInvite(current_room_, false);
|
||||
});
|
||||
connect(dialog, &dialogs::PlaceCall::video, this, [this]() {
|
||||
callManager_->sendInvite(current_room_, true);
|
||||
});
|
||||
utils::centerWidget(dialog, MainWindow::instance());
|
||||
dialog->show();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
connect(
|
||||
this, &ChatPage::updateGroupsInfo, communitiesList_, &CommunitiesList::setCommunities);
|
||||
|
||||
|
@ -635,12 +403,6 @@ ChatPage::resetUI()
|
|||
emit unreadMessages(0);
|
||||
}
|
||||
|
||||
void
|
||||
ChatPage::focusMessageInput()
|
||||
{
|
||||
this->text_input_->focusLineEdit();
|
||||
}
|
||||
|
||||
void
|
||||
ChatPage::deleteConfigs()
|
||||
{
|
||||
|
@ -805,7 +567,6 @@ ChatPage::showQuickSwitcher()
|
|||
connect(dialog, &QuickSwitcher::roomSelected, room_list_, &RoomList::highlightSelectedRoom);
|
||||
connect(dialog, &QuickSwitcher::closing, this, [this]() {
|
||||
MainWindow::instance()->hideOverlay();
|
||||
text_input_->setFocus(Qt::FocusReason::PopupFocusReason);
|
||||
});
|
||||
|
||||
MainWindow::instance()->showTransparentOverlayModal(dialog);
|
||||
|
@ -1299,21 +1060,6 @@ ChatPage::receivedSessionKey(const std::string &room_id, const std::string &sess
|
|||
view_manager_->receivedSessionKey(room_id, session_id);
|
||||
}
|
||||
|
||||
void
|
||||
ChatPage::sendTypingNotifications()
|
||||
{
|
||||
if (!userSettings_->typingNotifications())
|
||||
return;
|
||||
|
||||
http::client()->start_typing(
|
||||
current_room_.toStdString(), 10'000, [](mtx::http::RequestErr err) {
|
||||
if (err) {
|
||||
nhlog::net()->warn("failed to send typing notification: {}",
|
||||
err->matrix_error.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QString
|
||||
ChatPage::status() const
|
||||
{
|
||||
|
|
|
@ -46,7 +46,6 @@ class QuickSwitcher;
|
|||
class RoomList;
|
||||
class SideBarActions;
|
||||
class Splitter;
|
||||
class TextInputWidget;
|
||||
class TimelineViewManager;
|
||||
class UserInfoWidget;
|
||||
class UserSettings;
|
||||
|
@ -88,6 +87,8 @@ public:
|
|||
static ChatPage *instance() { return instance_; }
|
||||
|
||||
QSharedPointer<UserSettings> userSettings() { return userSettings_; }
|
||||
CallManager *callManager() { return callManager_; }
|
||||
TimelineViewManager *timelineManager() { return view_manager_; }
|
||||
void deleteConfigs();
|
||||
|
||||
CommunitiesList *communitiesList() { return communitiesList_; }
|
||||
|
@ -99,7 +100,6 @@ public:
|
|||
//! Show the room/group list (if it was visible).
|
||||
void showSideBars();
|
||||
void initiateLogout();
|
||||
void focusMessageInput();
|
||||
|
||||
QString status() const;
|
||||
void setStatus(const QString &status);
|
||||
|
@ -109,6 +109,7 @@ public:
|
|||
public slots:
|
||||
void leaveRoom(const QString &room_id);
|
||||
void createRoom(const mtx::requests::CreateRoom &req);
|
||||
void joinRoom(const QString &room);
|
||||
|
||||
void inviteUser(QString userid, QString reason);
|
||||
void kickUser(QString userid, QString reason);
|
||||
|
@ -125,17 +126,6 @@ signals:
|
|||
void highlightedNotifsRetrieved(const mtx::responses::Notifications &,
|
||||
const QPoint widgetPos);
|
||||
|
||||
void uploadFailed(const QString &msg);
|
||||
void mediaUploaded(const QString &roomid,
|
||||
const QString &filename,
|
||||
const std::optional<mtx::crypto::EncryptedFile> &file,
|
||||
const QString &url,
|
||||
const QString &mimeClass,
|
||||
const QString &mime,
|
||||
qint64 dsize,
|
||||
const QSize &dimensions,
|
||||
const QString &blurhash);
|
||||
|
||||
void contentLoaded();
|
||||
void closing();
|
||||
void changeWindowTitle(const int);
|
||||
|
@ -200,8 +190,6 @@ private slots:
|
|||
void removeRoom(const QString &room_id);
|
||||
void dropToLoginPage(const QString &msg);
|
||||
|
||||
void joinRoom(const QString &room);
|
||||
void sendTypingNotifications();
|
||||
void handleSyncResponse(const mtx::responses::Sync &res);
|
||||
|
||||
private:
|
||||
|
@ -263,8 +251,6 @@ private:
|
|||
TimelineViewManager *view_manager_;
|
||||
SideBarActions *sidebarActions_;
|
||||
|
||||
TextInputWidget *text_input_;
|
||||
|
||||
QTimer connectivityTimer_;
|
||||
std::atomic_bool isConnected_;
|
||||
|
||||
|
@ -275,8 +261,6 @@ private:
|
|||
|
||||
popups::UserMentions *user_mentions_popup_;
|
||||
|
||||
QTimer *typingRefresher_;
|
||||
|
||||
// Global user settings.
|
||||
QSharedPointer<UserSettings> userSettings_;
|
||||
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
// Class for showing a limited amount of completions at a time
|
||||
|
||||
#include <QSortFilterProxyModel>
|
||||
|
||||
class CompletionModel : public QSortFilterProxyModel
|
||||
{
|
||||
public:
|
||||
CompletionModel(QAbstractItemModel *model, QObject *parent = nullptr)
|
||||
: QSortFilterProxyModel(parent)
|
||||
{
|
||||
setSourceModel(model);
|
||||
}
|
||||
int rowCount(const QModelIndex &parent) const override
|
||||
{
|
||||
auto row_count = QSortFilterProxyModel::rowCount(parent);
|
||||
return (row_count < 7) ? row_count : 7;
|
||||
}
|
||||
};
|
15
src/CompletionModelRoles.h
Normal file
15
src/CompletionModelRoles.h
Normal file
|
@ -0,0 +1,15 @@
|
|||
#pragma once
|
||||
|
||||
#include <QAbstractItemModel>
|
||||
|
||||
// Interface for completion models
|
||||
namespace CompletionModel {
|
||||
|
||||
// Start at Qt::UserRole * 2 to prevent clashes
|
||||
enum Roles
|
||||
{
|
||||
CompletionRole = Qt::UserRole * 2, // The string to replace the active completion
|
||||
SearchRole, // String completer uses for search
|
||||
SearchRole2, // Secondary string completer uses for search
|
||||
};
|
||||
}
|
133
src/CompletionProxyModel.cpp
Normal file
133
src/CompletionProxyModel.cpp
Normal file
|
@ -0,0 +1,133 @@
|
|||
#include "CompletionProxyModel.h"
|
||||
|
||||
#include <QRegularExpression>
|
||||
|
||||
#include "CompletionModelRoles.h"
|
||||
#include "Logging.h"
|
||||
#include "Utils.h"
|
||||
|
||||
CompletionProxyModel::CompletionProxyModel(QAbstractItemModel *model, QObject *parent)
|
||||
: QAbstractProxyModel(parent)
|
||||
{
|
||||
setSourceModel(model);
|
||||
QRegularExpression splitPoints("\\s+|-");
|
||||
|
||||
for (int i = 0; i < sourceModel()->rowCount(); i++) {
|
||||
if (i < 7)
|
||||
mapping.push_back(i);
|
||||
|
||||
auto string1 = sourceModel()
|
||||
->data(sourceModel()->index(i, 0), CompletionModel::SearchRole)
|
||||
.toString()
|
||||
.toLower();
|
||||
trie_.insert(string1.toUcs4(), i);
|
||||
|
||||
for (const auto &e : string1.split(splitPoints, Qt::SkipEmptyParts)) {
|
||||
trie_.insert(e.toUcs4(), i);
|
||||
}
|
||||
|
||||
auto string2 = sourceModel()
|
||||
->data(sourceModel()->index(i, 0), CompletionModel::SearchRole2)
|
||||
.toString()
|
||||
.toLower();
|
||||
|
||||
if (!string2.isEmpty()) {
|
||||
trie_.insert(string2.toUcs4(), i);
|
||||
for (const auto &e : string2.split(splitPoints, Qt::SkipEmptyParts)) {
|
||||
trie_.insert(e.toUcs4(), i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connect(
|
||||
this,
|
||||
&CompletionProxyModel::newSearchString,
|
||||
this,
|
||||
[this](QString s) {
|
||||
s.remove(":");
|
||||
s.remove("@");
|
||||
searchString = s.toLower();
|
||||
invalidate();
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
void
|
||||
CompletionProxyModel::invalidate()
|
||||
{
|
||||
auto key = searchString.toUcs4();
|
||||
beginResetModel();
|
||||
mapping = trie_.search(key, 7);
|
||||
endResetModel();
|
||||
|
||||
std::string temp;
|
||||
for (auto v : mapping) {
|
||||
temp += std::to_string(v) + ", ";
|
||||
}
|
||||
nhlog::ui()->debug("mapping: {}", temp);
|
||||
}
|
||||
|
||||
QHash<int, QByteArray>
|
||||
CompletionProxyModel::roleNames() const
|
||||
{
|
||||
return this->sourceModel()->roleNames();
|
||||
}
|
||||
|
||||
int
|
||||
CompletionProxyModel::rowCount(const QModelIndex &) const
|
||||
{
|
||||
return (int)mapping.size();
|
||||
}
|
||||
|
||||
QModelIndex
|
||||
CompletionProxyModel::mapFromSource(const QModelIndex &sourceIndex) const
|
||||
{
|
||||
for (int i = 0; i < (int)mapping.size(); i++) {
|
||||
if (mapping[i] == sourceIndex.row()) {
|
||||
return index(i, 0);
|
||||
}
|
||||
}
|
||||
return QModelIndex();
|
||||
}
|
||||
|
||||
QModelIndex
|
||||
CompletionProxyModel::mapToSource(const QModelIndex &proxyIndex) const
|
||||
{
|
||||
auto row = proxyIndex.row();
|
||||
if (row < 0 || row >= (int)mapping.size())
|
||||
return QModelIndex();
|
||||
|
||||
return sourceModel()->index(mapping[row], 0);
|
||||
}
|
||||
|
||||
QModelIndex
|
||||
CompletionProxyModel::index(int row, int column, const QModelIndex &) const
|
||||
{
|
||||
return createIndex(row, column);
|
||||
}
|
||||
|
||||
QModelIndex
|
||||
CompletionProxyModel::parent(const QModelIndex &) const
|
||||
{
|
||||
return QModelIndex{};
|
||||
}
|
||||
int
|
||||
CompletionProxyModel::columnCount(const QModelIndex &) const
|
||||
{
|
||||
return sourceModel()->columnCount();
|
||||
}
|
||||
|
||||
QVariant
|
||||
CompletionProxyModel::completionAt(int i) const
|
||||
{
|
||||
if (i >= 0 && i < rowCount())
|
||||
return data(index(i, 0), CompletionModel::CompletionRole);
|
||||
else
|
||||
return {};
|
||||
}
|
||||
|
||||
void
|
||||
CompletionProxyModel::setSearchString(QString s)
|
||||
{
|
||||
emit newSearchString(s);
|
||||
}
|
159
src/CompletionProxyModel.h
Normal file
159
src/CompletionProxyModel.h
Normal file
|
@ -0,0 +1,159 @@
|
|||
#pragma once
|
||||
|
||||
// Class for showing a limited amount of completions at a time
|
||||
|
||||
#include <QAbstractProxyModel>
|
||||
|
||||
template<typename Key, typename Value>
|
||||
struct trie
|
||||
{
|
||||
std::vector<Value> values;
|
||||
std::map<Key, trie> next;
|
||||
|
||||
void insert(const QVector<Key> &keys, const Value &v)
|
||||
{
|
||||
auto t = this;
|
||||
for (const auto k : keys) {
|
||||
t = &t->next[k];
|
||||
}
|
||||
|
||||
t->values.push_back(v);
|
||||
}
|
||||
|
||||
std::vector<Value> valuesAndSubvalues(size_t limit = -1) const
|
||||
{
|
||||
std::vector<Value> ret;
|
||||
if (limit < 200)
|
||||
ret.reserve(limit);
|
||||
|
||||
for (const auto &v : values) {
|
||||
if (ret.size() >= limit)
|
||||
return ret;
|
||||
else
|
||||
ret.push_back(v);
|
||||
}
|
||||
|
||||
for (const auto &[k, t] : next) {
|
||||
(void)k;
|
||||
if (ret.size() >= limit)
|
||||
return ret;
|
||||
else {
|
||||
auto temp = t.valuesAndSubvalues(limit - ret.size());
|
||||
for (auto &&v : temp) {
|
||||
if (ret.size() >= limit)
|
||||
return ret;
|
||||
|
||||
if (std::find(ret.begin(), ret.end(), v) == ret.end()) {
|
||||
ret.push_back(std::move(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
std::vector<Value> search(const QVector<Key> &keys, //< TODO(Nico): replace this with a span
|
||||
size_t limit,
|
||||
size_t max_distance = 2) const
|
||||
{
|
||||
std::vector<Value> ret;
|
||||
if (!limit)
|
||||
return ret;
|
||||
|
||||
if (keys.isEmpty())
|
||||
return valuesAndSubvalues(limit);
|
||||
|
||||
auto append = [&ret, limit](std::vector<Value> &&in) {
|
||||
for (auto &&v : in) {
|
||||
if (ret.size() >= limit)
|
||||
return;
|
||||
|
||||
if (std::find(ret.begin(), ret.end(), v) == ret.end()) {
|
||||
ret.push_back(std::move(v));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (auto e = this->next.find(keys[0]); e != this->next.end()) {
|
||||
append(e->second.search(keys.mid(1), limit, max_distance));
|
||||
}
|
||||
|
||||
if (max_distance && ret.size() < limit) {
|
||||
max_distance -= 1;
|
||||
|
||||
// swap chars case
|
||||
if (keys.size() >= 2) {
|
||||
auto t = this;
|
||||
for (int i = 1; i >= 0; i--) {
|
||||
if (auto e = t->next.find(keys[i]); e != t->next.end()) {
|
||||
t = &e->second;
|
||||
} else {
|
||||
t = nullptr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (t) {
|
||||
append(t->search(
|
||||
keys.mid(2), (limit - ret.size()) * 2, max_distance));
|
||||
}
|
||||
}
|
||||
|
||||
// delete character case
|
||||
append(this->search(keys.mid(1), (limit - ret.size()) * 2, max_distance));
|
||||
|
||||
// substitute and insert cases
|
||||
for (const auto &[k, t] : this->next) {
|
||||
if (k == keys[0] || ret.size() >= limit)
|
||||
break;
|
||||
|
||||
// substitute
|
||||
append(t.search(keys.mid(1), limit - ret.size(), max_distance));
|
||||
|
||||
if (ret.size() >= limit)
|
||||
break;
|
||||
|
||||
// insert
|
||||
append(t.search(keys, limit - ret.size(), max_distance));
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
};
|
||||
|
||||
class CompletionProxyModel : public QAbstractProxyModel
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
CompletionProxyModel(QAbstractItemModel *model, QObject *parent = nullptr);
|
||||
|
||||
void invalidate();
|
||||
|
||||
QHash<int, QByteArray> roleNames() const override;
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||
int columnCount(const QModelIndex &) const override;
|
||||
|
||||
QModelIndex mapFromSource(const QModelIndex &sourceIndex) const override;
|
||||
QModelIndex mapToSource(const QModelIndex &proxyIndex) const override;
|
||||
|
||||
QModelIndex index(int row,
|
||||
int column,
|
||||
const QModelIndex &parent = QModelIndex()) const override;
|
||||
QModelIndex parent(const QModelIndex &) const override;
|
||||
|
||||
public slots:
|
||||
QVariant completionAt(int i) const;
|
||||
|
||||
void setSearchString(QString s);
|
||||
|
||||
signals:
|
||||
void newSearchString(QString);
|
||||
|
||||
private:
|
||||
QString searchString;
|
||||
trie<uint, int> trie_;
|
||||
std::vector<int> mapping;
|
||||
};
|
|
@ -118,7 +118,7 @@ QuickSwitcher::QuickSwitcher(QWidget *parent)
|
|||
connect(roomSearch_, &RoomSearchInput::hiding, this, [this]() { popup_.hide(); });
|
||||
connect(roomSearch_, &QLineEdit::returnPressed, this, [this]() {
|
||||
reset();
|
||||
popup_.selectHoveredSuggestion<RoomItem>();
|
||||
popup_.selectHoveredSuggestion();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,796 +0,0 @@
|
|||
/*
|
||||
* nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <QAbstractItemView>
|
||||
#include <QAbstractTextDocumentLayout>
|
||||
#include <QBuffer>
|
||||
#include <QClipboard>
|
||||
#include <QCompleter>
|
||||
#include <QFileDialog>
|
||||
#include <QMimeData>
|
||||
#include <QMimeDatabase>
|
||||
#include <QMimeType>
|
||||
#include <QPainter>
|
||||
#include <QStyleOption>
|
||||
#include <QtConcurrent>
|
||||
|
||||
#include "Cache.h"
|
||||
#include "ChatPage.h"
|
||||
#include "CompletionModel.h"
|
||||
#include "Logging.h"
|
||||
#include "TextInputWidget.h"
|
||||
#include "Utils.h"
|
||||
#include "emoji/EmojiSearchModel.h"
|
||||
#include "emoji/Provider.h"
|
||||
#include "ui/FlatButton.h"
|
||||
#include "ui/LoadingIndicator.h"
|
||||
|
||||
#if defined(Q_OS_MAC)
|
||||
#include "emoji/MacHelper.h"
|
||||
#endif
|
||||
|
||||
static constexpr size_t INPUT_HISTORY_SIZE = 127;
|
||||
static constexpr int MAX_TEXTINPUT_HEIGHT = 120;
|
||||
static constexpr int ButtonHeight = 22;
|
||||
|
||||
FilteredTextEdit::FilteredTextEdit(QWidget *parent)
|
||||
: QTextEdit{parent}
|
||||
, history_index_{0}
|
||||
, suggestionsPopup_{parent}
|
||||
, previewDialog_{parent}
|
||||
{
|
||||
setFrameStyle(QFrame::NoFrame);
|
||||
connect(document()->documentLayout(),
|
||||
&QAbstractTextDocumentLayout::documentSizeChanged,
|
||||
this,
|
||||
&FilteredTextEdit::updateGeometry);
|
||||
connect(document()->documentLayout(),
|
||||
&QAbstractTextDocumentLayout::documentSizeChanged,
|
||||
this,
|
||||
[this]() { emit heightChanged(document()->size().toSize().height()); });
|
||||
working_history_.push_back("");
|
||||
connect(this, &QTextEdit::textChanged, this, &FilteredTextEdit::textChanged);
|
||||
setAcceptRichText(false);
|
||||
|
||||
completer_ = new QCompleter(this);
|
||||
completer_->setWidget(this);
|
||||
auto model = new emoji::EmojiSearchModel(this);
|
||||
model->sort(0, Qt::AscendingOrder);
|
||||
completer_->setModel((emoji_completion_model_ = new CompletionModel(model, this)));
|
||||
completer_->setModelSorting(QCompleter::UnsortedModel);
|
||||
completer_->popup()->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
completer_->popup()->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
|
||||
connect(completer_,
|
||||
QOverload<const QModelIndex &>::of(&QCompleter::activated),
|
||||
[this](auto &index) {
|
||||
emoji_popup_open_ = false;
|
||||
auto emoji = index.data(emoji::EmojiModel::Unicode).toString();
|
||||
insertCompletion(emoji);
|
||||
});
|
||||
|
||||
typingTimer_ = new QTimer(this);
|
||||
typingTimer_->setInterval(1000);
|
||||
typingTimer_->setSingleShot(true);
|
||||
|
||||
connect(typingTimer_, &QTimer::timeout, this, &FilteredTextEdit::stopTyping);
|
||||
connect(&previewDialog_,
|
||||
&dialogs::PreviewUploadOverlay::confirmUpload,
|
||||
this,
|
||||
&FilteredTextEdit::uploadData);
|
||||
|
||||
connect(this, &FilteredTextEdit::resultsRetrieved, this, &FilteredTextEdit::showResults);
|
||||
connect(
|
||||
&suggestionsPopup_, &SuggestionsPopup::itemSelected, this, [this](const QString &text) {
|
||||
suggestionsPopup_.hide();
|
||||
|
||||
auto cursor = textCursor();
|
||||
const int end = cursor.position();
|
||||
|
||||
cursor.setPosition(atTriggerPosition_, QTextCursor::MoveAnchor);
|
||||
cursor.setPosition(end, QTextCursor::KeepAnchor);
|
||||
cursor.removeSelectedText();
|
||||
cursor.insertText(text);
|
||||
});
|
||||
|
||||
// For cycling through the suggestions by hitting tab.
|
||||
connect(this,
|
||||
&FilteredTextEdit::selectNextSuggestion,
|
||||
&suggestionsPopup_,
|
||||
&SuggestionsPopup::selectNextSuggestion);
|
||||
connect(this,
|
||||
&FilteredTextEdit::selectPreviousSuggestion,
|
||||
&suggestionsPopup_,
|
||||
&SuggestionsPopup::selectPreviousSuggestion);
|
||||
connect(this, &FilteredTextEdit::selectHoveredSuggestion, this, [this]() {
|
||||
suggestionsPopup_.selectHoveredSuggestion<UserItem>();
|
||||
});
|
||||
|
||||
previewDialog_.hide();
|
||||
}
|
||||
|
||||
void
|
||||
FilteredTextEdit::insertCompletion(QString completion)
|
||||
{
|
||||
// Paint the current word and replace it with 'completion'
|
||||
auto cur_text = textAfterPosition(trigger_pos_);
|
||||
auto tc = textCursor();
|
||||
tc.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor, cur_text.length());
|
||||
tc.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, cur_text.length());
|
||||
tc.insertText(completion);
|
||||
setTextCursor(tc);
|
||||
}
|
||||
|
||||
void
|
||||
FilteredTextEdit::showResults(const std::vector<SearchResult> &results)
|
||||
{
|
||||
QPoint pos;
|
||||
|
||||
if (isAnchorValid()) {
|
||||
auto cursor = textCursor();
|
||||
cursor.setPosition(atTriggerPosition_);
|
||||
pos = viewport()->mapToGlobal(cursorRect(cursor).topLeft());
|
||||
} else {
|
||||
auto rect = cursorRect();
|
||||
pos = viewport()->mapToGlobal(rect.topLeft());
|
||||
}
|
||||
|
||||
suggestionsPopup_.addUsers(results);
|
||||
suggestionsPopup_.move(pos.x(), pos.y() - suggestionsPopup_.height() - 10);
|
||||
suggestionsPopup_.show();
|
||||
}
|
||||
|
||||
void
|
||||
FilteredTextEdit::keyPressEvent(QKeyEvent *event)
|
||||
{
|
||||
const bool isModifier = (event->modifiers() != Qt::NoModifier);
|
||||
|
||||
#if defined(Q_OS_MAC)
|
||||
if (event->modifiers() == (Qt::ControlModifier | Qt::MetaModifier) &&
|
||||
event->key() == Qt::Key_Space)
|
||||
MacHelper::showEmojiWindow();
|
||||
#endif
|
||||
|
||||
if (event->modifiers() == Qt::ControlModifier && event->key() == Qt::Key_U)
|
||||
QTextEdit::setText("");
|
||||
|
||||
if (!isModifier) {
|
||||
if (!typingTimer_->isActive())
|
||||
emit startedTyping();
|
||||
|
||||
typingTimer_->start();
|
||||
}
|
||||
|
||||
// calculate the new query
|
||||
if (textCursor().position() < atTriggerPosition_ || !isAnchorValid()) {
|
||||
resetAnchor();
|
||||
closeSuggestions();
|
||||
}
|
||||
|
||||
if (suggestionsPopup_.isVisible()) {
|
||||
switch (event->key()) {
|
||||
case Qt::Key_Down:
|
||||
case Qt::Key_Tab:
|
||||
emit selectNextSuggestion();
|
||||
return;
|
||||
case Qt::Key_Enter:
|
||||
case Qt::Key_Return:
|
||||
emit selectHoveredSuggestion();
|
||||
return;
|
||||
case Qt::Key_Escape:
|
||||
closeSuggestions();
|
||||
return;
|
||||
case Qt::Key_Up:
|
||||
case Qt::Key_Backtab: {
|
||||
emit selectPreviousSuggestion();
|
||||
return;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (emoji_popup_open_) {
|
||||
auto fake_key = (event->key() == Qt::Key_Backtab) ? Qt::Key_Up : Qt::Key_Down;
|
||||
switch (event->key()) {
|
||||
case Qt::Key_Backtab:
|
||||
case Qt::Key_Tab: {
|
||||
// Simulate up/down arrow press
|
||||
auto ev = new QKeyEvent(QEvent::KeyPress, fake_key, Qt::NoModifier);
|
||||
QCoreApplication::postEvent(completer_->popup(), ev);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
switch (event->key()) {
|
||||
case Qt::Key_At:
|
||||
atTriggerPosition_ = textCursor().position();
|
||||
anchorType_ = AnchorType::Sigil;
|
||||
|
||||
QTextEdit::keyPressEvent(event);
|
||||
break;
|
||||
case Qt::Key_Tab: {
|
||||
auto cursor = textCursor();
|
||||
const int initialPos = cursor.position();
|
||||
|
||||
cursor.movePosition(QTextCursor::StartOfWord, QTextCursor::KeepAnchor);
|
||||
auto word = cursor.selectedText();
|
||||
|
||||
const int startOfWord = cursor.position();
|
||||
|
||||
// There is a word to complete.
|
||||
if (initialPos != startOfWord) {
|
||||
atTriggerPosition_ = startOfWord;
|
||||
anchorType_ = AnchorType::Tab;
|
||||
|
||||
emit showSuggestions(word);
|
||||
} else {
|
||||
QTextEdit::keyPressEvent(event);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case Qt::Key_Colon: {
|
||||
QTextEdit::keyPressEvent(event);
|
||||
trigger_pos_ = textCursor().position() - 1;
|
||||
emoji_completion_model_->setFilterRegExp("");
|
||||
emoji_popup_open_ = true;
|
||||
break;
|
||||
}
|
||||
case Qt::Key_Return:
|
||||
case Qt::Key_Enter:
|
||||
if (emoji_popup_open_) {
|
||||
if (!completer_->popup()->currentIndex().isValid()) {
|
||||
// No completion to select, do normal behavior
|
||||
completer_->popup()->hide();
|
||||
emoji_popup_open_ = false;
|
||||
} else {
|
||||
event->ignore();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(event->modifiers() & Qt::ShiftModifier)) {
|
||||
stopTyping();
|
||||
submit();
|
||||
} else {
|
||||
QTextEdit::keyPressEvent(event);
|
||||
}
|
||||
break;
|
||||
case Qt::Key_Up: {
|
||||
auto initial_cursor = textCursor();
|
||||
QTextEdit::keyPressEvent(event);
|
||||
|
||||
if (textCursor() == initial_cursor && textCursor().atStart() &&
|
||||
history_index_ + 1 < working_history_.size()) {
|
||||
++history_index_;
|
||||
setPlainText(working_history_[history_index_]);
|
||||
moveCursor(QTextCursor::End);
|
||||
} else if (textCursor() == initial_cursor) {
|
||||
// Move to the start of the text if there aren't any lines to move up to.
|
||||
initial_cursor.movePosition(QTextCursor::Start, QTextCursor::MoveAnchor, 1);
|
||||
setTextCursor(initial_cursor);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case Qt::Key_Down: {
|
||||
auto initial_cursor = textCursor();
|
||||
QTextEdit::keyPressEvent(event);
|
||||
|
||||
if (textCursor() == initial_cursor && textCursor().atEnd() && history_index_ > 0) {
|
||||
--history_index_;
|
||||
setPlainText(working_history_[history_index_]);
|
||||
moveCursor(QTextCursor::End);
|
||||
} else if (textCursor() == initial_cursor) {
|
||||
// Move to the end of the text if there aren't any lines to move down to.
|
||||
initial_cursor.movePosition(QTextCursor::End, QTextCursor::MoveAnchor, 1);
|
||||
setTextCursor(initial_cursor);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
QTextEdit::keyPressEvent(event);
|
||||
|
||||
if (isModifier)
|
||||
return;
|
||||
|
||||
if (emoji_popup_open_ && textAfterPosition(trigger_pos_).length() > 2) {
|
||||
// Update completion
|
||||
emoji_completion_model_->setFilterRegExp(textAfterPosition(trigger_pos_));
|
||||
completer_->complete(completerRect());
|
||||
}
|
||||
|
||||
if (emoji_popup_open_ && (completer_->completionCount() < 1 ||
|
||||
!textAfterPosition(trigger_pos_)
|
||||
.contains(QRegularExpression(":[^\r\n\t\f\v :]+$")))) {
|
||||
// No completions for this word or another word than the completer was
|
||||
// started with
|
||||
emoji_popup_open_ = false;
|
||||
completer_->popup()->hide();
|
||||
}
|
||||
|
||||
if (textCursor().position() == 0) {
|
||||
resetAnchor();
|
||||
closeSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the current word should be autocompleted.
|
||||
auto cursor = textCursor();
|
||||
cursor.movePosition(QTextCursor::StartOfWord, QTextCursor::KeepAnchor);
|
||||
auto word = cursor.selectedText();
|
||||
|
||||
if (hasAnchor(cursor.position(), anchorType_) && isAnchorValid()) {
|
||||
if (word.isEmpty()) {
|
||||
closeSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
emit showSuggestions(word);
|
||||
} else {
|
||||
resetAnchor();
|
||||
closeSuggestions();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool
|
||||
FilteredTextEdit::canInsertFromMimeData(const QMimeData *source) const
|
||||
{
|
||||
return (source->hasImage() || QTextEdit::canInsertFromMimeData(source));
|
||||
}
|
||||
|
||||
void
|
||||
FilteredTextEdit::insertFromMimeData(const QMimeData *source)
|
||||
{
|
||||
qInfo() << "Got mime formats: \n" << source->formats();
|
||||
const auto formats = source->formats().filter("/");
|
||||
const auto image = formats.filter("image/", Qt::CaseInsensitive);
|
||||
const auto audio = formats.filter("audio/", Qt::CaseInsensitive);
|
||||
const auto video = formats.filter("video/", Qt::CaseInsensitive);
|
||||
|
||||
if (!image.empty() && source->hasImage()) {
|
||||
QImage img = qvariant_cast<QImage>(source->imageData());
|
||||
previewDialog_.setPreview(img, image.front());
|
||||
} else if (!audio.empty()) {
|
||||
showPreview(source, audio);
|
||||
} else if (!video.empty()) {
|
||||
showPreview(source, video);
|
||||
} else if (source->hasUrls()) {
|
||||
// Generic file path for any platform.
|
||||
QString path;
|
||||
for (auto &&u : source->urls()) {
|
||||
if (u.isLocalFile()) {
|
||||
path = u.toLocalFile();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!path.isEmpty() && QFileInfo{path}.exists()) {
|
||||
previewDialog_.setPreview(path);
|
||||
} else {
|
||||
qWarning()
|
||||
<< "Clipboard does not contain any valid file paths:" << source->urls();
|
||||
}
|
||||
} else if (source->hasFormat("x-special/gnome-copied-files")) {
|
||||
// Special case for X11 users. See "Notes for X11 Users" in source.
|
||||
// Source: http://doc.qt.io/qt-5/qclipboard.html
|
||||
|
||||
// This MIME type returns a string with multiple lines separated by '\n'. The first
|
||||
// line is the command to perform with the clipboard (not useful to us). The
|
||||
// following lines are the file URIs.
|
||||
//
|
||||
// Source: the nautilus source code in file 'src/nautilus-clipboard.c' in function
|
||||
// nautilus_clipboard_get_uri_list_from_selection_data()
|
||||
// https://github.com/GNOME/nautilus/blob/master/src/nautilus-clipboard.c
|
||||
|
||||
auto data = source->data("x-special/gnome-copied-files").split('\n');
|
||||
if (data.size() < 2) {
|
||||
qWarning() << "MIME format is malformed, cannot perform paste.";
|
||||
return;
|
||||
}
|
||||
|
||||
QString path;
|
||||
for (int i = 1; i < data.size(); ++i) {
|
||||
QUrl url{data[i]};
|
||||
if (url.isLocalFile()) {
|
||||
path = url.toLocalFile();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!path.isEmpty()) {
|
||||
previewDialog_.setPreview(path);
|
||||
} else {
|
||||
qWarning() << "Clipboard does not contain any valid file paths:" << data;
|
||||
}
|
||||
} else {
|
||||
QTextEdit::insertFromMimeData(source);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
FilteredTextEdit::stopTyping()
|
||||
{
|
||||
typingTimer_->stop();
|
||||
emit stoppedTyping();
|
||||
}
|
||||
|
||||
QRect
|
||||
FilteredTextEdit::completerRect()
|
||||
{
|
||||
// Move left edge to the beginning of the word
|
||||
auto cursor = textCursor();
|
||||
auto rect = cursorRect();
|
||||
cursor.movePosition(
|
||||
QTextCursor::Left, QTextCursor::MoveAnchor, textAfterPosition(trigger_pos_).length());
|
||||
auto cursor_global_x = viewport()->mapToGlobal(cursorRect(cursor).topLeft()).x();
|
||||
auto rect_global_left = viewport()->mapToGlobal(rect.bottomLeft()).x();
|
||||
auto dx = qAbs(rect_global_left - cursor_global_x);
|
||||
rect.moveLeft(rect.left() - dx);
|
||||
|
||||
auto item_height = completer_->popup()->sizeHintForRow(0);
|
||||
auto max_height = item_height * completer_->maxVisibleItems();
|
||||
auto height = (completer_->completionCount() > completer_->maxVisibleItems())
|
||||
? max_height
|
||||
: completer_->completionCount() * item_height;
|
||||
rect.setWidth(completer_->popup()->sizeHintForColumn(0));
|
||||
rect.moveBottom(-height);
|
||||
return rect;
|
||||
}
|
||||
|
||||
QSize
|
||||
FilteredTextEdit::sizeHint() const
|
||||
{
|
||||
ensurePolished();
|
||||
auto margins = viewportMargins();
|
||||
margins += document()->documentMargin();
|
||||
QSize size = document()->size().toSize();
|
||||
size.rwidth() += margins.left() + margins.right();
|
||||
size.rheight() += margins.top() + margins.bottom();
|
||||
return size;
|
||||
}
|
||||
|
||||
QSize
|
||||
FilteredTextEdit::minimumSizeHint() const
|
||||
{
|
||||
ensurePolished();
|
||||
auto margins = viewportMargins();
|
||||
margins += document()->documentMargin();
|
||||
margins += contentsMargins();
|
||||
QSize size(fontMetrics().averageCharWidth() * 10,
|
||||
fontMetrics().lineSpacing() + margins.top() + margins.bottom());
|
||||
return size;
|
||||
}
|
||||
|
||||
void
|
||||
FilteredTextEdit::submit()
|
||||
{
|
||||
if (toPlainText().trimmed().isEmpty())
|
||||
return;
|
||||
|
||||
if (true_history_.size() == INPUT_HISTORY_SIZE)
|
||||
true_history_.pop_back();
|
||||
true_history_.push_front(toPlainText());
|
||||
working_history_ = true_history_;
|
||||
working_history_.push_front("");
|
||||
history_index_ = 0;
|
||||
|
||||
QString text = toPlainText();
|
||||
|
||||
if (text.startsWith('/')) {
|
||||
int command_end = text.indexOf(' ');
|
||||
if (command_end == -1)
|
||||
command_end = text.size();
|
||||
auto name = text.mid(1, command_end - 1);
|
||||
auto args = text.mid(command_end + 1);
|
||||
if (name.isEmpty() || name == "/") {
|
||||
message(args);
|
||||
} else {
|
||||
command(name, args);
|
||||
}
|
||||
} else {
|
||||
message(std::move(text));
|
||||
}
|
||||
|
||||
clear();
|
||||
}
|
||||
|
||||
void
|
||||
FilteredTextEdit::textChanged()
|
||||
{
|
||||
working_history_[history_index_] = toPlainText();
|
||||
}
|
||||
|
||||
void
|
||||
FilteredTextEdit::uploadData(const QByteArray data,
|
||||
const QString &mediaType,
|
||||
const QString &filename)
|
||||
{
|
||||
QSharedPointer<QBuffer> buffer{new QBuffer{this}};
|
||||
buffer->setData(data);
|
||||
|
||||
emit startedUpload();
|
||||
|
||||
emit media(buffer, mediaType, filename);
|
||||
}
|
||||
|
||||
void
|
||||
FilteredTextEdit::showPreview(const QMimeData *source, const QStringList &formats)
|
||||
{
|
||||
// Retrieve data as MIME type.
|
||||
auto const &mime = formats.first();
|
||||
QByteArray data = source->data(mime);
|
||||
previewDialog_.setPreview(data, mime);
|
||||
}
|
||||
|
||||
TextInputWidget::TextInputWidget(QWidget *parent)
|
||||
: QWidget(parent)
|
||||
{
|
||||
QFont f;
|
||||
f.setPointSizeF(f.pointSizeF());
|
||||
const int fontHeight = QFontMetrics(f).height();
|
||||
const int contentHeight = static_cast<int>(fontHeight * 2.5);
|
||||
const int InputHeight = static_cast<int>(fontHeight * 1.5);
|
||||
|
||||
setFixedHeight(contentHeight);
|
||||
setCursor(Qt::ArrowCursor);
|
||||
|
||||
topLayout_ = new QHBoxLayout();
|
||||
topLayout_->setSpacing(0);
|
||||
topLayout_->setContentsMargins(13, 1, 13, 0);
|
||||
|
||||
#ifdef GSTREAMER_AVAILABLE
|
||||
callBtn_ = new FlatButton(this);
|
||||
changeCallButtonState(webrtc::State::DISCONNECTED);
|
||||
connect(&WebRTCSession::instance(),
|
||||
&WebRTCSession::stateChanged,
|
||||
this,
|
||||
&TextInputWidget::changeCallButtonState);
|
||||
#endif
|
||||
|
||||
QIcon send_file_icon;
|
||||
send_file_icon.addFile(":/icons/icons/ui/paper-clip-outline.png");
|
||||
|
||||
sendFileBtn_ = new FlatButton(this);
|
||||
sendFileBtn_->setToolTip(tr("Send a file"));
|
||||
sendFileBtn_->setIcon(send_file_icon);
|
||||
sendFileBtn_->setIconSize(QSize(ButtonHeight, ButtonHeight));
|
||||
|
||||
spinner_ = new LoadingIndicator(this);
|
||||
spinner_->setFixedHeight(InputHeight);
|
||||
spinner_->setFixedWidth(InputHeight);
|
||||
spinner_->setObjectName("FileUploadSpinner");
|
||||
spinner_->hide();
|
||||
|
||||
input_ = new FilteredTextEdit(this);
|
||||
input_->setFixedHeight(InputHeight);
|
||||
input_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
input_->setPlaceholderText(tr("Write a message..."));
|
||||
|
||||
connect(input_,
|
||||
&FilteredTextEdit::heightChanged,
|
||||
this,
|
||||
[this, InputHeight, contentHeight](int height) {
|
||||
int widgetHeight =
|
||||
std::min(MAX_TEXTINPUT_HEIGHT, std::max(height, contentHeight));
|
||||
int textInputHeight =
|
||||
std::min(widgetHeight - 1, std::max(height, InputHeight));
|
||||
|
||||
setFixedHeight(widgetHeight);
|
||||
input_->setFixedHeight(textInputHeight);
|
||||
|
||||
emit heightChanged(widgetHeight);
|
||||
});
|
||||
connect(input_, &FilteredTextEdit::showSuggestions, this, [this](const QString &q) {
|
||||
if (q.isEmpty())
|
||||
return;
|
||||
|
||||
QtConcurrent::run([this, q = q.toLower().toStdString()]() {
|
||||
try {
|
||||
emit input_->resultsRetrieved(cache::searchUsers(
|
||||
ChatPage::instance()->currentRoom().toStdString(), q));
|
||||
} catch (const lmdb::error &e) {
|
||||
nhlog::db()->error("Suggestion retrieval failed: {}", e.what());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
sendMessageBtn_ = new FlatButton(this);
|
||||
sendMessageBtn_->setToolTip(tr("Send a message"));
|
||||
|
||||
QIcon send_message_icon;
|
||||
send_message_icon.addFile(":/icons/icons/ui/cursor.png");
|
||||
sendMessageBtn_->setIcon(send_message_icon);
|
||||
sendMessageBtn_->setIconSize(QSize(ButtonHeight, ButtonHeight));
|
||||
|
||||
emojiBtn_ = new emoji::PickButton(this);
|
||||
emojiBtn_->setToolTip(tr("Emoji"));
|
||||
|
||||
#if defined(Q_OS_MAC)
|
||||
// macOS has a native emoji picker.
|
||||
emojiBtn_->hide();
|
||||
#endif
|
||||
|
||||
QIcon emoji_icon;
|
||||
emoji_icon.addFile(":/icons/icons/ui/smile.png");
|
||||
emojiBtn_->setIcon(emoji_icon);
|
||||
emojiBtn_->setIconSize(QSize(ButtonHeight, ButtonHeight));
|
||||
|
||||
#ifdef GSTREAMER_AVAILABLE
|
||||
topLayout_->addWidget(callBtn_);
|
||||
#endif
|
||||
topLayout_->addWidget(sendFileBtn_);
|
||||
topLayout_->addWidget(input_);
|
||||
topLayout_->addWidget(emojiBtn_);
|
||||
topLayout_->addWidget(sendMessageBtn_);
|
||||
|
||||
setLayout(topLayout_);
|
||||
|
||||
#ifdef GSTREAMER_AVAILABLE
|
||||
connect(callBtn_, &FlatButton::clicked, this, &TextInputWidget::callButtonPress);
|
||||
#endif
|
||||
connect(sendMessageBtn_, &FlatButton::clicked, input_, &FilteredTextEdit::submit);
|
||||
connect(sendFileBtn_, SIGNAL(clicked()), this, SLOT(openFileSelection()));
|
||||
connect(input_, &FilteredTextEdit::message, this, &TextInputWidget::sendTextMessage);
|
||||
connect(input_, &FilteredTextEdit::command, this, &TextInputWidget::command);
|
||||
connect(input_, &FilteredTextEdit::media, this, &TextInputWidget::uploadMedia);
|
||||
connect(emojiBtn_,
|
||||
SIGNAL(emojiSelected(const QString &)),
|
||||
this,
|
||||
SLOT(addSelectedEmoji(const QString &)));
|
||||
|
||||
connect(input_, &FilteredTextEdit::startedTyping, this, &TextInputWidget::startedTyping);
|
||||
|
||||
connect(input_, &FilteredTextEdit::stoppedTyping, this, &TextInputWidget::stoppedTyping);
|
||||
|
||||
connect(
|
||||
input_, &FilteredTextEdit::startedUpload, this, &TextInputWidget::showUploadSpinner);
|
||||
}
|
||||
|
||||
void
|
||||
TextInputWidget::addSelectedEmoji(const QString &emoji)
|
||||
{
|
||||
QTextCursor cursor = input_->textCursor();
|
||||
|
||||
QTextCharFormat charfmt;
|
||||
input_->setCurrentCharFormat(charfmt);
|
||||
|
||||
input_->insertPlainText(emoji);
|
||||
cursor.movePosition(QTextCursor::End);
|
||||
|
||||
input_->setCurrentCharFormat(charfmt);
|
||||
|
||||
input_->show();
|
||||
}
|
||||
|
||||
void
|
||||
TextInputWidget::command(QString command, QString args)
|
||||
{
|
||||
if (command == "me") {
|
||||
emit sendEmoteMessage(args);
|
||||
} else if (command == "join") {
|
||||
emit sendJoinRoomRequest(args);
|
||||
} else if (command == "invite") {
|
||||
emit sendInviteRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
||||
} else if (command == "kick") {
|
||||
emit sendKickRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
||||
} else if (command == "ban") {
|
||||
emit sendBanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
||||
} else if (command == "unban") {
|
||||
emit sendUnbanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
||||
} else if (command == "roomnick") {
|
||||
emit changeRoomNick(args);
|
||||
} else if (command == "shrug") {
|
||||
emit sendTextMessage("¯\\_(ツ)_/¯" + (args.isEmpty() ? "" : " " + args));
|
||||
} else if (command == "fliptable") {
|
||||
emit sendTextMessage("(╯°□°)╯︵ ┻━┻");
|
||||
} else if (command == "unfliptable") {
|
||||
emit sendTextMessage(" ┯━┯╭( º _ º╭)");
|
||||
} else if (command == "sovietflip") {
|
||||
emit sendTextMessage("ノ┬─┬ノ ︵ ( \\o°o)\\");
|
||||
} else if (command == "clear-timeline") {
|
||||
emit clearRoomTimeline();
|
||||
} else if (command == "rotate-megolm-session") {
|
||||
emit rotateMegolmSession();
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
TextInputWidget::openFileSelection()
|
||||
{
|
||||
const QString homeFolder = QStandardPaths::writableLocation(QStandardPaths::HomeLocation);
|
||||
const auto fileName =
|
||||
QFileDialog::getOpenFileName(this, tr("Select a file"), homeFolder, tr("All Files (*)"));
|
||||
|
||||
if (fileName.isEmpty())
|
||||
return;
|
||||
|
||||
QMimeDatabase db;
|
||||
QMimeType mime = db.mimeTypeForFile(fileName, QMimeDatabase::MatchContent);
|
||||
|
||||
const auto format = mime.name().split("/")[0];
|
||||
|
||||
QSharedPointer<QFile> file{new QFile{fileName, this}};
|
||||
|
||||
emit uploadMedia(file, format, QFileInfo(fileName).fileName());
|
||||
|
||||
showUploadSpinner();
|
||||
}
|
||||
|
||||
void
|
||||
TextInputWidget::showUploadSpinner()
|
||||
{
|
||||
topLayout_->removeWidget(sendFileBtn_);
|
||||
sendFileBtn_->hide();
|
||||
|
||||
topLayout_->insertWidget(1, spinner_);
|
||||
spinner_->start();
|
||||
}
|
||||
|
||||
void
|
||||
TextInputWidget::hideUploadSpinner()
|
||||
{
|
||||
topLayout_->removeWidget(spinner_);
|
||||
topLayout_->insertWidget(1, sendFileBtn_);
|
||||
sendFileBtn_->show();
|
||||
spinner_->stop();
|
||||
}
|
||||
|
||||
void
|
||||
TextInputWidget::stopTyping()
|
||||
{
|
||||
input_->stopTyping();
|
||||
}
|
||||
|
||||
void
|
||||
TextInputWidget::focusInEvent(QFocusEvent *event)
|
||||
{
|
||||
input_->setFocus(event->reason());
|
||||
}
|
||||
|
||||
void
|
||||
TextInputWidget::paintEvent(QPaintEvent *)
|
||||
{
|
||||
QStyleOption opt;
|
||||
opt.init(this);
|
||||
QPainter p(this);
|
||||
|
||||
style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
|
||||
}
|
||||
|
||||
void
|
||||
TextInputWidget::changeCallButtonState(webrtc::State state)
|
||||
{
|
||||
QIcon icon;
|
||||
if (state == webrtc::State::ICEFAILED || state == webrtc::State::DISCONNECTED) {
|
||||
callBtn_->setToolTip(tr("Place a call"));
|
||||
icon.addFile(":/icons/icons/ui/place-call.png");
|
||||
} else {
|
||||
callBtn_->setToolTip(tr("Hang up"));
|
||||
icon.addFile(":/icons/icons/ui/end-call.png");
|
||||
}
|
||||
callBtn_->setIcon(icon);
|
||||
callBtn_->setIconSize(QSize(ButtonHeight * 1.1, ButtonHeight * 1.1));
|
||||
}
|
|
@ -1,213 +0,0 @@
|
|||
/*
|
||||
* nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <deque>
|
||||
#include <optional>
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QHBoxLayout>
|
||||
#include <QPaintEvent>
|
||||
#include <QTextEdit>
|
||||
#include <QWidget>
|
||||
|
||||
#include "WebRTCSession.h"
|
||||
#include "dialogs/PreviewUploadOverlay.h"
|
||||
#include "emoji/PickButton.h"
|
||||
#include "popups/SuggestionsPopup.h"
|
||||
|
||||
struct SearchResult;
|
||||
|
||||
class CompletionModel;
|
||||
class FlatButton;
|
||||
class LoadingIndicator;
|
||||
class QCompleter;
|
||||
|
||||
class FilteredTextEdit : public QTextEdit
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit FilteredTextEdit(QWidget *parent = nullptr);
|
||||
|
||||
void stopTyping();
|
||||
|
||||
QSize sizeHint() const override;
|
||||
QSize minimumSizeHint() const override;
|
||||
|
||||
void submit();
|
||||
|
||||
signals:
|
||||
void heightChanged(int height);
|
||||
void startedTyping();
|
||||
void stoppedTyping();
|
||||
void startedUpload();
|
||||
void message(QString msg);
|
||||
void command(QString name, QString args);
|
||||
void media(QSharedPointer<QIODevice> data, QString mimeClass, const QString &filename);
|
||||
|
||||
//! Trigger the suggestion popup.
|
||||
void showSuggestions(const QString &query);
|
||||
void resultsRetrieved(const std::vector<SearchResult> &results);
|
||||
void selectNextSuggestion();
|
||||
void selectPreviousSuggestion();
|
||||
void selectHoveredSuggestion();
|
||||
|
||||
public slots:
|
||||
void showResults(const std::vector<SearchResult> &results);
|
||||
|
||||
protected:
|
||||
void keyPressEvent(QKeyEvent *event) override;
|
||||
bool canInsertFromMimeData(const QMimeData *source) const override;
|
||||
void insertFromMimeData(const QMimeData *source) override;
|
||||
void focusOutEvent(QFocusEvent *event) override
|
||||
{
|
||||
suggestionsPopup_.hide();
|
||||
QTextEdit::focusOutEvent(event);
|
||||
}
|
||||
|
||||
private:
|
||||
bool emoji_popup_open_ = false;
|
||||
CompletionModel *emoji_completion_model_;
|
||||
std::deque<QString> true_history_, working_history_;
|
||||
int trigger_pos_; // Where emoji completer was triggered
|
||||
size_t history_index_;
|
||||
QCompleter *completer_;
|
||||
QTimer *typingTimer_;
|
||||
|
||||
SuggestionsPopup suggestionsPopup_;
|
||||
|
||||
enum class AnchorType
|
||||
{
|
||||
Tab = 0,
|
||||
Sigil = 1,
|
||||
};
|
||||
|
||||
AnchorType anchorType_ = AnchorType::Sigil;
|
||||
|
||||
int anchorWidth(AnchorType anchor) { return static_cast<int>(anchor); }
|
||||
|
||||
void closeSuggestions() { suggestionsPopup_.hide(); }
|
||||
void resetAnchor() { atTriggerPosition_ = -1; }
|
||||
bool isAnchorValid() { return atTriggerPosition_ != -1; }
|
||||
bool hasAnchor(int pos, AnchorType anchor)
|
||||
{
|
||||
return pos == atTriggerPosition_ + anchorWidth(anchor);
|
||||
}
|
||||
QRect completerRect();
|
||||
QString query()
|
||||
{
|
||||
auto cursor = textCursor();
|
||||
cursor.movePosition(QTextCursor::StartOfWord, QTextCursor::KeepAnchor);
|
||||
return cursor.selectedText();
|
||||
}
|
||||
QString textAfterPosition(int pos)
|
||||
{
|
||||
auto tc = textCursor();
|
||||
tc.setPosition(pos);
|
||||
tc.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
|
||||
return tc.selectedText();
|
||||
}
|
||||
|
||||
dialogs::PreviewUploadOverlay previewDialog_;
|
||||
|
||||
//! Latest position of the '@' character that triggers the username completer.
|
||||
int atTriggerPosition_ = -1;
|
||||
|
||||
void insertCompletion(QString completion);
|
||||
void textChanged();
|
||||
void uploadData(const QByteArray data, const QString &media, const QString &filename);
|
||||
void afterCompletion(int);
|
||||
void showPreview(const QMimeData *source, const QStringList &formats);
|
||||
};
|
||||
|
||||
class TextInputWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
Q_PROPERTY(QColor borderColor READ borderColor WRITE setBorderColor)
|
||||
|
||||
public:
|
||||
TextInputWidget(QWidget *parent = nullptr);
|
||||
|
||||
void stopTyping();
|
||||
|
||||
QColor borderColor() const { return borderColor_; }
|
||||
void setBorderColor(QColor &color) { borderColor_ = color; }
|
||||
void disableInput()
|
||||
{
|
||||
input_->setEnabled(false);
|
||||
input_->setPlaceholderText(tr("Connection lost. Nheko is trying to re-connect..."));
|
||||
}
|
||||
void enableInput()
|
||||
{
|
||||
input_->setEnabled(true);
|
||||
input_->setPlaceholderText(tr("Write a message..."));
|
||||
}
|
||||
|
||||
public slots:
|
||||
void openFileSelection();
|
||||
void hideUploadSpinner();
|
||||
void focusLineEdit() { input_->setFocus(); }
|
||||
void changeCallButtonState(webrtc::State);
|
||||
|
||||
private slots:
|
||||
void addSelectedEmoji(const QString &emoji);
|
||||
|
||||
signals:
|
||||
void sendTextMessage(const QString &msg);
|
||||
void sendEmoteMessage(QString msg);
|
||||
void clearRoomTimeline();
|
||||
void heightChanged(int height);
|
||||
|
||||
void uploadMedia(const QSharedPointer<QIODevice> data,
|
||||
QString mimeClass,
|
||||
const QString &filename);
|
||||
void callButtonPress();
|
||||
|
||||
void sendJoinRoomRequest(const QString &room);
|
||||
void sendInviteRoomRequest(const QString &userid, const QString &reason);
|
||||
void sendKickRoomRequest(const QString &userid, const QString &reason);
|
||||
void sendBanRoomRequest(const QString &userid, const QString &reason);
|
||||
void sendUnbanRoomRequest(const QString &userid, const QString &reason);
|
||||
void changeRoomNick(const QString &displayname);
|
||||
void rotateMegolmSession();
|
||||
|
||||
void startedTyping();
|
||||
void stoppedTyping();
|
||||
|
||||
protected:
|
||||
void focusInEvent(QFocusEvent *event) override;
|
||||
void paintEvent(QPaintEvent *) override;
|
||||
|
||||
private:
|
||||
void showUploadSpinner();
|
||||
void command(QString name, QString args);
|
||||
|
||||
QHBoxLayout *topLayout_;
|
||||
FilteredTextEdit *input_;
|
||||
|
||||
LoadingIndicator *spinner_;
|
||||
|
||||
FlatButton *callBtn_;
|
||||
FlatButton *sendFileBtn_;
|
||||
FlatButton *sendMessageBtn_;
|
||||
emoji::PickButton *emojiBtn_;
|
||||
|
||||
QColor borderColor_;
|
||||
};
|
53
src/UsersModel.cpp
Normal file
53
src/UsersModel.cpp
Normal file
|
@ -0,0 +1,53 @@
|
|||
#include "UsersModel.h"
|
||||
|
||||
#include "Cache.h"
|
||||
#include "CompletionModelRoles.h"
|
||||
|
||||
UsersModel::UsersModel(const std::string &roomId, QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
, room_id(roomId)
|
||||
{
|
||||
roomMembers_ = cache::roomMembers(roomId);
|
||||
for (const auto &m : roomMembers_) {
|
||||
displayNames.push_back(QString::fromStdString(cache::displayName(room_id, m)));
|
||||
userids.push_back(QString::fromStdString(m));
|
||||
}
|
||||
}
|
||||
|
||||
QHash<int, QByteArray>
|
||||
UsersModel::roleNames() const
|
||||
{
|
||||
return {
|
||||
{CompletionModel::CompletionRole, "completionRole"},
|
||||
{CompletionModel::SearchRole, "searchRole"},
|
||||
{CompletionModel::SearchRole2, "searchRole2"},
|
||||
{Roles::DisplayName, "displayName"},
|
||||
{Roles::AvatarUrl, "avatarUrl"},
|
||||
{Roles::UserID, "userid"},
|
||||
};
|
||||
}
|
||||
|
||||
QVariant
|
||||
UsersModel::data(const QModelIndex &index, int role) const
|
||||
{
|
||||
if (hasIndex(index.row(), index.column(), index.parent())) {
|
||||
switch (role) {
|
||||
case CompletionModel::CompletionRole:
|
||||
return QString("[%1](https://matrix.to/#/%2)")
|
||||
.arg(displayNames[index.row()])
|
||||
.arg(userids[index.row()]);
|
||||
case CompletionModel::SearchRole:
|
||||
case Qt::DisplayRole:
|
||||
case Roles::DisplayName:
|
||||
return displayNames[index.row()];
|
||||
case CompletionModel::SearchRole2:
|
||||
return userids[index.row()];
|
||||
case Roles::AvatarUrl:
|
||||
return cache::avatarUrl(QString::fromStdString(room_id),
|
||||
QString::fromStdString(roomMembers_[index.row()]));
|
||||
case Roles::UserID:
|
||||
return userids[index.row()];
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
29
src/UsersModel.h
Normal file
29
src/UsersModel.h
Normal file
|
@ -0,0 +1,29 @@
|
|||
#pragma once
|
||||
|
||||
#include <QAbstractListModel>
|
||||
|
||||
class UsersModel : public QAbstractListModel
|
||||
{
|
||||
public:
|
||||
enum Roles
|
||||
{
|
||||
AvatarUrl = Qt::UserRole,
|
||||
DisplayName,
|
||||
UserID,
|
||||
};
|
||||
|
||||
UsersModel(const std::string &roomId, QObject *parent = nullptr);
|
||||
QHash<int, QByteArray> roleNames() const override;
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override
|
||||
{
|
||||
(void)parent;
|
||||
return roomMembers_.size();
|
||||
}
|
||||
QVariant data(const QModelIndex &index, int role) const override;
|
||||
|
||||
private:
|
||||
std::string room_id;
|
||||
std::vector<std::string> roomMembers_;
|
||||
std::vector<QString> displayNames;
|
||||
std::vector<QString> userids;
|
||||
};
|
|
@ -677,9 +677,10 @@ utils::restoreCombobox(QComboBox *combo, const QString &value)
|
|||
}
|
||||
|
||||
QImage
|
||||
utils::readImage(QByteArray *data)
|
||||
utils::readImage(const QByteArray *data)
|
||||
{
|
||||
QBuffer buf(data);
|
||||
QBuffer buf;
|
||||
buf.setData(*data);
|
||||
QImageReader reader(&buf);
|
||||
reader.setAutoTransform(true);
|
||||
return reader.read();
|
||||
|
|
|
@ -303,5 +303,5 @@ restoreCombobox(QComboBox *combo, const QString &value);
|
|||
|
||||
//! Read image respecting exif orientation
|
||||
QImage
|
||||
readImage(QByteArray *data);
|
||||
readImage(const QByteArray *data);
|
||||
}
|
||||
|
|
|
@ -60,7 +60,10 @@ PreviewUploadOverlay::PreviewUploadOverlay(QWidget *parent)
|
|||
emit confirmUpload(data_, mediaType_, fileName_.text());
|
||||
close();
|
||||
});
|
||||
connect(&cancel_, &QPushButton::clicked, this, &PreviewUploadOverlay::close);
|
||||
connect(&cancel_, &QPushButton::clicked, this, [this]() {
|
||||
emit aborted();
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
void
|
||||
|
@ -115,7 +118,7 @@ PreviewUploadOverlay::init()
|
|||
void
|
||||
PreviewUploadOverlay::setLabels(const QString &type, const QString &mime, uint64_t upload_size)
|
||||
{
|
||||
if (mediaType_ == "image") {
|
||||
if (mediaType_.split('/')[0] == "image") {
|
||||
if (!image_.loadFromData(data_)) {
|
||||
titleLabel_.setText(QString{tr(ERR_MSG)}.arg(type));
|
||||
} else {
|
||||
|
@ -151,7 +154,7 @@ PreviewUploadOverlay::setPreview(const QImage &src, const QString &mime)
|
|||
else
|
||||
titleLabel_.setText(QString{tr(ERR_MSG)}.arg(type));
|
||||
|
||||
mediaType_ = split[0];
|
||||
mediaType_ = mime;
|
||||
filePath_ = "clipboard." + type;
|
||||
image_.convertFromImage(src);
|
||||
isImage_ = true;
|
||||
|
@ -167,7 +170,7 @@ PreviewUploadOverlay::setPreview(const QByteArray data, const QString &mime)
|
|||
auto const &type = split[1];
|
||||
|
||||
data_ = data;
|
||||
mediaType_ = split[0];
|
||||
mediaType_ = mime;
|
||||
filePath_ = "clipboard." + type;
|
||||
isImage_ = false;
|
||||
|
||||
|
@ -199,7 +202,7 @@ PreviewUploadOverlay::setPreview(const QString &path)
|
|||
|
||||
auto const &split = mime.name().split('/');
|
||||
|
||||
mediaType_ = split[0];
|
||||
mediaType_ = mime.name();
|
||||
filePath_ = file.fileName();
|
||||
isImage_ = false;
|
||||
|
||||
|
|
|
@ -40,6 +40,7 @@ public:
|
|||
|
||||
signals:
|
||||
void confirmUpload(const QByteArray data, const QString &media, const QString &filename);
|
||||
void aborted();
|
||||
|
||||
private:
|
||||
void init();
|
||||
|
|
|
@ -1,95 +0,0 @@
|
|||
/*
|
||||
* nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <QLabel>
|
||||
#include <QListView>
|
||||
#include <QPainter>
|
||||
#include <QScrollBar>
|
||||
#include <QStyleOption>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "Config.h"
|
||||
|
||||
#include "emoji/Category.h"
|
||||
|
||||
using namespace emoji;
|
||||
|
||||
Category::Category(QString category, std::vector<Emoji> emoji, QWidget *parent)
|
||||
: QWidget(parent)
|
||||
{
|
||||
mainLayout_ = new QVBoxLayout(this);
|
||||
mainLayout_->setMargin(0);
|
||||
mainLayout_->setSpacing(0);
|
||||
|
||||
emojiListView_ = new QListView();
|
||||
itemModel_ = new QStandardItemModel(this);
|
||||
|
||||
delegate_ = new ItemDelegate(this);
|
||||
data_ = new Emoji;
|
||||
|
||||
emojiListView_->setItemDelegate(delegate_);
|
||||
emojiListView_->setModel(itemModel_);
|
||||
emojiListView_->setViewMode(QListView::IconMode);
|
||||
emojiListView_->setFlow(QListView::LeftToRight);
|
||||
emojiListView_->setResizeMode(QListView::Adjust);
|
||||
emojiListView_->setMouseTracking(true);
|
||||
emojiListView_->verticalScrollBar()->setEnabled(false);
|
||||
emojiListView_->horizontalScrollBar()->setEnabled(false);
|
||||
|
||||
const int cols = 7;
|
||||
const int rows = emoji.size() / 7 + 1;
|
||||
|
||||
const int emojiSize = 48;
|
||||
const int gridSize = emojiSize + 4;
|
||||
// TODO: Be precise here. Take the parent into consideration.
|
||||
emojiListView_->setFixedSize(cols * gridSize + 20, rows * gridSize);
|
||||
emojiListView_->setGridSize(QSize(gridSize, gridSize));
|
||||
emojiListView_->setDragEnabled(false);
|
||||
emojiListView_->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
|
||||
for (const auto &e : emoji) {
|
||||
data_->unicode = e.unicode;
|
||||
|
||||
auto item = new QStandardItem;
|
||||
item->setSizeHint(QSize(emojiSize, emojiSize));
|
||||
|
||||
QVariant unicode(data_->unicode);
|
||||
item->setData(unicode.toString(), Qt::UserRole);
|
||||
|
||||
itemModel_->appendRow(item);
|
||||
}
|
||||
|
||||
QFont font;
|
||||
font.setWeight(QFont::Medium);
|
||||
|
||||
category_ = new QLabel(category, this);
|
||||
category_->setFont(font);
|
||||
|
||||
mainLayout_->addWidget(category_);
|
||||
mainLayout_->addWidget(emojiListView_);
|
||||
|
||||
connect(emojiListView_, &QListView::clicked, this, &Category::clickIndex);
|
||||
}
|
||||
|
||||
void
|
||||
Category::paintEvent(QPaintEvent *)
|
||||
{
|
||||
QStyleOption opt;
|
||||
opt.init(this);
|
||||
QPainter p(this);
|
||||
style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
|
||||
}
|
|
@ -1,67 +0,0 @@
|
|||
/*
|
||||
* nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QColor>
|
||||
|
||||
#include "ItemDelegate.h"
|
||||
|
||||
class QLabel;
|
||||
class QListView;
|
||||
class QStandardItemModel;
|
||||
class QVBoxLayout;
|
||||
|
||||
namespace emoji {
|
||||
|
||||
class Category : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(
|
||||
QColor hoverBackgroundColor READ hoverBackgroundColor WRITE setHoverBackgroundColor)
|
||||
|
||||
public:
|
||||
Category(QString category, std::vector<Emoji> emoji, QWidget *parent = nullptr);
|
||||
QColor hoverBackgroundColor() const { return hoverBackgroundColor_; }
|
||||
void setHoverBackgroundColor(QColor color) { hoverBackgroundColor_ = color; }
|
||||
|
||||
signals:
|
||||
void emojiSelected(const QString &emoji);
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
|
||||
private slots:
|
||||
void clickIndex(const QModelIndex &index)
|
||||
{
|
||||
emit emojiSelected(index.data(Qt::UserRole).toString());
|
||||
};
|
||||
|
||||
private:
|
||||
QVBoxLayout *mainLayout_;
|
||||
|
||||
QStandardItemModel *itemModel_;
|
||||
QListView *emojiListView_;
|
||||
|
||||
emoji::Emoji *data_;
|
||||
emoji::ItemDelegate *delegate_;
|
||||
|
||||
QLabel *category_;
|
||||
|
||||
QColor hoverBackgroundColor_;
|
||||
};
|
||||
} // namespace emoji
|
|
@ -3,6 +3,8 @@
|
|||
#include <Cache.h>
|
||||
#include <MatrixClient.h>
|
||||
|
||||
#include "CompletionModelRoles.h"
|
||||
|
||||
using namespace emoji;
|
||||
|
||||
QHash<int, QByteArray>
|
||||
|
@ -35,10 +37,12 @@ EmojiModel::data(const QModelIndex &index, int role) const
|
|||
if (hasIndex(index.row(), index.column(), index.parent())) {
|
||||
switch (role) {
|
||||
case Qt::DisplayRole:
|
||||
case CompletionModel::CompletionRole:
|
||||
case static_cast<int>(EmojiModel::Roles::Unicode):
|
||||
return Provider::emoji[index.row()].unicode;
|
||||
|
||||
case Qt::ToolTipRole:
|
||||
case CompletionModel::SearchRole:
|
||||
case static_cast<int>(EmojiModel::Roles::ShortName):
|
||||
return Provider::emoji[index.row()].shortName;
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include "EmojiModel.h"
|
||||
|
||||
#include <CompletionModelRoles.h>
|
||||
#include <QDebug>
|
||||
#include <QEvent>
|
||||
#include <QSortFilterProxyModel>
|
||||
|
@ -19,12 +20,21 @@ public:
|
|||
}
|
||||
QVariant data(const QModelIndex &index, int role = Qt::UserRole + 1) const override
|
||||
{
|
||||
if (role == Qt::DisplayRole) {
|
||||
switch (role) {
|
||||
case Qt::DisplayRole: {
|
||||
auto emoji = QSortFilterProxyModel::data(index, role).toString();
|
||||
return emoji + " :" +
|
||||
toShortcode(data(index, EmojiModel::ShortName).toString()) + ":";
|
||||
}
|
||||
return QSortFilterProxyModel::data(index, role);
|
||||
case CompletionModel::CompletionRole:
|
||||
return QSortFilterProxyModel::data(index, EmojiModel::Unicode);
|
||||
case CompletionModel::SearchRole: {
|
||||
return toShortcode(
|
||||
QSortFilterProxyModel::data(index, EmojiModel::ShortName).toString());
|
||||
}
|
||||
default:
|
||||
return QSortFilterProxyModel::data(index, role);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
/*
|
||||
* nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <QPainter>
|
||||
#include <QSettings>
|
||||
|
||||
#include "emoji/ItemDelegate.h"
|
||||
|
||||
using namespace emoji;
|
||||
|
||||
ItemDelegate::ItemDelegate(QObject *parent)
|
||||
: QStyledItemDelegate(parent)
|
||||
{
|
||||
data_ = new Emoji;
|
||||
}
|
||||
|
||||
ItemDelegate::~ItemDelegate() { delete data_; }
|
||||
|
||||
void
|
||||
ItemDelegate::paint(QPainter *painter,
|
||||
const QStyleOptionViewItem &option,
|
||||
const QModelIndex &index) const
|
||||
{
|
||||
Q_UNUSED(index);
|
||||
|
||||
painter->save();
|
||||
|
||||
QStyleOptionViewItem viewOption(option);
|
||||
|
||||
auto emoji = index.data(Qt::UserRole).toString();
|
||||
|
||||
QSettings settings;
|
||||
|
||||
QFont font;
|
||||
QString userFontFamily = settings.value("user/emoji_font_family", "emoji").toString();
|
||||
if (!userFontFamily.isEmpty()) {
|
||||
font.setFamily(userFontFamily);
|
||||
} else {
|
||||
font.setFamily("emoji");
|
||||
}
|
||||
|
||||
font.setPixelSize(36);
|
||||
painter->setFont(font);
|
||||
if (option.state & QStyle::State_MouseOver) {
|
||||
painter->setBackgroundMode(Qt::OpaqueMode);
|
||||
QColor hoverColor = parent()->property("hoverBackgroundColor").value<QColor>();
|
||||
painter->setBackground(hoverColor);
|
||||
}
|
||||
painter->drawText(viewOption.rect, Qt::AlignCenter, emoji);
|
||||
|
||||
painter->restore();
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
/*
|
||||
* nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QModelIndex>
|
||||
#include <QStandardItemModel>
|
||||
#include <QStyledItemDelegate>
|
||||
|
||||
#include "Provider.h"
|
||||
|
||||
namespace emoji {
|
||||
|
||||
class ItemDelegate : public QStyledItemDelegate
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ItemDelegate(QObject *parent = nullptr);
|
||||
~ItemDelegate() override;
|
||||
|
||||
void paint(QPainter *painter,
|
||||
const QStyleOptionViewItem &option,
|
||||
const QModelIndex &index) const override;
|
||||
|
||||
private:
|
||||
Emoji *data_;
|
||||
};
|
||||
} // namespace emoji
|
|
@ -1,231 +0,0 @@
|
|||
/*
|
||||
* nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <QPainter>
|
||||
#include <QPushButton>
|
||||
#include <QScrollBar>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "ui/DropShadow.h"
|
||||
#include "ui/FlatButton.h"
|
||||
|
||||
#include "emoji/Category.h"
|
||||
#include "emoji/Panel.h"
|
||||
|
||||
using namespace emoji;
|
||||
|
||||
Panel::Panel(QWidget *parent)
|
||||
: QWidget(parent)
|
||||
, shadowMargin_{2}
|
||||
, width_{370}
|
||||
, height_{350}
|
||||
, categoryIconSize_{20}
|
||||
{
|
||||
setAttribute(Qt::WA_ShowWithoutActivating, true);
|
||||
setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint);
|
||||
|
||||
auto mainWidget = new QWidget(this);
|
||||
mainWidget->setMaximumSize(width_, height_);
|
||||
|
||||
auto topLayout = new QVBoxLayout(this);
|
||||
topLayout->addWidget(mainWidget);
|
||||
topLayout->setMargin(shadowMargin_);
|
||||
topLayout->setSpacing(0);
|
||||
|
||||
auto contentLayout = new QVBoxLayout(mainWidget);
|
||||
contentLayout->setMargin(0);
|
||||
contentLayout->setSpacing(0);
|
||||
|
||||
auto emojiCategories = new QFrame(mainWidget);
|
||||
|
||||
auto categoriesLayout = new QHBoxLayout(emojiCategories);
|
||||
categoriesLayout->setSpacing(0);
|
||||
categoriesLayout->setMargin(0);
|
||||
|
||||
QIcon icon;
|
||||
|
||||
auto peopleCategory = new FlatButton(emojiCategories);
|
||||
icon.addFile(":/icons/icons/emoji-categories/people.png");
|
||||
peopleCategory->setIcon(icon);
|
||||
peopleCategory->setIconSize(QSize(categoryIconSize_, categoryIconSize_));
|
||||
|
||||
auto natureCategory_ = new FlatButton(emojiCategories);
|
||||
icon.addFile(":/icons/icons/emoji-categories/nature.png");
|
||||
natureCategory_->setIcon(icon);
|
||||
natureCategory_->setIconSize(QSize(categoryIconSize_, categoryIconSize_));
|
||||
|
||||
auto foodCategory_ = new FlatButton(emojiCategories);
|
||||
icon.addFile(":/icons/icons/emoji-categories/foods.png");
|
||||
foodCategory_->setIcon(icon);
|
||||
foodCategory_->setIconSize(QSize(categoryIconSize_, categoryIconSize_));
|
||||
|
||||
auto activityCategory = new FlatButton(emojiCategories);
|
||||
icon.addFile(":/icons/icons/emoji-categories/activity.png");
|
||||
activityCategory->setIcon(icon);
|
||||
activityCategory->setIconSize(QSize(categoryIconSize_, categoryIconSize_));
|
||||
|
||||
auto travelCategory = new FlatButton(emojiCategories);
|
||||
icon.addFile(":/icons/icons/emoji-categories/travel.png");
|
||||
travelCategory->setIcon(icon);
|
||||
travelCategory->setIconSize(QSize(categoryIconSize_, categoryIconSize_));
|
||||
|
||||
auto objectsCategory = new FlatButton(emojiCategories);
|
||||
icon.addFile(":/icons/icons/emoji-categories/objects.png");
|
||||
objectsCategory->setIcon(icon);
|
||||
objectsCategory->setIconSize(QSize(categoryIconSize_, categoryIconSize_));
|
||||
|
||||
auto symbolsCategory = new FlatButton(emojiCategories);
|
||||
icon.addFile(":/icons/icons/emoji-categories/symbols.png");
|
||||
symbolsCategory->setIcon(icon);
|
||||
symbolsCategory->setIconSize(QSize(categoryIconSize_, categoryIconSize_));
|
||||
|
||||
auto flagsCategory = new FlatButton(emojiCategories);
|
||||
icon.addFile(":/icons/icons/emoji-categories/flags.png");
|
||||
flagsCategory->setIcon(icon);
|
||||
flagsCategory->setIconSize(QSize(categoryIconSize_, categoryIconSize_));
|
||||
|
||||
categoriesLayout->addWidget(peopleCategory);
|
||||
categoriesLayout->addWidget(natureCategory_);
|
||||
categoriesLayout->addWidget(foodCategory_);
|
||||
categoriesLayout->addWidget(activityCategory);
|
||||
categoriesLayout->addWidget(travelCategory);
|
||||
categoriesLayout->addWidget(objectsCategory);
|
||||
categoriesLayout->addWidget(symbolsCategory);
|
||||
categoriesLayout->addWidget(flagsCategory);
|
||||
|
||||
scrollArea_ = new QScrollArea(this);
|
||||
scrollArea_->setWidgetResizable(true);
|
||||
scrollArea_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
|
||||
auto scrollWidget = new QWidget(this);
|
||||
auto scrollLayout = new QVBoxLayout(scrollWidget);
|
||||
|
||||
scrollLayout->setMargin(0);
|
||||
scrollLayout->setSpacing(0);
|
||||
scrollArea_->setWidget(scrollWidget);
|
||||
|
||||
auto peopleEmoji =
|
||||
new Category(tr("Smileys & People"), emoji_provider_.people, scrollWidget);
|
||||
scrollLayout->addWidget(peopleEmoji);
|
||||
|
||||
auto natureEmoji =
|
||||
new Category(tr("Animals & Nature"), emoji_provider_.nature, scrollWidget);
|
||||
scrollLayout->addWidget(natureEmoji);
|
||||
|
||||
auto foodEmoji = new Category(tr("Food & Drink"), emoji_provider_.food, scrollWidget);
|
||||
scrollLayout->addWidget(foodEmoji);
|
||||
|
||||
auto activityEmoji = new Category(tr("Activity"), emoji_provider_.activity, scrollWidget);
|
||||
scrollLayout->addWidget(activityEmoji);
|
||||
|
||||
auto travelEmoji =
|
||||
new Category(tr("Travel & Places"), emoji_provider_.travel, scrollWidget);
|
||||
scrollLayout->addWidget(travelEmoji);
|
||||
|
||||
auto objectsEmoji = new Category(tr("Objects"), emoji_provider_.objects, scrollWidget);
|
||||
scrollLayout->addWidget(objectsEmoji);
|
||||
|
||||
auto symbolsEmoji = new Category(tr("Symbols"), emoji_provider_.symbols, scrollWidget);
|
||||
scrollLayout->addWidget(symbolsEmoji);
|
||||
|
||||
auto flagsEmoji = new Category(tr("Flags"), emoji_provider_.flags, scrollWidget);
|
||||
scrollLayout->addWidget(flagsEmoji);
|
||||
|
||||
contentLayout->addWidget(scrollArea_);
|
||||
contentLayout->addWidget(emojiCategories);
|
||||
|
||||
connect(peopleEmoji, &Category::emojiSelected, this, &Panel::emojiSelected);
|
||||
connect(peopleCategory, &QPushButton::clicked, [this, peopleEmoji]() {
|
||||
this->showCategory(peopleEmoji);
|
||||
});
|
||||
|
||||
connect(natureEmoji, &Category::emojiSelected, this, &Panel::emojiSelected);
|
||||
connect(natureCategory_, &QPushButton::clicked, [this, natureEmoji]() {
|
||||
this->showCategory(natureEmoji);
|
||||
});
|
||||
|
||||
connect(foodEmoji, &Category::emojiSelected, this, &Panel::emojiSelected);
|
||||
connect(foodCategory_, &QPushButton::clicked, [this, foodEmoji]() {
|
||||
this->showCategory(foodEmoji);
|
||||
});
|
||||
|
||||
connect(activityEmoji, &Category::emojiSelected, this, &Panel::emojiSelected);
|
||||
connect(activityCategory, &QPushButton::clicked, [this, activityEmoji]() {
|
||||
this->showCategory(activityEmoji);
|
||||
});
|
||||
|
||||
connect(travelEmoji, &Category::emojiSelected, this, &Panel::emojiSelected);
|
||||
connect(travelCategory, &QPushButton::clicked, [this, travelEmoji]() {
|
||||
this->showCategory(travelEmoji);
|
||||
});
|
||||
|
||||
connect(objectsEmoji, &Category::emojiSelected, this, &Panel::emojiSelected);
|
||||
connect(objectsCategory, &QPushButton::clicked, [this, objectsEmoji]() {
|
||||
this->showCategory(objectsEmoji);
|
||||
});
|
||||
|
||||
connect(symbolsEmoji, &Category::emojiSelected, this, &Panel::emojiSelected);
|
||||
connect(symbolsCategory, &QPushButton::clicked, [this, symbolsEmoji]() {
|
||||
this->showCategory(symbolsEmoji);
|
||||
});
|
||||
|
||||
connect(flagsEmoji, &Category::emojiSelected, this, &Panel::emojiSelected);
|
||||
connect(flagsCategory, &QPushButton::clicked, [this, flagsEmoji]() {
|
||||
this->showCategory(flagsEmoji);
|
||||
});
|
||||
}
|
||||
|
||||
void
|
||||
Panel::showCategory(const Category *category)
|
||||
{
|
||||
auto posToGo = category->mapToParent(QPoint()).y();
|
||||
auto current = scrollArea_->verticalScrollBar()->value();
|
||||
|
||||
if (current == posToGo)
|
||||
return;
|
||||
|
||||
// HACK
|
||||
// We want the top of the category to be visible, so scroll to the top first and then to the
|
||||
// category
|
||||
if (current > posToGo)
|
||||
this->scrollArea_->ensureVisible(0, 0, 0, 0);
|
||||
|
||||
posToGo += scrollArea_->height();
|
||||
this->scrollArea_->ensureVisible(0, posToGo, 0, 0);
|
||||
}
|
||||
|
||||
void
|
||||
Panel::paintEvent(QPaintEvent *event)
|
||||
{
|
||||
Q_UNUSED(event);
|
||||
|
||||
QStyleOption opt;
|
||||
opt.init(this);
|
||||
QPainter p(this);
|
||||
style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
|
||||
|
||||
DropShadow::draw(p,
|
||||
shadowMargin_,
|
||||
4.0,
|
||||
QColor(120, 120, 120, 92),
|
||||
QColor(255, 255, 255, 0),
|
||||
0.0,
|
||||
1.0,
|
||||
0.6,
|
||||
width(),
|
||||
height());
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
/*
|
||||
* nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QScrollArea>
|
||||
|
||||
#include "Provider.h"
|
||||
|
||||
namespace emoji {
|
||||
|
||||
class Category;
|
||||
|
||||
class Panel : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
Panel(QWidget *parent = nullptr);
|
||||
|
||||
signals:
|
||||
void mouseLeft();
|
||||
void emojiSelected(const QString &emoji);
|
||||
|
||||
protected:
|
||||
void leaveEvent(QEvent *event) override
|
||||
{
|
||||
emit leaving();
|
||||
QWidget::leaveEvent(event);
|
||||
}
|
||||
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
|
||||
signals:
|
||||
void leaving();
|
||||
|
||||
private:
|
||||
void showCategory(const Category *category);
|
||||
|
||||
Provider emoji_provider_;
|
||||
|
||||
QScrollArea *scrollArea_;
|
||||
|
||||
int shadowMargin_;
|
||||
|
||||
// Panel dimensions.
|
||||
int width_;
|
||||
int height_;
|
||||
|
||||
int categoryIconSize_;
|
||||
};
|
||||
} // namespace emoji
|
|
@ -1,82 +0,0 @@
|
|||
/*
|
||||
* nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <QDebug>
|
||||
|
||||
#include "emoji/Panel.h"
|
||||
#include "emoji/PickButton.h"
|
||||
|
||||
using namespace emoji;
|
||||
|
||||
// Number of milliseconds after which the panel will be hidden
|
||||
// if the mouse cursor is not on top of the widget.
|
||||
constexpr int HIDE_TIMEOUT = 300;
|
||||
|
||||
PickButton::PickButton(QWidget *parent)
|
||||
: FlatButton(parent)
|
||||
, panel_{nullptr}
|
||||
{
|
||||
connect(&hideTimer_, &QTimer::timeout, this, &PickButton::hidePanel);
|
||||
connect(this, &QPushButton::clicked, this, [this]() {
|
||||
if (panel_ && panel_->isVisible()) {
|
||||
hidePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
showPanel();
|
||||
});
|
||||
}
|
||||
|
||||
void
|
||||
PickButton::hidePanel()
|
||||
{
|
||||
if (panel_ && !panel_->underMouse()) {
|
||||
hideTimer_.stop();
|
||||
panel_->hide();
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
PickButton::showPanel()
|
||||
{
|
||||
if (panel_.isNull()) {
|
||||
panel_ = QSharedPointer<Panel>(new Panel(this));
|
||||
connect(panel_.data(), &Panel::emojiSelected, this, &PickButton::emojiSelected);
|
||||
connect(panel_.data(), &Panel::leaving, this, [this]() { panel_->hide(); });
|
||||
}
|
||||
|
||||
if (panel_->isVisible())
|
||||
return;
|
||||
|
||||
QPoint pos(rect().x(), rect().y());
|
||||
pos = this->mapToGlobal(pos);
|
||||
|
||||
auto panel_size = panel_->sizeHint();
|
||||
|
||||
auto x = pos.x() - panel_size.width() + horizontal_distance_;
|
||||
auto y = pos.y() - panel_size.height() - vertical_distance_;
|
||||
|
||||
panel_->move(x, y);
|
||||
panel_->show();
|
||||
}
|
||||
|
||||
void
|
||||
PickButton::leaveEvent(QEvent *e)
|
||||
{
|
||||
hideTimer_.start(HIDE_TIMEOUT);
|
||||
FlatButton::leaveEvent(e);
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
/*
|
||||
* nheko Copyright (C) 2017 Konstantinos Sideris <siderisk@auth.gr>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QEvent>
|
||||
#include <QTimer>
|
||||
#include <QWidget>
|
||||
|
||||
#include "ui/FlatButton.h"
|
||||
|
||||
namespace emoji {
|
||||
|
||||
class Panel;
|
||||
|
||||
class PickButton : public FlatButton
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit PickButton(QWidget *parent = nullptr);
|
||||
|
||||
signals:
|
||||
void emojiSelected(const QString &emoji);
|
||||
|
||||
protected:
|
||||
void leaveEvent(QEvent *e) override;
|
||||
|
||||
private:
|
||||
void showPanel();
|
||||
void hidePanel();
|
||||
|
||||
// Vertical distance from panel's bottom.
|
||||
int vertical_distance_ = 10;
|
||||
|
||||
// Horizontal distance from panel's bottom right corner.
|
||||
int horizontal_distance_ = 70;
|
||||
|
||||
QSharedPointer<Panel> panel_;
|
||||
QTimer hideTimer_;
|
||||
};
|
||||
} // namespace emoji
|
File diff suppressed because it is too large
Load diff
|
@ -59,14 +59,6 @@ class Provider
|
|||
public:
|
||||
// all emoji for QML purposes
|
||||
static const QVector<Emoji> emoji;
|
||||
static const std::vector<Emoji> people;
|
||||
static const std::vector<Emoji> nature;
|
||||
static const std::vector<Emoji> food;
|
||||
static const std::vector<Emoji> activity;
|
||||
static const std::vector<Emoji> travel;
|
||||
static const std::vector<Emoji> objects;
|
||||
static const std::vector<Emoji> symbols;
|
||||
static const std::vector<Emoji> flags;
|
||||
};
|
||||
|
||||
} // namespace emoji
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
#include "../Utils.h"
|
||||
#include "../ui/Avatar.h"
|
||||
#include "../ui/DropShadow.h"
|
||||
#include "ChatPage.h"
|
||||
#include "PopupItem.h"
|
||||
#include "SuggestionsPopup.h"
|
||||
|
||||
SuggestionsPopup::SuggestionsPopup(QWidget *parent)
|
||||
|
@ -65,44 +67,6 @@ SuggestionsPopup::addRooms(const std::vector<RoomSearchResult> &rooms)
|
|||
selectNextSuggestion();
|
||||
}
|
||||
|
||||
void
|
||||
SuggestionsPopup::addUsers(const std::vector<SearchResult> &users)
|
||||
{
|
||||
if (users.empty()) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t layoutCount = layout_->count();
|
||||
const size_t userCount = users.size();
|
||||
|
||||
// Remove the extra widgets from the layout.
|
||||
if (userCount < layoutCount)
|
||||
removeLayoutItemsAfter(userCount - 1);
|
||||
|
||||
for (size_t i = 0; i < userCount; ++i) {
|
||||
auto item = layout_->itemAt(i);
|
||||
|
||||
// Create a new widget if there isn't already one in that
|
||||
// layout position.
|
||||
if (!item) {
|
||||
auto user = new UserItem(this, users.at(i).user_id);
|
||||
connect(user, &UserItem::clicked, this, &SuggestionsPopup::itemSelected);
|
||||
layout_->addWidget(user);
|
||||
} else {
|
||||
// Update the current widget with the new data.
|
||||
auto userWidget = qobject_cast<UserItem *>(item->widget());
|
||||
if (userWidget)
|
||||
userWidget->updateItem(users.at(i).user_id);
|
||||
}
|
||||
}
|
||||
|
||||
resetSelection();
|
||||
adjustSize();
|
||||
|
||||
selectNextSuggestion();
|
||||
}
|
||||
|
||||
void
|
||||
SuggestionsPopup::hoverSelection()
|
||||
{
|
||||
|
@ -111,6 +75,19 @@ SuggestionsPopup::hoverSelection()
|
|||
update();
|
||||
}
|
||||
|
||||
void
|
||||
SuggestionsPopup::selectHoveredSuggestion()
|
||||
{
|
||||
const auto item = layout_->itemAt(selectedItem_);
|
||||
if (!item)
|
||||
return;
|
||||
|
||||
const auto &widget = qobject_cast<RoomItem *>(item->widget());
|
||||
emit itemSelected(displayName(ChatPage::instance()->currentRoom(), widget->selectedText()));
|
||||
|
||||
resetSelection();
|
||||
}
|
||||
|
||||
void
|
||||
SuggestionsPopup::selectNextSuggestion()
|
||||
{
|
||||
|
@ -160,3 +137,23 @@ SuggestionsPopup::paintEvent(QPaintEvent *)
|
|||
QPainter p(this);
|
||||
style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
|
||||
}
|
||||
|
||||
void
|
||||
SuggestionsPopup::selectLastItem()
|
||||
{
|
||||
selectedItem_ = layout_->count() - 1;
|
||||
}
|
||||
|
||||
void
|
||||
SuggestionsPopup::removeLayoutItemsAfter(size_t startingPos)
|
||||
{
|
||||
size_t posToRemove = layout_->count() - 1;
|
||||
|
||||
QLayoutItem *item;
|
||||
while (startingPos <= posToRemove && (item = layout_->takeAt(posToRemove)) != nullptr) {
|
||||
delete item->widget();
|
||||
delete item;
|
||||
|
||||
posToRemove = layout_->count() - 1;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,8 +3,9 @@
|
|||
#include <QWidget>
|
||||
|
||||
#include "CacheStructs.h"
|
||||
#include "ChatPage.h"
|
||||
#include "PopupItem.h"
|
||||
|
||||
class QVBoxLayout;
|
||||
class QLayoutItem;
|
||||
|
||||
class SuggestionsPopup : public QWidget
|
||||
{
|
||||
|
@ -13,22 +14,9 @@ class SuggestionsPopup : public QWidget
|
|||
public:
|
||||
explicit SuggestionsPopup(QWidget *parent = nullptr);
|
||||
|
||||
template<class Item>
|
||||
void selectHoveredSuggestion()
|
||||
{
|
||||
const auto item = layout_->itemAt(selectedItem_);
|
||||
if (!item)
|
||||
return;
|
||||
|
||||
const auto &widget = qobject_cast<Item *>(item->widget());
|
||||
emit itemSelected(
|
||||
displayName(ChatPage::instance()->currentRoom(), widget->selectedText()));
|
||||
|
||||
resetSelection();
|
||||
}
|
||||
void selectHoveredSuggestion();
|
||||
|
||||
public slots:
|
||||
void addUsers(const std::vector<SearchResult> &users);
|
||||
void addRooms(const std::vector<RoomSearchResult> &rooms);
|
||||
|
||||
//! Move to the next available suggestion item.
|
||||
|
@ -51,20 +39,8 @@ private:
|
|||
void hoverSelection();
|
||||
void resetSelection() { selectedItem_ = -1; }
|
||||
void selectFirstItem() { selectedItem_ = 0; }
|
||||
void selectLastItem() { selectedItem_ = layout_->count() - 1; }
|
||||
void removeLayoutItemsAfter(size_t startingPos)
|
||||
{
|
||||
size_t posToRemove = layout_->count() - 1;
|
||||
|
||||
QLayoutItem *item;
|
||||
while (startingPos <= posToRemove &&
|
||||
(item = layout_->takeAt(posToRemove)) != nullptr) {
|
||||
delete item->widget();
|
||||
delete item;
|
||||
|
||||
posToRemove = layout_->count() - 1;
|
||||
}
|
||||
}
|
||||
void selectLastItem();
|
||||
void removeLayoutItemsAfter(size_t startingPos);
|
||||
|
||||
QVBoxLayout *layout_;
|
||||
|
||||
|
|
666
src/timeline/InputBar.cpp
Normal file
666
src/timeline/InputBar.cpp
Normal file
|
@ -0,0 +1,666 @@
|
|||
#include "InputBar.h"
|
||||
|
||||
#include <QClipboard>
|
||||
#include <QDropEvent>
|
||||
#include <QFileDialog>
|
||||
#include <QGuiApplication>
|
||||
#include <QMimeData>
|
||||
#include <QMimeDatabase>
|
||||
#include <QStandardPaths>
|
||||
#include <QUrl>
|
||||
|
||||
#include <mtx/responses/common.hpp>
|
||||
#include <mtx/responses/media.hpp>
|
||||
|
||||
#include "Cache.h"
|
||||
#include "CallManager.h"
|
||||
#include "ChatPage.h"
|
||||
#include "CompletionProxyModel.h"
|
||||
#include "Logging.h"
|
||||
#include "MainWindow.h"
|
||||
#include "MatrixClient.h"
|
||||
#include "Olm.h"
|
||||
#include "TimelineModel.h"
|
||||
#include "UserSettingsPage.h"
|
||||
#include "UsersModel.h"
|
||||
#include "Utils.h"
|
||||
#include "dialogs/PlaceCall.h"
|
||||
#include "dialogs/PreviewUploadOverlay.h"
|
||||
#include "emoji/EmojiModel.h"
|
||||
|
||||
#include "blurhash.hpp"
|
||||
|
||||
static constexpr size_t INPUT_HISTORY_SIZE = 10;
|
||||
|
||||
void
|
||||
InputBar::paste(bool fromMouse)
|
||||
{
|
||||
const QMimeData *md = nullptr;
|
||||
|
||||
if (fromMouse) {
|
||||
if (QGuiApplication::clipboard()->supportsSelection()) {
|
||||
md = QGuiApplication::clipboard()->mimeData(QClipboard::Selection);
|
||||
}
|
||||
} else {
|
||||
md = QGuiApplication::clipboard()->mimeData(QClipboard::Clipboard);
|
||||
}
|
||||
|
||||
if (md)
|
||||
insertMimeData(md);
|
||||
}
|
||||
|
||||
void
|
||||
InputBar::insertMimeData(const QMimeData *md)
|
||||
{
|
||||
if (!md)
|
||||
return;
|
||||
|
||||
nhlog::ui()->debug("Got mime formats: {}", md->formats().join(", ").toStdString());
|
||||
const auto formats = md->formats().filter("/");
|
||||
const auto image = formats.filter("image/", Qt::CaseInsensitive);
|
||||
const auto audio = formats.filter("audio/", Qt::CaseInsensitive);
|
||||
const auto video = formats.filter("video/", Qt::CaseInsensitive);
|
||||
|
||||
if (!image.empty() && md->hasImage()) {
|
||||
showPreview(*md, "", image);
|
||||
} else if (!audio.empty()) {
|
||||
showPreview(*md, "", audio);
|
||||
} else if (!video.empty()) {
|
||||
showPreview(*md, "", video);
|
||||
} else if (md->hasUrls()) {
|
||||
// Generic file path for any platform.
|
||||
QString path;
|
||||
for (auto &&u : md->urls()) {
|
||||
if (u.isLocalFile()) {
|
||||
path = u.toLocalFile();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!path.isEmpty() && QFileInfo{path}.exists()) {
|
||||
showPreview(*md, path, formats);
|
||||
} else {
|
||||
nhlog::ui()->warn("Clipboard does not contain any valid file paths.");
|
||||
}
|
||||
} else if (md->hasFormat("x-special/gnome-copied-files")) {
|
||||
// Special case for X11 users. See "Notes for X11 Users" in md.
|
||||
// Source: http://doc.qt.io/qt-5/qclipboard.html
|
||||
|
||||
// This MIME type returns a string with multiple lines separated by '\n'. The first
|
||||
// line is the command to perform with the clipboard (not useful to us). The
|
||||
// following lines are the file URIs.
|
||||
//
|
||||
// Source: the nautilus source code in file 'src/nautilus-clipboard.c' in function
|
||||
// nautilus_clipboard_get_uri_list_from_selection_data()
|
||||
// https://github.com/GNOME/nautilus/blob/master/src/nautilus-clipboard.c
|
||||
|
||||
auto data = md->data("x-special/gnome-copied-files").split('\n');
|
||||
if (data.size() < 2) {
|
||||
nhlog::ui()->warn("MIME format is malformed, cannot perform paste.");
|
||||
return;
|
||||
}
|
||||
|
||||
QString path;
|
||||
for (int i = 1; i < data.size(); ++i) {
|
||||
QUrl url{data[i]};
|
||||
if (url.isLocalFile()) {
|
||||
path = url.toLocalFile();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!path.isEmpty()) {
|
||||
showPreview(*md, path, formats);
|
||||
} else {
|
||||
nhlog::ui()->warn("Clipboard does not contain any valid file paths: {}",
|
||||
data.join(", ").toStdString());
|
||||
}
|
||||
} else if (md->hasText()) {
|
||||
emit insertText(md->text());
|
||||
} else {
|
||||
nhlog::ui()->debug("formats: {}", md->formats().join(", ").toStdString());
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
InputBar::updateState(int selectionStart_, int selectionEnd_, int cursorPosition_, QString text_)
|
||||
{
|
||||
if (text_.isEmpty())
|
||||
stopTyping();
|
||||
else
|
||||
startTyping();
|
||||
|
||||
if (text_ != text()) {
|
||||
if (history_.empty())
|
||||
history_.push_front(text_);
|
||||
else
|
||||
history_.front() = text_;
|
||||
history_index_ = 0;
|
||||
}
|
||||
|
||||
selectionStart = selectionStart_;
|
||||
selectionEnd = selectionEnd_;
|
||||
cursorPosition = cursorPosition_;
|
||||
}
|
||||
|
||||
QString
|
||||
InputBar::text() const
|
||||
{
|
||||
if (history_index_ < history_.size())
|
||||
return history_.at(history_index_);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
QString
|
||||
InputBar::previousText()
|
||||
{
|
||||
history_index_++;
|
||||
if (history_index_ >= INPUT_HISTORY_SIZE)
|
||||
history_index_ = INPUT_HISTORY_SIZE;
|
||||
else if (text().isEmpty())
|
||||
history_index_--;
|
||||
|
||||
return text();
|
||||
}
|
||||
|
||||
QString
|
||||
InputBar::nextText()
|
||||
{
|
||||
history_index_--;
|
||||
if (history_index_ >= INPUT_HISTORY_SIZE)
|
||||
history_index_ = 0;
|
||||
|
||||
return text();
|
||||
}
|
||||
|
||||
QObject *
|
||||
InputBar::completerFor(QString completerName)
|
||||
{
|
||||
if (completerName == "user") {
|
||||
auto userModel = new UsersModel(room->roomId().toStdString());
|
||||
auto proxy = new CompletionProxyModel(userModel);
|
||||
userModel->setParent(proxy);
|
||||
return proxy;
|
||||
} else if (completerName == "emoji") {
|
||||
auto emojiModel = new emoji::EmojiModel();
|
||||
auto proxy = new CompletionProxyModel(emojiModel);
|
||||
emojiModel->setParent(proxy);
|
||||
return proxy;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void
|
||||
InputBar::send()
|
||||
{
|
||||
if (text().trimmed().isEmpty())
|
||||
return;
|
||||
|
||||
if (text().startsWith('/')) {
|
||||
int command_end = text().indexOf(' ');
|
||||
if (command_end == -1)
|
||||
command_end = text().size();
|
||||
auto name = text().mid(1, command_end - 1);
|
||||
auto args = text().mid(command_end + 1);
|
||||
if (name.isEmpty() || name == "/") {
|
||||
message(args);
|
||||
} else {
|
||||
command(name, args);
|
||||
}
|
||||
} else {
|
||||
message(text());
|
||||
}
|
||||
|
||||
nhlog::ui()->debug("Send: {}", text().toStdString());
|
||||
|
||||
if (history_.size() == INPUT_HISTORY_SIZE)
|
||||
history_.pop_back();
|
||||
history_.push_front("");
|
||||
history_index_ = 0;
|
||||
}
|
||||
|
||||
void
|
||||
InputBar::openFileSelection()
|
||||
{
|
||||
const QString homeFolder = QStandardPaths::writableLocation(QStandardPaths::HomeLocation);
|
||||
const auto fileName = QFileDialog::getOpenFileName(
|
||||
ChatPage::instance(), tr("Select a file"), homeFolder, tr("All Files (*)"));
|
||||
|
||||
if (fileName.isEmpty())
|
||||
return;
|
||||
|
||||
QMimeDatabase db;
|
||||
QMimeType mime = db.mimeTypeForFile(fileName, QMimeDatabase::MatchContent);
|
||||
|
||||
QFile file{fileName};
|
||||
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
emit ChatPage::instance()->showNotification(
|
||||
QString("Error while reading media: %1").arg(file.errorString()));
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
|
||||
auto bin = file.readAll();
|
||||
|
||||
QMimeData data;
|
||||
data.setData(mime.name(), bin);
|
||||
|
||||
showPreview(data, fileName, QStringList{mime.name()});
|
||||
}
|
||||
|
||||
void
|
||||
InputBar::message(QString msg)
|
||||
{
|
||||
mtx::events::msg::Text text = {};
|
||||
text.body = msg.trimmed().toStdString();
|
||||
|
||||
if (ChatPage::instance()->userSettings()->markdown()) {
|
||||
text.formatted_body = utils::markdownToHtml(msg).toStdString();
|
||||
|
||||
// Don't send formatted_body, when we don't need to
|
||||
if (text.formatted_body.find("<") == std::string::npos)
|
||||
text.formatted_body = "";
|
||||
else
|
||||
text.format = "org.matrix.custom.html";
|
||||
}
|
||||
|
||||
if (!room->reply().isEmpty()) {
|
||||
auto related = room->relatedInfo(room->reply());
|
||||
|
||||
QString body;
|
||||
bool firstLine = true;
|
||||
for (const auto &line : related.quoted_body.split("\n")) {
|
||||
if (firstLine) {
|
||||
firstLine = false;
|
||||
body = QString("> <%1> %2\n").arg(related.quoted_user).arg(line);
|
||||
} else {
|
||||
body = QString("%1\n> %2\n").arg(body).arg(line);
|
||||
}
|
||||
}
|
||||
|
||||
text.body = QString("%1\n%2").arg(body).arg(msg).toStdString();
|
||||
|
||||
// NOTE(Nico): rich replies always need a formatted_body!
|
||||
text.format = "org.matrix.custom.html";
|
||||
if (ChatPage::instance()->userSettings()->markdown())
|
||||
text.formatted_body =
|
||||
utils::getFormattedQuoteBody(related, utils::markdownToHtml(msg))
|
||||
.toStdString();
|
||||
else
|
||||
text.formatted_body =
|
||||
utils::getFormattedQuoteBody(related, msg.toHtmlEscaped()).toStdString();
|
||||
|
||||
text.relates_to.in_reply_to.event_id = related.related_event;
|
||||
room->resetReply();
|
||||
}
|
||||
|
||||
room->sendMessageEvent(text, mtx::events::EventType::RoomMessage);
|
||||
}
|
||||
|
||||
void
|
||||
InputBar::emote(QString msg)
|
||||
{
|
||||
auto html = utils::markdownToHtml(msg);
|
||||
|
||||
mtx::events::msg::Emote emote;
|
||||
emote.body = msg.trimmed().toStdString();
|
||||
|
||||
if (html != msg.trimmed().toHtmlEscaped() &&
|
||||
ChatPage::instance()->userSettings()->markdown()) {
|
||||
emote.formatted_body = html.toStdString();
|
||||
emote.format = "org.matrix.custom.html";
|
||||
}
|
||||
|
||||
if (!room->reply().isEmpty()) {
|
||||
emote.relates_to.in_reply_to.event_id = room->reply().toStdString();
|
||||
room->resetReply();
|
||||
}
|
||||
|
||||
room->sendMessageEvent(emote, mtx::events::EventType::RoomMessage);
|
||||
}
|
||||
|
||||
void
|
||||
InputBar::image(const QString &filename,
|
||||
const std::optional<mtx::crypto::EncryptedFile> &file,
|
||||
const QString &url,
|
||||
const QString &mime,
|
||||
uint64_t dsize,
|
||||
const QSize &dimensions,
|
||||
const QString &blurhash)
|
||||
{
|
||||
mtx::events::msg::Image image;
|
||||
image.info.mimetype = mime.toStdString();
|
||||
image.info.size = dsize;
|
||||
image.info.blurhash = blurhash.toStdString();
|
||||
image.body = filename.toStdString();
|
||||
image.info.h = dimensions.height();
|
||||
image.info.w = dimensions.width();
|
||||
|
||||
if (file)
|
||||
image.file = file;
|
||||
else
|
||||
image.url = url.toStdString();
|
||||
|
||||
if (!room->reply().isEmpty()) {
|
||||
image.relates_to.in_reply_to.event_id = room->reply().toStdString();
|
||||
room->resetReply();
|
||||
}
|
||||
|
||||
room->sendMessageEvent(image, mtx::events::EventType::RoomMessage);
|
||||
}
|
||||
|
||||
void
|
||||
InputBar::file(const QString &filename,
|
||||
const std::optional<mtx::crypto::EncryptedFile> &encryptedFile,
|
||||
const QString &url,
|
||||
const QString &mime,
|
||||
uint64_t dsize)
|
||||
{
|
||||
mtx::events::msg::File file;
|
||||
file.info.mimetype = mime.toStdString();
|
||||
file.info.size = dsize;
|
||||
file.body = filename.toStdString();
|
||||
|
||||
if (encryptedFile)
|
||||
file.file = encryptedFile;
|
||||
else
|
||||
file.url = url.toStdString();
|
||||
|
||||
if (!room->reply().isEmpty()) {
|
||||
file.relates_to.in_reply_to.event_id = room->reply().toStdString();
|
||||
room->resetReply();
|
||||
}
|
||||
|
||||
room->sendMessageEvent(file, mtx::events::EventType::RoomMessage);
|
||||
}
|
||||
|
||||
void
|
||||
InputBar::audio(const QString &filename,
|
||||
const std::optional<mtx::crypto::EncryptedFile> &file,
|
||||
const QString &url,
|
||||
const QString &mime,
|
||||
uint64_t dsize)
|
||||
{
|
||||
mtx::events::msg::Audio audio;
|
||||
audio.info.mimetype = mime.toStdString();
|
||||
audio.info.size = dsize;
|
||||
audio.body = filename.toStdString();
|
||||
audio.url = url.toStdString();
|
||||
|
||||
if (file)
|
||||
audio.file = file;
|
||||
else
|
||||
audio.url = url.toStdString();
|
||||
|
||||
if (!room->reply().isEmpty()) {
|
||||
audio.relates_to.in_reply_to.event_id = room->reply().toStdString();
|
||||
room->resetReply();
|
||||
}
|
||||
|
||||
room->sendMessageEvent(audio, mtx::events::EventType::RoomMessage);
|
||||
}
|
||||
|
||||
void
|
||||
InputBar::video(const QString &filename,
|
||||
const std::optional<mtx::crypto::EncryptedFile> &file,
|
||||
const QString &url,
|
||||
const QString &mime,
|
||||
uint64_t dsize)
|
||||
{
|
||||
mtx::events::msg::Video video;
|
||||
video.info.mimetype = mime.toStdString();
|
||||
video.info.size = dsize;
|
||||
video.body = filename.toStdString();
|
||||
|
||||
if (file)
|
||||
video.file = file;
|
||||
else
|
||||
video.url = url.toStdString();
|
||||
|
||||
if (!room->reply().isEmpty()) {
|
||||
video.relates_to.in_reply_to.event_id = room->reply().toStdString();
|
||||
room->resetReply();
|
||||
}
|
||||
|
||||
room->sendMessageEvent(video, mtx::events::EventType::RoomMessage);
|
||||
}
|
||||
|
||||
void
|
||||
InputBar::command(QString command, QString args)
|
||||
{
|
||||
if (command == "me") {
|
||||
emote(args);
|
||||
} else if (command == "join") {
|
||||
ChatPage::instance()->joinRoom(args);
|
||||
} else if (command == "invite") {
|
||||
ChatPage::instance()->inviteUser(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
||||
} else if (command == "kick") {
|
||||
ChatPage::instance()->kickUser(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
||||
} else if (command == "ban") {
|
||||
ChatPage::instance()->banUser(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
||||
} else if (command == "unban") {
|
||||
ChatPage::instance()->unbanUser(args.section(' ', 0, 0), args.section(' ', 1, -1));
|
||||
} else if (command == "roomnick") {
|
||||
mtx::events::state::Member member;
|
||||
member.display_name = args.toStdString();
|
||||
member.avatar_url =
|
||||
cache::avatarUrl(room->roomId(),
|
||||
QString::fromStdString(http::client()->user_id().to_string()))
|
||||
.toStdString();
|
||||
member.membership = mtx::events::state::Membership::Join;
|
||||
|
||||
http::client()->send_state_event(
|
||||
room->roomId().toStdString(),
|
||||
http::client()->user_id().to_string(),
|
||||
member,
|
||||
[](mtx::responses::EventId, mtx::http::RequestErr err) {
|
||||
if (err)
|
||||
nhlog::net()->error("Failed to set room displayname: {}",
|
||||
err->matrix_error.error);
|
||||
});
|
||||
} else if (command == "shrug") {
|
||||
message("¯\\_(ツ)_/¯" + (args.isEmpty() ? "" : " " + args));
|
||||
} else if (command == "fliptable") {
|
||||
message("(╯°□°)╯︵ ┻━┻");
|
||||
} else if (command == "unfliptable") {
|
||||
message(" ┯━┯╭( º _ º╭)");
|
||||
} else if (command == "sovietflip") {
|
||||
message("ノ┬─┬ノ ︵ ( \\o°o)\\");
|
||||
} else if (command == "clear-timeline") {
|
||||
room->clearTimeline();
|
||||
} else if (command == "rotate-megolm-session") {
|
||||
cache::dropOutboundMegolmSession(room->roomId().toStdString());
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
InputBar::showPreview(const QMimeData &source, QString path, const QStringList &formats)
|
||||
{
|
||||
dialogs::PreviewUploadOverlay *previewDialog_ =
|
||||
new dialogs::PreviewUploadOverlay(ChatPage::instance());
|
||||
previewDialog_->setAttribute(Qt::WA_DeleteOnClose);
|
||||
|
||||
if (source.hasImage())
|
||||
previewDialog_->setPreview(qvariant_cast<QImage>(source.imageData()),
|
||||
formats.front());
|
||||
else if (!path.isEmpty())
|
||||
previewDialog_->setPreview(path);
|
||||
else if (!formats.isEmpty()) {
|
||||
auto mime = formats.first();
|
||||
previewDialog_->setPreview(source.data(mime), mime);
|
||||
} else {
|
||||
setUploading(false);
|
||||
previewDialog_->deleteLater();
|
||||
return;
|
||||
}
|
||||
|
||||
connect(previewDialog_, &dialogs::PreviewUploadOverlay::aborted, this, [this]() {
|
||||
setUploading(false);
|
||||
});
|
||||
|
||||
connect(
|
||||
previewDialog_,
|
||||
&dialogs::PreviewUploadOverlay::confirmUpload,
|
||||
this,
|
||||
[this](const QByteArray data, const QString &mime, const QString &fn) {
|
||||
setUploading(true);
|
||||
|
||||
auto payload = std::string(data.data(), data.size());
|
||||
std::optional<mtx::crypto::EncryptedFile> encryptedFile;
|
||||
if (cache::isRoomEncrypted(room->roomId().toStdString())) {
|
||||
mtx::crypto::BinaryBuf buf;
|
||||
std::tie(buf, encryptedFile) = mtx::crypto::encrypt_file(payload);
|
||||
payload = mtx::crypto::to_string(buf);
|
||||
}
|
||||
|
||||
QSize dimensions;
|
||||
QString blurhash;
|
||||
auto mimeClass = mime.split("/")[0];
|
||||
nhlog::ui()->debug("Mime: {}", mime.toStdString());
|
||||
if (mimeClass == "image") {
|
||||
QImage img = utils::readImage(&data);
|
||||
|
||||
dimensions = img.size();
|
||||
if (img.height() > 200 && img.width() > 360)
|
||||
img = img.scaled(360, 200, Qt::KeepAspectRatioByExpanding);
|
||||
std::vector<unsigned char> data;
|
||||
for (int y = 0; y < img.height(); y++) {
|
||||
for (int x = 0; x < img.width(); x++) {
|
||||
auto p = img.pixel(x, y);
|
||||
data.push_back(static_cast<unsigned char>(qRed(p)));
|
||||
data.push_back(static_cast<unsigned char>(qGreen(p)));
|
||||
data.push_back(static_cast<unsigned char>(qBlue(p)));
|
||||
}
|
||||
}
|
||||
blurhash = QString::fromStdString(
|
||||
blurhash::encode(data.data(), img.width(), img.height(), 4, 3));
|
||||
}
|
||||
|
||||
http::client()->upload(
|
||||
payload,
|
||||
encryptedFile ? "application/octet-stream" : mime.toStdString(),
|
||||
QFileInfo(fn).fileName().toStdString(),
|
||||
[this,
|
||||
filename = fn,
|
||||
encryptedFile = std::move(encryptedFile),
|
||||
mimeClass,
|
||||
mime,
|
||||
size = payload.size(),
|
||||
dimensions,
|
||||
blurhash](const mtx::responses::ContentURI &res,
|
||||
mtx::http::RequestErr err) mutable {
|
||||
if (err) {
|
||||
emit ChatPage::instance()->showNotification(
|
||||
tr("Failed to upload media. Please try again."));
|
||||
nhlog::net()->warn("failed to upload media: {} {} ({})",
|
||||
err->matrix_error.error,
|
||||
to_string(err->matrix_error.errcode),
|
||||
static_cast<int>(err->status_code));
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
auto url = QString::fromStdString(res.content_uri);
|
||||
if (encryptedFile)
|
||||
encryptedFile->url = res.content_uri;
|
||||
|
||||
if (mimeClass == "image")
|
||||
image(filename,
|
||||
encryptedFile,
|
||||
url,
|
||||
mime,
|
||||
size,
|
||||
dimensions,
|
||||
blurhash);
|
||||
else if (mimeClass == "audio")
|
||||
audio(filename, encryptedFile, url, mime, size);
|
||||
else if (mimeClass == "video")
|
||||
video(filename, encryptedFile, url, mime, size);
|
||||
else
|
||||
file(filename, encryptedFile, url, mime, size);
|
||||
|
||||
setUploading(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void
|
||||
InputBar::callButton()
|
||||
{
|
||||
auto callManager_ = ChatPage::instance()->callManager();
|
||||
if (callManager_->onActiveCall()) {
|
||||
callManager_->hangUp();
|
||||
} else {
|
||||
auto current_room_ = room->roomId();
|
||||
if (auto roomInfo = cache::singleRoomInfo(current_room_.toStdString());
|
||||
roomInfo.member_count != 2) {
|
||||
ChatPage::instance()->showNotification("Calls are limited to 1:1 rooms.");
|
||||
} else {
|
||||
std::vector<RoomMember> members(
|
||||
cache::getMembers(current_room_.toStdString()));
|
||||
const RoomMember &callee = members.front().user_id == utils::localUser()
|
||||
? members.back()
|
||||
: members.front();
|
||||
auto dialog =
|
||||
new dialogs::PlaceCall(callee.user_id,
|
||||
callee.display_name,
|
||||
QString::fromStdString(roomInfo.name),
|
||||
QString::fromStdString(roomInfo.avatar_url),
|
||||
ChatPage::instance()->userSettings(),
|
||||
MainWindow::instance());
|
||||
connect(dialog,
|
||||
&dialogs::PlaceCall::voice,
|
||||
callManager_,
|
||||
[callManager_, current_room_]() {
|
||||
callManager_->sendInvite(current_room_, false);
|
||||
});
|
||||
connect(dialog,
|
||||
&dialogs::PlaceCall::video,
|
||||
callManager_,
|
||||
[callManager_, current_room_]() {
|
||||
callManager_->sendInvite(current_room_, true);
|
||||
});
|
||||
utils::centerWidget(dialog, MainWindow::instance());
|
||||
dialog->show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
InputBar::startTyping()
|
||||
{
|
||||
if (!typingRefresh_.isActive()) {
|
||||
typingRefresh_.start();
|
||||
|
||||
if (ChatPage::instance()->userSettings()->typingNotifications()) {
|
||||
http::client()->start_typing(
|
||||
room->roomId().toStdString(), 10'000, [](mtx::http::RequestErr err) {
|
||||
if (err) {
|
||||
nhlog::net()->warn(
|
||||
"failed to send typing notification: {}",
|
||||
err->matrix_error.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
typingTimeout_.start();
|
||||
}
|
||||
void
|
||||
InputBar::stopTyping()
|
||||
{
|
||||
typingRefresh_.stop();
|
||||
typingTimeout_.stop();
|
||||
|
||||
if (!ChatPage::instance()->userSettings()->typingNotifications())
|
||||
return;
|
||||
|
||||
http::client()->stop_typing(room->roomId().toStdString(), [](mtx::http::RequestErr err) {
|
||||
if (err) {
|
||||
nhlog::net()->warn("failed to stop typing notifications: {}",
|
||||
err->matrix_error.error);
|
||||
}
|
||||
});
|
||||
}
|
99
src/timeline/InputBar.h
Normal file
99
src/timeline/InputBar.h
Normal file
|
@ -0,0 +1,99 @@
|
|||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QTimer>
|
||||
#include <deque>
|
||||
|
||||
#include <mtx/common.hpp>
|
||||
#include <mtx/responses/messages.hpp>
|
||||
|
||||
class TimelineModel;
|
||||
class QMimeData;
|
||||
class QDropEvent;
|
||||
class QStringList;
|
||||
|
||||
class InputBar : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(bool uploading READ uploading NOTIFY uploadingChanged)
|
||||
|
||||
public:
|
||||
InputBar(TimelineModel *parent)
|
||||
: QObject()
|
||||
, room(parent)
|
||||
{
|
||||
typingRefresh_.setInterval(10'000);
|
||||
typingRefresh_.setSingleShot(true);
|
||||
typingTimeout_.setInterval(5'000);
|
||||
typingTimeout_.setSingleShot(true);
|
||||
connect(&typingRefresh_, &QTimer::timeout, this, &InputBar::startTyping);
|
||||
connect(&typingTimeout_, &QTimer::timeout, this, &InputBar::stopTyping);
|
||||
}
|
||||
|
||||
public slots:
|
||||
QString text() const;
|
||||
QString previousText();
|
||||
QString nextText();
|
||||
|
||||
void send();
|
||||
void paste(bool fromMouse);
|
||||
void insertMimeData(const QMimeData *data);
|
||||
void updateState(int selectionStart, int selectionEnd, int cursorPosition, QString text);
|
||||
void openFileSelection();
|
||||
bool uploading() const { return uploading_; }
|
||||
void callButton();
|
||||
|
||||
QObject *completerFor(QString completerName);
|
||||
|
||||
private slots:
|
||||
void startTyping();
|
||||
void stopTyping();
|
||||
|
||||
signals:
|
||||
void insertText(QString text);
|
||||
void uploadingChanged(bool value);
|
||||
|
||||
private:
|
||||
void message(QString body);
|
||||
void emote(QString body);
|
||||
void command(QString name, QString args);
|
||||
void image(const QString &filename,
|
||||
const std::optional<mtx::crypto::EncryptedFile> &file,
|
||||
const QString &url,
|
||||
const QString &mime,
|
||||
uint64_t dsize,
|
||||
const QSize &dimensions,
|
||||
const QString &blurhash);
|
||||
void file(const QString &filename,
|
||||
const std::optional<mtx::crypto::EncryptedFile> &encryptedFile,
|
||||
const QString &url,
|
||||
const QString &mime,
|
||||
uint64_t dsize);
|
||||
void audio(const QString &filename,
|
||||
const std::optional<mtx::crypto::EncryptedFile> &file,
|
||||
const QString &url,
|
||||
const QString &mime,
|
||||
uint64_t dsize);
|
||||
void video(const QString &filename,
|
||||
const std::optional<mtx::crypto::EncryptedFile> &file,
|
||||
const QString &url,
|
||||
const QString &mime,
|
||||
uint64_t dsize);
|
||||
|
||||
void showPreview(const QMimeData &source, QString path, const QStringList &formats);
|
||||
void setUploading(bool value)
|
||||
{
|
||||
if (value != uploading_) {
|
||||
uploading_ = value;
|
||||
emit uploadingChanged(value);
|
||||
}
|
||||
}
|
||||
|
||||
QTimer typingRefresh_;
|
||||
QTimer typingTimeout_;
|
||||
TimelineModel *room;
|
||||
std::deque<QString> history_;
|
||||
std::size_t history_index_ = 0;
|
||||
int selectionStart = 0, selectionEnd = 0, cursorPosition = 0;
|
||||
bool uploading_ = false;
|
||||
};
|
|
@ -800,7 +800,6 @@ void
|
|||
TimelineModel::replyAction(QString id)
|
||||
{
|
||||
setReply(id);
|
||||
ChatPage::instance()->focusMessageInput();
|
||||
}
|
||||
|
||||
RelatedInfo
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
#include "CacheCryptoStructs.h"
|
||||
#include "EventStore.h"
|
||||
#include "InputBar.h"
|
||||
#include "ui/UserProfile.h"
|
||||
|
||||
namespace mtx::http {
|
||||
|
@ -149,6 +150,7 @@ class TimelineModel : public QAbstractListModel
|
|||
Q_PROPERTY(QString roomName READ roomName NOTIFY roomNameChanged)
|
||||
Q_PROPERTY(QString roomAvatarUrl READ roomAvatarUrl NOTIFY roomAvatarUrlChanged)
|
||||
Q_PROPERTY(QString roomTopic READ roomTopic NOTIFY roomTopicChanged)
|
||||
Q_PROPERTY(InputBar *input READ input CONSTANT)
|
||||
|
||||
public:
|
||||
explicit TimelineModel(TimelineViewManager *manager,
|
||||
|
@ -271,6 +273,7 @@ public slots:
|
|||
|
||||
QString roomName() const;
|
||||
QString roomTopic() const;
|
||||
InputBar *input() { return &input_; }
|
||||
QString roomAvatarUrl() const;
|
||||
QString roomId() const { return room_id_; }
|
||||
|
||||
|
@ -320,6 +323,8 @@ private:
|
|||
|
||||
TimelineViewManager *manager_;
|
||||
|
||||
InputBar input_{this};
|
||||
|
||||
friend struct SendMessageVisitor;
|
||||
};
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
#include "TimelineViewManager.h"
|
||||
|
||||
#include <QDesktopServices>
|
||||
#include <QDropEvent>
|
||||
#include <QMetaType>
|
||||
#include <QPalette>
|
||||
#include <QQmlContext>
|
||||
|
@ -20,6 +21,7 @@
|
|||
#include "dialogs/ImageOverlay.h"
|
||||
#include "emoji/EmojiModel.h"
|
||||
#include "emoji/Provider.h"
|
||||
#include "ui/NhekoDropArea.h"
|
||||
|
||||
#include <iostream> //only for debugging
|
||||
|
||||
|
@ -115,6 +117,7 @@ TimelineViewManager::TimelineViewManager(CallManager *callManager, ChatPage *par
|
|||
|
||||
qmlRegisterType<DelegateChoice>("im.nheko", 1, 0, "DelegateChoice");
|
||||
qmlRegisterType<DelegateChooser>("im.nheko", 1, 0, "DelegateChooser");
|
||||
qmlRegisterType<NhekoDropArea>("im.nheko", 1, 0, "NhekoDropArea");
|
||||
qmlRegisterUncreatableType<DeviceVerificationFlow>(
|
||||
"im.nheko", 1, 0, "DeviceVerificationFlow", "Can't create verification flow from QML!");
|
||||
qmlRegisterUncreatableType<UserProfile>(
|
||||
|
@ -244,6 +247,26 @@ TimelineViewManager::TimelineViewManager(CallManager *callManager, ChatPage *par
|
|||
&CallManager::newVideoCallState,
|
||||
this,
|
||||
&TimelineViewManager::videoCallChanged);
|
||||
|
||||
connect(&WebRTCSession::instance(),
|
||||
&WebRTCSession::stateChanged,
|
||||
this,
|
||||
&TimelineViewManager::onCallChanged);
|
||||
}
|
||||
|
||||
bool
|
||||
TimelineViewManager::isOnCall() const
|
||||
{
|
||||
return callManager_->onActiveCall();
|
||||
}
|
||||
bool
|
||||
TimelineViewManager::callsSupported() const
|
||||
{
|
||||
#ifdef GSTREAMER_AVAILABLE
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void
|
||||
|
@ -313,6 +336,7 @@ TimelineViewManager::setHistoryView(const QString &room_id)
|
|||
if (room != models.end()) {
|
||||
timeline_ = room.value().data();
|
||||
emit activeTimelineChanged(timeline_);
|
||||
container->setFocus();
|
||||
nhlog::ui()->info("Activated room {}", room_id.toStdString());
|
||||
}
|
||||
}
|
||||
|
@ -474,81 +498,6 @@ TimelineViewManager::initWithMessages(const std::vector<QString> &roomIds)
|
|||
addRoom(roomId);
|
||||
}
|
||||
|
||||
void
|
||||
TimelineViewManager::queueTextMessage(const QString &msg)
|
||||
{
|
||||
if (!timeline_)
|
||||
return;
|
||||
|
||||
mtx::events::msg::Text text = {};
|
||||
text.body = msg.trimmed().toStdString();
|
||||
|
||||
if (ChatPage::instance()->userSettings()->markdown()) {
|
||||
text.formatted_body = utils::markdownToHtml(msg).toStdString();
|
||||
|
||||
// Don't send formatted_body, when we don't need to
|
||||
if (text.formatted_body.find("<") == std::string::npos)
|
||||
text.formatted_body = "";
|
||||
else
|
||||
text.format = "org.matrix.custom.html";
|
||||
}
|
||||
|
||||
if (!timeline_->reply().isEmpty()) {
|
||||
auto related = timeline_->relatedInfo(timeline_->reply());
|
||||
|
||||
QString body;
|
||||
bool firstLine = true;
|
||||
for (const auto &line : related.quoted_body.split("\n")) {
|
||||
if (firstLine) {
|
||||
firstLine = false;
|
||||
body = QString("> <%1> %2\n").arg(related.quoted_user).arg(line);
|
||||
} else {
|
||||
body = QString("%1\n> %2\n").arg(body).arg(line);
|
||||
}
|
||||
}
|
||||
|
||||
text.body = QString("%1\n%2").arg(body).arg(msg).toStdString();
|
||||
|
||||
// NOTE(Nico): rich replies always need a formatted_body!
|
||||
text.format = "org.matrix.custom.html";
|
||||
if (ChatPage::instance()->userSettings()->markdown())
|
||||
text.formatted_body =
|
||||
utils::getFormattedQuoteBody(related, utils::markdownToHtml(msg))
|
||||
.toStdString();
|
||||
else
|
||||
text.formatted_body =
|
||||
utils::getFormattedQuoteBody(related, msg.toHtmlEscaped()).toStdString();
|
||||
|
||||
text.relates_to.in_reply_to.event_id = related.related_event;
|
||||
timeline_->resetReply();
|
||||
}
|
||||
|
||||
timeline_->sendMessageEvent(text, mtx::events::EventType::RoomMessage);
|
||||
}
|
||||
|
||||
void
|
||||
TimelineViewManager::queueEmoteMessage(const QString &msg)
|
||||
{
|
||||
auto html = utils::markdownToHtml(msg);
|
||||
|
||||
mtx::events::msg::Emote emote;
|
||||
emote.body = msg.trimmed().toStdString();
|
||||
|
||||
if (html != msg.trimmed().toHtmlEscaped() &&
|
||||
ChatPage::instance()->userSettings()->markdown()) {
|
||||
emote.formatted_body = html.toStdString();
|
||||
emote.format = "org.matrix.custom.html";
|
||||
}
|
||||
|
||||
if (!timeline_->reply().isEmpty()) {
|
||||
emote.relates_to.in_reply_to.event_id = timeline_->reply().toStdString();
|
||||
timeline_->resetReply();
|
||||
}
|
||||
|
||||
if (timeline_)
|
||||
timeline_->sendMessageEvent(emote, mtx::events::EventType::RoomMessage);
|
||||
}
|
||||
|
||||
void
|
||||
TimelineViewManager::queueReactionMessage(const QString &reactedEvent, const QString &reactionKey)
|
||||
{
|
||||
|
@ -581,122 +530,6 @@ TimelineViewManager::queueReactionMessage(const QString &reactedEvent, const QSt
|
|||
timeline_->redactEvent(selfReactedEvent);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
TimelineViewManager::queueImageMessage(const QString &roomid,
|
||||
const QString &filename,
|
||||
const std::optional<mtx::crypto::EncryptedFile> &file,
|
||||
const QString &url,
|
||||
const QString &mime,
|
||||
uint64_t dsize,
|
||||
const QSize &dimensions,
|
||||
const QString &blurhash)
|
||||
{
|
||||
mtx::events::msg::Image image;
|
||||
image.info.mimetype = mime.toStdString();
|
||||
image.info.size = dsize;
|
||||
image.info.blurhash = blurhash.toStdString();
|
||||
image.body = filename.toStdString();
|
||||
image.info.h = dimensions.height();
|
||||
image.info.w = dimensions.width();
|
||||
|
||||
if (file)
|
||||
image.file = file;
|
||||
else
|
||||
image.url = url.toStdString();
|
||||
|
||||
auto model = models.value(roomid);
|
||||
if (!model->reply().isEmpty()) {
|
||||
image.relates_to.in_reply_to.event_id = model->reply().toStdString();
|
||||
model->resetReply();
|
||||
}
|
||||
|
||||
model->sendMessageEvent(image, mtx::events::EventType::RoomMessage);
|
||||
}
|
||||
|
||||
void
|
||||
TimelineViewManager::queueFileMessage(
|
||||
const QString &roomid,
|
||||
const QString &filename,
|
||||
const std::optional<mtx::crypto::EncryptedFile> &encryptedFile,
|
||||
const QString &url,
|
||||
const QString &mime,
|
||||
uint64_t dsize)
|
||||
{
|
||||
mtx::events::msg::File file;
|
||||
file.info.mimetype = mime.toStdString();
|
||||
file.info.size = dsize;
|
||||
file.body = filename.toStdString();
|
||||
|
||||
if (encryptedFile)
|
||||
file.file = encryptedFile;
|
||||
else
|
||||
file.url = url.toStdString();
|
||||
|
||||
auto model = models.value(roomid);
|
||||
if (!model->reply().isEmpty()) {
|
||||
file.relates_to.in_reply_to.event_id = model->reply().toStdString();
|
||||
model->resetReply();
|
||||
}
|
||||
|
||||
model->sendMessageEvent(file, mtx::events::EventType::RoomMessage);
|
||||
}
|
||||
|
||||
void
|
||||
TimelineViewManager::queueAudioMessage(const QString &roomid,
|
||||
const QString &filename,
|
||||
const std::optional<mtx::crypto::EncryptedFile> &file,
|
||||
const QString &url,
|
||||
const QString &mime,
|
||||
uint64_t dsize)
|
||||
{
|
||||
mtx::events::msg::Audio audio;
|
||||
audio.info.mimetype = mime.toStdString();
|
||||
audio.info.size = dsize;
|
||||
audio.body = filename.toStdString();
|
||||
audio.url = url.toStdString();
|
||||
|
||||
if (file)
|
||||
audio.file = file;
|
||||
else
|
||||
audio.url = url.toStdString();
|
||||
|
||||
auto model = models.value(roomid);
|
||||
if (!model->reply().isEmpty()) {
|
||||
audio.relates_to.in_reply_to.event_id = model->reply().toStdString();
|
||||
model->resetReply();
|
||||
}
|
||||
|
||||
model->sendMessageEvent(audio, mtx::events::EventType::RoomMessage);
|
||||
}
|
||||
|
||||
void
|
||||
TimelineViewManager::queueVideoMessage(const QString &roomid,
|
||||
const QString &filename,
|
||||
const std::optional<mtx::crypto::EncryptedFile> &file,
|
||||
const QString &url,
|
||||
const QString &mime,
|
||||
uint64_t dsize)
|
||||
{
|
||||
mtx::events::msg::Video video;
|
||||
video.info.mimetype = mime.toStdString();
|
||||
video.info.size = dsize;
|
||||
video.body = filename.toStdString();
|
||||
|
||||
if (file)
|
||||
video.file = file;
|
||||
else
|
||||
video.url = url.toStdString();
|
||||
|
||||
auto model = models.value(roomid);
|
||||
if (!model->reply().isEmpty()) {
|
||||
video.relates_to.in_reply_to.event_id = model->reply().toStdString();
|
||||
model->resetReply();
|
||||
}
|
||||
|
||||
model->sendMessageEvent(video, mtx::events::EventType::RoomMessage);
|
||||
}
|
||||
|
||||
void
|
||||
TimelineViewManager::queueCallMessage(const QString &roomid,
|
||||
const mtx::events::msg::CallInvite &callInvite)
|
||||
|
|
|
@ -41,6 +41,8 @@ class TimelineViewManager : public QObject
|
|||
Q_PROPERTY(QString callPartyName READ callPartyName NOTIFY callPartyChanged)
|
||||
Q_PROPERTY(QString callPartyAvatarUrl READ callPartyAvatarUrl NOTIFY callPartyChanged)
|
||||
Q_PROPERTY(bool isMicMuted READ isMicMuted NOTIFY micMuteChanged)
|
||||
Q_PROPERTY(bool isOnCall READ isOnCall NOTIFY onCallChanged)
|
||||
Q_PROPERTY(bool callsSupported READ callsSupported CONSTANT)
|
||||
|
||||
public:
|
||||
TimelineViewManager(CallManager *callManager, ChatPage *parent = nullptr);
|
||||
|
@ -95,6 +97,7 @@ signals:
|
|||
void videoCallChanged();
|
||||
void callPartyChanged();
|
||||
void micMuteChanged();
|
||||
void onCallChanged();
|
||||
|
||||
public slots:
|
||||
void updateReadReceipts(const QString &room_id, const std::vector<QString> &event_ids);
|
||||
|
@ -102,48 +105,25 @@ public slots:
|
|||
void initWithMessages(const std::vector<QString> &roomIds);
|
||||
|
||||
void setHistoryView(const QString &room_id);
|
||||
TimelineModel *getHistoryView(const QString &room_id)
|
||||
{
|
||||
auto room = models.find(room_id);
|
||||
if (room != models.end())
|
||||
return room.value().data();
|
||||
else
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void updateColorPalette();
|
||||
void queueReactionMessage(const QString &reactedEvent, const QString &reactionKey);
|
||||
void queueTextMessage(const QString &msg);
|
||||
void queueEmoteMessage(const QString &msg);
|
||||
void queueImageMessage(const QString &roomid,
|
||||
const QString &filename,
|
||||
const std::optional<mtx::crypto::EncryptedFile> &file,
|
||||
const QString &url,
|
||||
const QString &mime,
|
||||
uint64_t dsize,
|
||||
const QSize &dimensions,
|
||||
const QString &blurhash);
|
||||
void queueFileMessage(const QString &roomid,
|
||||
const QString &filename,
|
||||
const std::optional<mtx::crypto::EncryptedFile> &file,
|
||||
const QString &url,
|
||||
const QString &mime,
|
||||
uint64_t dsize);
|
||||
void queueAudioMessage(const QString &roomid,
|
||||
const QString &filename,
|
||||
const std::optional<mtx::crypto::EncryptedFile> &file,
|
||||
const QString &url,
|
||||
const QString &mime,
|
||||
uint64_t dsize);
|
||||
void queueVideoMessage(const QString &roomid,
|
||||
const QString &filename,
|
||||
const std::optional<mtx::crypto::EncryptedFile> &file,
|
||||
const QString &url,
|
||||
const QString &mime,
|
||||
uint64_t dsize);
|
||||
void queueCallMessage(const QString &roomid, const mtx::events::msg::CallInvite &);
|
||||
void queueCallMessage(const QString &roomid, const mtx::events::msg::CallCandidates &);
|
||||
void queueCallMessage(const QString &roomid, const mtx::events::msg::CallAnswer &);
|
||||
void queueCallMessage(const QString &roomid, const mtx::events::msg::CallHangUp &);
|
||||
|
||||
void updateEncryptedDescriptions();
|
||||
|
||||
void clearCurrentRoomTimeline()
|
||||
{
|
||||
if (timeline_)
|
||||
timeline_->clearTimeline();
|
||||
}
|
||||
bool isOnCall() const;
|
||||
bool callsSupported() const;
|
||||
|
||||
void enableBackButton()
|
||||
{
|
||||
|
|
39
src/ui/NhekoDropArea.cpp
Normal file
39
src/ui/NhekoDropArea.cpp
Normal file
|
@ -0,0 +1,39 @@
|
|||
#include "NhekoDropArea.h"
|
||||
|
||||
#include <QMimeData>
|
||||
|
||||
#include "ChatPage.h"
|
||||
#include "timeline/InputBar.h"
|
||||
#include "timeline/TimelineModel.h"
|
||||
#include "timeline/TimelineViewManager.h"
|
||||
|
||||
#include "Logging.h"
|
||||
|
||||
NhekoDropArea::NhekoDropArea(QQuickItem *parent)
|
||||
: QQuickItem(parent)
|
||||
{
|
||||
setFlags(ItemAcceptsDrops);
|
||||
}
|
||||
|
||||
void
|
||||
NhekoDropArea::dragEnterEvent(QDragEnterEvent *event)
|
||||
{
|
||||
event->acceptProposedAction();
|
||||
}
|
||||
|
||||
void
|
||||
NhekoDropArea::dragMoveEvent(QDragMoveEvent *event)
|
||||
{
|
||||
event->acceptProposedAction();
|
||||
}
|
||||
|
||||
void
|
||||
NhekoDropArea::dropEvent(QDropEvent *event)
|
||||
{
|
||||
if (event) {
|
||||
auto model = ChatPage::instance()->timelineManager()->getHistoryView(roomid_);
|
||||
if (model) {
|
||||
model->input()->insertMimeData(event->mimeData());
|
||||
}
|
||||
}
|
||||
}
|
30
src/ui/NhekoDropArea.h
Normal file
30
src/ui/NhekoDropArea.h
Normal file
|
@ -0,0 +1,30 @@
|
|||
#include <QQuickItem>
|
||||
|
||||
class NhekoDropArea : public QQuickItem
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(QString roomid READ roomid WRITE setRoomid NOTIFY roomidChanged)
|
||||
public:
|
||||
NhekoDropArea(QQuickItem *parent = nullptr);
|
||||
|
||||
signals:
|
||||
void roomidChanged(QString roomid);
|
||||
|
||||
public slots:
|
||||
void setRoomid(QString roomid)
|
||||
{
|
||||
if (roomid_ != roomid) {
|
||||
roomid_ = roomid;
|
||||
emit roomidChanged(roomid);
|
||||
}
|
||||
}
|
||||
QString roomid() const { return roomid_; }
|
||||
|
||||
protected:
|
||||
void dragEnterEvent(QDragEnterEvent *event) override;
|
||||
void dragMoveEvent(QDragMoveEvent *event) override;
|
||||
void dropEvent(QDropEvent *event) override;
|
||||
|
||||
private:
|
||||
QString roomid_;
|
||||
};
|
Loading…
Reference in a new issue