From: Claudio Cambra Date: Fri, 18 Nov 2022 15:12:49 +0000 (+0100) Subject: Convert share details menu into a page pushed onto stackview X-Git-Tag: archive/raspbian/3.16.7-1_deb13u1+rpi1~1^2~12^2~11^2~79^2~7 X-Git-Url: https://dgit.raspbian.org/?a=commitdiff_plain;h=046f8ea9105f92ec3e08f8cc06ec7ffb64119f62;p=nextcloud-desktop.git Convert share details menu into a page pushed onto stackview Signed-off-by: Claudio Cambra --- diff --git a/src/gui/filedetails/FileDetailsPage.qml b/src/gui/filedetails/FileDetailsPage.qml index 304d1cee4..85eb57c3f 100644 --- a/src/gui/filedetails/FileDetailsPage.qml +++ b/src/gui/filedetails/FileDetailsPage.qml @@ -187,6 +187,7 @@ Page { fileDetails: root.fileDetails horizontalPadding: root.intendedPadding iconSize: root.iconSize + rootStackView: root.rootStackView } } } diff --git a/src/gui/filedetails/ShareDelegate.qml b/src/gui/filedetails/ShareDelegate.qml index 885b78c6c..d76268405 100644 --- a/src/gui/filedetails/ShareDelegate.qml +++ b/src/gui/filedetails/ShareDelegate.qml @@ -29,6 +29,10 @@ GridLayout { signal deleteShare signal createNewLinkShare + signal resetMenu + signal resetPasswordField + signal showPasswordSetError(string errorMessage); + signal toggleAllowEditing(bool enable) signal toggleAllowResharing(bool enable) signal togglePasswordProtect(bool enable) @@ -40,130 +44,28 @@ GridLayout { signal setPassword(string password) signal setNote(string note) - anchors.left: parent.left - anchors.right: parent.right - - columns: 3 - rows: linkDetailLabel.visible ? 1 : 2 - - columnSpacing: Style.standardSpacing / 2 - rowSpacing: Style.standardSpacing / 2 - property int iconSize: 32 - - property var share: model.share ?? ({}) - - property string iconUrl: model.iconUrl ?? "" - property string avatarUrl: model.avatarUrl ?? "" - property string text: model.display ?? "" - property string detailText: model.detailText ?? "" - property string link: model.link ?? "" - property string note: model.note ?? "" - property string password: model.password ?? "" - property string passwordPlaceholder: "●●●●●●●●●●" - - property var expireDate: model.expireDate // Don't use int as we are limited - property var maximumExpireDate: model.enforcedMaximumExpireDate - - property string linkShareLabel: model.linkShareLabel ?? "" - - property bool editingAllowed: model.editingAllowed - property bool noteEnabled: model.noteEnabled - property bool expireDateEnabled: model.expireDateEnabled - property bool expireDateEnforced: model.expireDateEnforced - property bool passwordProtectEnabled: model.passwordProtectEnabled - property bool passwordEnforced: model.passwordEnforced - - property bool isLinkShare: model.shareType === ShareModel.ShareTypeLink - property bool isPlaceholderLinkShare: model.shareType === ShareModel.ShareTypePlaceholderLink + property FileDetails fileDetails: FileDetails {} + property StackView rootStackView: StackView {} property bool canCreateLinkShares: true - property bool waitingForEditingAllowedChange: false - property bool waitingForNoteEnabledChange: false - property bool waitingForExpireDateEnabledChange: false - property bool waitingForPasswordProtectEnabledChange: false - property bool waitingForExpireDateChange: false - property bool waitingForLinkShareLabelChange: false - property bool waitingForPasswordChange: false - property bool waitingForNoteChange: false - - function showPasswordSetError(message) { - passwordErrorBoxLoader.message = message !== "" ? - message : qsTr("An error occurred setting the share password."); - } - - function resetNoteField() { - noteTextEdit.text = note; - waitingForNoteChange = false; - } - - function resetLinkShareLabelField() { - linkShareLabelTextField.text = linkShareLabel; - waitingForLinkShareLabelChange = false; - } - - function resetPasswordField() { - passwordTextField.text = password !== "" ? password : passwordPlaceholder; - waitingForPasswordChange = false; - } + readonly property bool isLinkShare: model.shareType === ShareModel.ShareTypeLink + readonly property bool isPlaceholderLinkShare: model.shareType === ShareModel.ShareTypePlaceholderLink - function resetExpireDateField() { - // Expire date changing is handled by the expireDateSpinBox - waitingForExpireDateChange = false; - } + readonly property string text: model.display ?? "" + readonly property string detailText: model.detailText ?? "" + readonly property string iconUrl: model.iconUrl ?? "" + readonly property string avatarUrl: model.avatarUrl ?? "" - function resetEditingAllowedField() { - editingAllowedMenuItem.checked = editingAllowed; - waitingForEditingAllowedChange = false; - } - - function resetNoteEnabledField() { - noteEnabledMenuItem.checked = noteEnabled; - waitingForNoteEnabledChange = false; - } - - function resetExpireDateEnabledField() { - expireDateEnabledMenuItem.checked = expireDateEnabled; - waitingForExpireDateEnabledChange = false; - } - - function resetPasswordProtectEnabledField() { - passwordProtectEnabledMenuItem.checked = passwordProtectEnabled; - waitingForPasswordProtectEnabledChange = false; - } - - function resetMenu() { - moreMenu.close(); - - resetNoteField(); - resetPasswordField(); - resetLinkShareLabelField(); - resetExpireDateField(); - - resetEditingAllowedField(); - resetNoteEnabledField(); - resetExpireDateEnabledField(); - resetPasswordProtectEnabledField(); - } - - // Renaming a link share can lead to the model being reshuffled. - // This can cause a situation where this delegate is assigned to - // a new row and it doesn't have its properties signalled as - // changed by the model, leading to bugs. We therefore reset all - // the fields here when we detect the share has been changed - onShareChanged: resetMenu() + anchors.left: parent.left + anchors.right: parent.right - // Reset value after property binding broken by user interaction - onNoteChanged: resetNoteField() - onPasswordChanged: resetPasswordField() - onLinkShareLabelChanged: resetLinkShareLabelField() - onExpireDateChanged: resetExpireDateField() + columns: 3 + rows: linkDetailLabel.visible ? 1 : 2 - onEditingAllowedChanged: resetEditingAllowedField() - onNoteEnabledChanged: resetNoteEnabledField() - onExpireDateEnabledChanged: resetExpireDateEnabledField() - onPasswordProtectEnabledChanged: resetPasswordProtectEnabledField() + columnSpacing: Style.standardSpacing / 2 + rowSpacing: Style.standardSpacing / 2 Item { id: imageItem @@ -310,506 +212,50 @@ GridLayout { visible: !root.isPlaceholderLinkShare enabled: visible - onClicked: moreMenu.popup() - - Menu { - id: moreMenu - - property int rowIconWidth: 16 - property int indicatorItemWidth: 20 - property int indicatorSpacing: Style.standardSpacing - property int itemPadding: Style.smallSpacing - - padding: Style.smallSpacing - // TODO: Rather than setting all these palette colours manually, - // create a custom style and do it for all components globally - palette { - text: Style.ncTextColor - windowText: Style.ncTextColor - buttonText: Style.ncTextColor - light: Style.lightHover - midlight: Style.lightHover - mid: Style.ncSecondaryTextColor - dark: Style.menuBorder - button: Style.menuBorder - window: Style.backgroundColor - base: Style.backgroundColor - } - - RowLayout { - anchors.left: parent.left - anchors.leftMargin: moreMenu.itemPadding - anchors.right: parent.right - anchors.rightMargin: moreMenu.itemPadding - height: visible ? implicitHeight : 0 - spacing: moreMenu.indicatorSpacing - - visible: root.isLinkShare - - Image { - Layout.preferredWidth: moreMenu.indicatorItemWidth - Layout.fillHeight: true + onClicked: root.rootStackView.push(shareDetailsPageComponent, {}, StackView.PushTransition) - verticalAlignment: Image.AlignVCenter - horizontalAlignment: Image.AlignHCenter - fillMode: Image.Pad + Component { + id: shareDetailsPageComponent + ShareDetailsPage { + id: shareDetailsPage - source: "image://svgimage-custom-color/edit.svg/" + Style.menuBorder - sourceSize.width: moreMenu.rowIconWidth - sourceSize.height: moreMenu.rowIconWidth - } + width: parent.width + height: parent.height - NCInputTextField { - id: linkShareLabelTextField + fileDetails: root.fileDetails + shareModelData: model - Layout.fillWidth: true - height: visible ? implicitHeight : 0 + canCreateLinkShares: root.canCreateLinkShares - text: root.linkShareLabel - placeholderText: qsTr("Share label") + onCloseShareDetails: root.rootStackView.clear(StackView.PopTransition) - enabled: root.isLinkShare && - !root.waitingForLinkShareLabelChange + onToggleAllowEditing: root.toggleAllowEditing(enable) + onToggleAllowResharing: root.toggleAllowResharing(enable) + onTogglePasswordProtect: root.togglePasswordProtect(enable) + onToggleExpirationDate: root.toggleExpirationDate(enable) + onToggleNoteToRecipient: root.toggleNoteToRecipient(enable) - onAccepted: if(text !== root.linkShareLabel) { - root.setLinkShareLabel(text); - root.waitingForLinkShareLabelChange = true; - } + onSetLinkShareLabel: root.setLinkShareLabel(label) + onSetExpireDate: root.setExpireDate(milliseconds) // Since QML ints are only 32 bits, use a variant + onSetPassword: root.setPassword(password) + onSetNote: root.setNote(note) - NCBusyIndicator { - anchors.fill: parent - visible: root.waitingForLinkShareLabelChange - running: visible - z: 1 - } + onDeleteShare: { + root.deleteShare(); + closeShareDetails(); } - } - - // On these checkables, the clicked() signal is called after - // the check state changes. - CheckBox { - id: editingAllowedMenuItem - - spacing: moreMenu.indicatorSpacing - padding: moreMenu.itemPadding - indicator.width: moreMenu.indicatorItemWidth - indicator.height: moreMenu.indicatorItemWidth - - checkable: true - checked: root.editingAllowed - text: qsTr("Allow editing") - enabled: !root.waitingForEditingAllowedChange - - onClicked: { - root.toggleAllowEditing(checked); - root.waitingForEditingAllowedChange = true; + onCreateNewLinkShare: { + root.createNewLinkShare(); + closeShareDetails(); } - NCBusyIndicator { - anchors.fill: parent - visible: root.waitingForEditingAllowedChange - running: visible - z: 1 + Connections { + target: root + function onResetMenu() { shareDetailsPage.resetMenu() } + function onResetPasswordField() { shareDetailsPage.resetPasswordField() } + function onShowPasswordSetError(errorMessage) { shareDetailsPage.showPasswordSetError(errorMessage) } } } - - CheckBox { - id: passwordProtectEnabledMenuItem - - spacing: moreMenu.indicatorSpacing - padding: moreMenu.itemPadding - indicator.width: moreMenu.indicatorItemWidth - indicator.height: moreMenu.indicatorItemWidth - - checkable: true - checked: root.passwordProtectEnabled - text: qsTr("Password protect") - enabled: !root.waitingForPasswordProtectEnabledChange && !root.passwordEnforced - - onClicked: { - root.togglePasswordProtect(checked); - root.waitingForPasswordProtectEnabledChange = true; - } - - NCBusyIndicator { - anchors.fill: parent - visible: root.waitingForPasswordProtectEnabledChange - running: visible - z: 1 - } - } - - RowLayout { - anchors.left: parent.left - anchors.leftMargin: moreMenu.itemPadding - anchors.right: parent.right - anchors.rightMargin: moreMenu.itemPadding - height: visible ? implicitHeight : 0 - spacing: moreMenu.indicatorSpacing - - visible: root.passwordProtectEnabled - - Image { - Layout.preferredWidth: moreMenu.indicatorItemWidth - Layout.fillHeight: true - - verticalAlignment: Image.AlignVCenter - horizontalAlignment: Image.AlignHCenter - fillMode: Image.Pad - - source: "image://svgimage-custom-color/lock-https.svg/" + Style.menuBorder - sourceSize.width: moreMenu.rowIconWidth - sourceSize.height: moreMenu.rowIconWidth - } - - NCInputTextField { - id: passwordTextField - - Layout.fillWidth: true - height: visible ? implicitHeight : 0 - - text: root.password !== "" ? root.password : root.passwordPlaceholder - enabled: root.passwordProtectEnabled && - !root.waitingForPasswordChange && - !root.waitingForPasswordProtectEnabledChange - - onAccepted: if(text !== root.password && text !== root.passwordPlaceholder) { - passwordErrorBoxLoader.message = ""; - root.setPassword(text); - root.waitingForPasswordChange = true; - } - - NCBusyIndicator { - anchors.fill: parent - visible: root.waitingForPasswordChange || - root.waitingForPasswordProtectEnabledChange - running: visible - z: 1 - } - } - } - - Loader { - id: passwordErrorBoxLoader - - property string message: "" - - anchors.left: parent.left - anchors.right: parent.right - height: message !== "" ? implicitHeight : 0 - - active: message !== "" - visible: active - - sourceComponent: Item { - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - // Artificially add vertical padding - implicitHeight: passwordErrorBox.implicitHeight + (Style.smallSpacing * 2) - - ErrorBox { - id: passwordErrorBox - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - - text: passwordErrorBoxLoader.message - } - } - } - - CheckBox { - id: expireDateEnabledMenuItem - - spacing: moreMenu.indicatorSpacing - padding: moreMenu.itemPadding - indicator.width: moreMenu.indicatorItemWidth - indicator.height: moreMenu.indicatorItemWidth - - checkable: true - checked: root.expireDateEnabled - text: qsTr("Set expiration date") - enabled: !root.waitingForExpireDateEnabledChange && !root.expireDateEnforced - - onClicked: { - root.toggleExpirationDate(checked); - root.waitingForExpireDateEnabledChange = true; - } - - NCBusyIndicator { - anchors.fill: parent - visible: root.waitingForExpireDateEnabledChange - running: visible - z: 1 - } - } - - RowLayout { - anchors.left: parent.left - anchors.leftMargin: moreMenu.itemPadding - anchors.right: parent.right - anchors.rightMargin: moreMenu.itemPadding - height: visible ? implicitHeight : 0 - spacing: moreMenu.indicatorSpacing - - visible: root.expireDateEnabled - - Image { - Layout.preferredWidth: moreMenu.indicatorItemWidth - Layout.fillHeight: true - - verticalAlignment: Image.AlignVCenter - horizontalAlignment: Image.AlignHCenter - fillMode: Image.Pad - - source: "image://svgimage-custom-color/calendar.svg/" + Style.menuBorder - sourceSize.width: moreMenu.rowIconWidth - sourceSize.height: moreMenu.rowIconWidth - } - - // QML dates are essentially JavaScript dates, which makes them very finicky and unreliable. - // Instead, we exclusively deal with msecs from epoch time to make things less painful when editing. - // We only use the QML Date when showing the nice string to the user. - SpinBox { - id: expireDateSpinBox - - // Work arounds the limitations of QML's 32 bit integer when handling msecs from epoch - // Instead, we handle everything as days since epoch - readonly property int dayInMSecs: 24 * 60 * 60 * 1000 - readonly property int expireDateReduced: Math.floor(root.expireDate / dayInMSecs) - // Reset the model data after binding broken on user interact - onExpireDateReducedChanged: value = expireDateReduced - - // We can't use JS's convenient Infinity or Number.MAX_VALUE as - // JS Number type is 64 bits, whereas QML's int type is only 32 bits - readonly property IntValidator intValidator: IntValidator {} - readonly property int maximumExpireDateReduced: root.expireDateEnforced ? - Math.floor(root.maximumExpireDate / dayInMSecs) : - intValidator.top - readonly property int minimumExpireDateReduced: { - const currentDate = new Date(); - const minDateUTC = new Date(Date.UTC(currentDate.getFullYear(), - currentDate.getMonth(), - currentDate.getDate() + 1)); - return Math.floor(minDateUTC / dayInMSecs) // Start of day at 00:00:0000 UTC - } - - // Taken from Kalendar 22.08 - // https://invent.kde.org/pim/kalendar/-/blob/release/22.08/src/contents/ui/KalendarUtils/dateutils.js - function parseDateString(dateString) { - function defaultParse() { - const defaultParsedDate = Date.fromLocaleDateString(Qt.locale(), dateString, Locale.NarrowFormat); - // JS always generates date in system locale, eliminate timezone difference to UTC - const msecsSinceEpoch = defaultParsedDate.getTime() - (defaultParsedDate.getTimezoneOffset() * 60 * 1000); - return new Date(msecsSinceEpoch); - } - - const dateStringDelimiterMatches = dateString.match(/\D/); - if(dateStringDelimiterMatches.length === 0) { - // Let the date method figure out this weirdness - return defaultParse(); - } - - const dateStringDelimiter = dateStringDelimiterMatches[0]; - - const localisedDateFormatSplit = Qt.locale().dateFormat(Locale.NarrowFormat).split(dateStringDelimiter); - const localisedDateDayPosition = localisedDateFormatSplit.findIndex((x) => /d/gi.test(x)); - const localisedDateMonthPosition = localisedDateFormatSplit.findIndex((x) => /m/gi.test(x)); - const localisedDateYearPosition = localisedDateFormatSplit.findIndex((x) => /y/gi.test(x)); - - let splitDateString = dateString.split(dateStringDelimiter); - let userProvidedYear = splitDateString[localisedDateYearPosition] - - const dateNow = new Date(); - const stringifiedCurrentYear = dateNow.getFullYear().toString(); - - // If we have any input weirdness, or if we have a fully-written year - // (e.g. 2022 instead of 22) then use default parse - if(splitDateString.length === 0 || - splitDateString.length > 3 || - userProvidedYear.length >= stringifiedCurrentYear.length) { - - return defaultParse(); - } - - let fullyWrittenYear = userProvidedYear.split(""); - const digitsToAdd = stringifiedCurrentYear.length - fullyWrittenYear.length; - for(let i = 0; i < digitsToAdd; i++) { - fullyWrittenYear.splice(i, 0, stringifiedCurrentYear[i]) - } - fullyWrittenYear = fullyWrittenYear.join(""); - - const fixedYearNum = Number(fullyWrittenYear); - const monthIndexNum = Number(splitDateString[localisedDateMonthPosition]) - 1; - const dayNum = Number(splitDateString[localisedDateDayPosition]); - - console.log(dayNum, monthIndexNum, fixedYearNum); - - // Modification: return date in UTC - return new Date(Date.UTC(fixedYearNum, monthIndexNum, dayNum)); - } - - Layout.fillWidth: true - height: visible ? implicitHeight : 0 - - - // We want all the internal benefits of the spinbox but don't actually want the - // buttons, so set an empty item as a dummy - up.indicator: Item {} - down.indicator: Item {} - - background: Rectangle { - radius: Style.slightlyRoundedButtonRadius - border.width: Style.normalBorderWidth - border.color: expireDateSpinBox.activeFocus ? Style.ncBlue : Style.menuBorder - color: Style.backgroundColor - } - - value: expireDateReduced - from: minimumExpireDateReduced - to: maximumExpireDateReduced - - textFromValue: (value, locale) => { - const dateFromValue = new Date(value * dayInMSecs); - return dateFromValue.toLocaleDateString(Qt.locale(), Locale.NarrowFormat); - } - valueFromText: (text, locale) => { - const dateFromText = parseDateString(text); - return Math.floor(dateFromText.getTime() / dayInMSecs); - } - - editable: true - inputMethodHints: Qt.ImhDate | Qt.ImhFormattedNumbersOnly - - enabled: root.expireDateEnabled && - !root.waitingForExpireDateChange && - !root.waitingForExpireDateEnabledChange - - onValueModified: { - if (!enabled || !activeFocus) { - return; - } - - root.setExpireDate(value * dayInMSecs); - root.waitingForExpireDateChange = true; - } - - NCBusyIndicator { - anchors.fill: parent - visible: root.waitingForExpireDateEnabledChange || - root.waitingForExpireDateChange - running: visible - z: 1 - } - } - } - - CheckBox { - id: noteEnabledMenuItem - - spacing: moreMenu.indicatorSpacing - padding: moreMenu.itemPadding - indicator.width: moreMenu.indicatorItemWidth - indicator.height: moreMenu.indicatorItemWidth - - checkable: true - checked: root.noteEnabled - text: qsTr("Note to recipient") - enabled: !root.waitingForNoteEnabledChange - - onClicked: { - root.toggleNoteToRecipient(checked); - root.waitingForNoteEnabledChange = true; - } - - NCBusyIndicator { - anchors.fill: parent - visible: root.waitingForNoteEnabledChange - running: visible - z: 1 - } - } - - RowLayout { - anchors.left: parent.left - anchors.leftMargin: moreMenu.itemPadding - anchors.right: parent.right - anchors.rightMargin: moreMenu.itemPadding - height: visible ? implicitHeight : 0 - spacing: moreMenu.indicatorSpacing - - visible: root.noteEnabled - - Image { - Layout.preferredWidth: moreMenu.indicatorItemWidth - Layout.fillHeight: true - - verticalAlignment: Image.AlignVCenter - horizontalAlignment: Image.AlignHCenter - fillMode: Image.Pad - - source: "image://svgimage-custom-color/edit.svg/" + Style.menuBorder - sourceSize.width: moreMenu.rowIconWidth - sourceSize.height: moreMenu.rowIconWidth - } - - NCInputTextEdit { - id: noteTextEdit - - Layout.fillWidth: true - height: visible ? Math.max(Style.talkReplyTextFieldPreferredHeight, contentHeight) : 0 - submitButton.height: Math.min(Style.talkReplyTextFieldPreferredHeight, height - 2) - - text: root.note - enabled: root.noteEnabled && - !root.waitingForNoteChange && - !root.waitingForNoteEnabledChange - - onEditingFinished: if(text !== root.note) { - root.setNote(text); - root.waitingForNoteChange = true; - } - - NCBusyIndicator { - anchors.fill: parent - visible: root.waitingForNoteChange || - root.waitingForNoteEnabledChange - running: visible - z: 1 - } - } - } - - MenuItem { - spacing: moreMenu.indicatorSpacing - padding: moreMenu.itemPadding - - icon.width: moreMenu.indicatorItemWidth - icon.height: moreMenu.indicatorItemWidth - icon.color: Style.ncTextColor - icon.source: "qrc:///client/theme/close.svg" - text: qsTr("Unshare") - - onTriggered: root.deleteShare() - } - - MenuItem { - height: visible ? implicitHeight : 0 - spacing: moreMenu.indicatorSpacing - padding: moreMenu.itemPadding - - icon.width: moreMenu.indicatorItemWidth - icon.height: moreMenu.indicatorItemWidth - icon.color: Style.ncTextColor - icon.source: "qrc:///client/theme/add.svg" - text: qsTr("Add another link") - - visible: root.isLinkShare && root.canCreateLinkShares - enabled: visible - - onTriggered: root.createNewLinkShare() - } } } } diff --git a/src/gui/filedetails/ShareDetailsPage.qml b/src/gui/filedetails/ShareDetailsPage.qml new file mode 100644 index 000000000..75ef88df6 --- /dev/null +++ b/src/gui/filedetails/ShareDetailsPage.qml @@ -0,0 +1,737 @@ +/* + * Copyright (C) 2022 by Claudio Cambra + * + * 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 2 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. + */ + +import QtQuick 2.15 +import QtQuick.Window 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.15 +import QtGraphicalEffects 1.15 + +import com.nextcloud.desktopclient 1.0 +import Style 1.0 +import "../tray" +import "../" + +Page { + id: root + + signal closeShareDetails + signal deleteShare + signal createNewLinkShare + + signal toggleAllowEditing(bool enable) + signal toggleAllowResharing(bool enable) + signal togglePasswordProtect(bool enable) + signal toggleExpirationDate(bool enable) + signal toggleNoteToRecipient(bool enable) + + signal setLinkShareLabel(string label) + signal setExpireDate(var milliseconds) // Since QML ints are only 32 bits, use a variant + signal setPassword(string password) + signal setNote(string note) + + property FileDetails fileDetails: FileDetails {} + property var shareModelData: ({}) + + property bool canCreateLinkShares: true + + readonly property var share: shareModelData.share ?? ({}) + + readonly property string iconUrl: shareModelData.iconUrl ?? "" + readonly property string avatarUrl: shareModelData.avatarUrl ?? "" + readonly property string text: shareModelData.display ?? "" + readonly property string detailText: shareModelData.detailText ?? "" + readonly property string link: shareModelData.link ?? "" + readonly property string note: shareModelData.note ?? "" + readonly property string password: shareModelData.password ?? "" + readonly property string passwordPlaceholder: "●●●●●●●●●●" + + readonly property var expireDate: shareModelData.expireDate // Don't use int as we are limited + readonly property var maximumExpireDate: shareModelData.enforcedMaximumExpireDate + + readonly property string linkShareLabel: shareModelData.linkShareLabel ?? "" + + readonly property bool editingAllowed: shareModelData.editingAllowed + readonly property bool noteEnabled: shareModelData.noteEnabled + readonly property bool expireDateEnabled: shareModelData.expireDateEnabled + readonly property bool expireDateEnforced: shareModelData.expireDateEnforced + readonly property bool passwordProtectEnabled: shareModelData.passwordProtectEnabled + readonly property bool passwordEnforced: shareModelData.passwordEnforced + + readonly property bool isLinkShare: shareModelData.shareType === ShareModel.ShareTypeLink + readonly property bool isPlaceholderLinkShare: shareModelData.shareType === ShareModel.ShareTypePlaceholderLink + + property bool waitingForEditingAllowedChange: false + property bool waitingForNoteEnabledChange: false + property bool waitingForExpireDateEnabledChange: false + property bool waitingForPasswordProtectEnabledChange: false + property bool waitingForExpireDateChange: false + property bool waitingForLinkShareLabelChange: false + property bool waitingForPasswordChange: false + property bool waitingForNoteChange: false + + function showPasswordSetError(message) { + passwordErrorBoxLoader.message = message !== "" ? + message : qsTr("An error occurred setting the share password."); + } + + function resetNoteField() { + noteTextEdit.text = note; + waitingForNoteChange = false; + } + + function resetLinkShareLabelField() { + linkShareLabelTextField.text = linkShareLabel; + waitingForLinkShareLabelChange = false; + } + + function resetPasswordField() { + passwordTextField.text = password !== "" ? password : passwordPlaceholder; + waitingForPasswordChange = false; + } + + function resetExpireDateField() { + // Expire date changing is handled by the expireDateSpinBox + waitingForExpireDateChange = false; + } + + function resetEditingAllowedField() { + editingAllowedMenuItem.checked = editingAllowed; + waitingForEditingAllowedChange = false; + } + + function resetNoteEnabledField() { + noteEnabledMenuItem.checked = noteEnabled; + waitingForNoteEnabledChange = false; + } + + function resetExpireDateEnabledField() { + expireDateEnabledMenuItem.checked = expireDateEnabled; + waitingForExpireDateEnabledChange = false; + } + + function resetPasswordProtectEnabledField() { + passwordProtectEnabledMenuItem.checked = passwordProtectEnabled; + waitingForPasswordProtectEnabledChange = false; + } + + function resetMenu() { + moreMenu.close(); + + resetNoteField(); + resetPasswordField(); + resetLinkShareLabelField(); + resetExpireDateField(); + + resetEditingAllowedField(); + resetNoteEnabledField(); + resetExpireDateEnabledField(); + resetPasswordProtectEnabledField(); + } + + // Renaming a link share can lead to the model being reshuffled. + // This can cause a situation where this delegate is assigned to + // a new row and it doesn't have its properties signalled as + // changed by the model, leading to bugs. We therefore reset all + // the fields here when we detect the share has been changed + onShareChanged: resetMenu() + + // Reset value after property binding broken by user interaction + onNoteChanged: resetNoteField() + onPasswordChanged: resetPasswordField() + onLinkShareLabelChanged: resetLinkShareLabelField() + onExpireDateChanged: resetExpireDateField() + + onEditingAllowedChanged: resetEditingAllowedField() + onNoteEnabledChanged: resetNoteEnabledField() + onExpireDateEnabledChanged: resetExpireDateEnabledField() + onPasswordProtectEnabledChanged: resetPasswordProtectEnabledField() + + padding: Style.standardSpacing * 2 + + // TODO: Rather than setting all these palette colours manually, + // create a custom style and do it for all components globally + palette { + text: Style.ncTextColor + windowText: Style.ncTextColor + buttonText: Style.ncTextColor + light: Style.lightHover + midlight: Style.lightHover + mid: Style.ncSecondaryTextColor + dark: Style.menuBorder + button: Style.menuBorder + window: Style.backgroundColor + base: Style.backgroundColor + } + + background: Rectangle { + color: Style.backgroundColor + } + + header: ColumnLayout { + spacing: root.intendedPadding + + GridLayout { + id: headerGridLayout + + Layout.fillWidth: parent + Layout.topMargin: root.topPadding + + columns: 3 + rows: 2 + + rowSpacing: Style.standardSpacing / 2 + columnSpacing: Style.standardSpacing + + Image { + id: fileIcon + + Layout.rowSpan: headerGridLayout.rows + Layout.preferredWidth: Style.trayListItemIconSize + Layout.leftMargin: root.padding + Layout.fillHeight: true + + verticalAlignment: Image.AlignVCenter + horizontalAlignment: Image.AlignHCenter + source: root.fileDetails.iconUrl + sourceSize.width: Style.trayListItemIconSize + sourceSize.height: Style.trayListItemIconSize + fillMode: Image.PreserveAspectFit + } + + Label { + id: headLabel + + Layout.fillWidth: true + + text: qsTr("Edit share") + color: Style.ncTextColor + font.bold: true + elide: Text.ElideRight + } + + CustomButton { + id: closeButton + + Layout.rowSpan: headerGridLayout.rows + Layout.preferredWidth: Style.iconButtonWidth + Layout.preferredHeight: width + Layout.rightMargin: root.padding + + imageSource: "image://svgimage-custom-color/clear.svg" + "/" + Style.ncTextColor + bgColor: Style.lightHover + bgNormalOpacity: 0 + toolTipText: qsTr("Dismiss") + + onClicked: root.closeShareDetails() + } + + Label { + id: secondaryLabel + + Layout.fillWidth: true + Layout.rightMargin: root.padding + + text: root.fileDetails.name + color: Style.ncSecondaryTextColor + wrapMode: Text.Wrap + } + } + } + + ColumnLayout { + id: moreMenu + + property int rowIconWidth: 16 + property int indicatorItemWidth: 20 + property int indicatorSpacing: Style.standardSpacing + property int itemPadding: Style.smallSpacing + + RowLayout { + anchors.left: parent.left + anchors.leftMargin: moreMenu.itemPadding + anchors.right: parent.right + anchors.rightMargin: moreMenu.itemPadding + height: visible ? implicitHeight : 0 + spacing: moreMenu.indicatorSpacing + + visible: root.isLinkShare + + Image { + Layout.preferredWidth: moreMenu.indicatorItemWidth + Layout.fillHeight: true + + verticalAlignment: Image.AlignVCenter + horizontalAlignment: Image.AlignHCenter + fillMode: Image.Pad + + source: "image://svgimage-custom-color/edit.svg/" + Style.menuBorder + sourceSize.width: moreMenu.rowIconWidth + sourceSize.height: moreMenu.rowIconWidth + } + + NCInputTextField { + id: linkShareLabelTextField + + Layout.fillWidth: true + height: visible ? implicitHeight : 0 + + text: root.linkShareLabel + placeholderText: qsTr("Share label") + + enabled: root.isLinkShare && + !root.waitingForLinkShareLabelChange + + onAccepted: if(text !== root.linkShareLabel) { + root.setLinkShareLabel(text); + root.waitingForLinkShareLabelChange = true; + } + + NCBusyIndicator { + anchors.fill: parent + visible: root.waitingForLinkShareLabelChange + running: visible + z: 1 + } + } + } + + // On these checkables, the clicked() signal is called after + // the check state changes. + CheckBox { + id: editingAllowedMenuItem + + spacing: moreMenu.indicatorSpacing + padding: moreMenu.itemPadding + indicator.width: moreMenu.indicatorItemWidth + indicator.height: moreMenu.indicatorItemWidth + + checkable: true + checked: root.editingAllowed + text: qsTr("Allow editing") + enabled: !root.waitingForEditingAllowedChange + + onClicked: { + root.toggleAllowEditing(checked); + root.waitingForEditingAllowedChange = true; + } + + NCBusyIndicator { + anchors.fill: parent + visible: root.waitingForEditingAllowedChange + running: visible + z: 1 + } + } + + CheckBox { + id: passwordProtectEnabledMenuItem + + spacing: moreMenu.indicatorSpacing + padding: moreMenu.itemPadding + indicator.width: moreMenu.indicatorItemWidth + indicator.height: moreMenu.indicatorItemWidth + + checkable: true + checked: root.passwordProtectEnabled + text: qsTr("Password protect") + enabled: !root.waitingForPasswordProtectEnabledChange && !root.passwordEnforced + + onClicked: { + root.togglePasswordProtect(checked); + root.waitingForPasswordProtectEnabledChange = true; + } + + NCBusyIndicator { + anchors.fill: parent + visible: root.waitingForPasswordProtectEnabledChange + running: visible + z: 1 + } + } + + RowLayout { + anchors.left: parent.left + anchors.leftMargin: moreMenu.itemPadding + anchors.right: parent.right + anchors.rightMargin: moreMenu.itemPadding + height: visible ? implicitHeight : 0 + spacing: moreMenu.indicatorSpacing + + visible: root.passwordProtectEnabled + + Image { + Layout.preferredWidth: moreMenu.indicatorItemWidth + Layout.fillHeight: true + + verticalAlignment: Image.AlignVCenter + horizontalAlignment: Image.AlignHCenter + fillMode: Image.Pad + + source: "image://svgimage-custom-color/lock-https.svg/" + Style.menuBorder + sourceSize.width: moreMenu.rowIconWidth + sourceSize.height: moreMenu.rowIconWidth + } + + NCInputTextField { + id: passwordTextField + + Layout.fillWidth: true + height: visible ? implicitHeight : 0 + + text: root.password !== "" ? root.password : root.passwordPlaceholder + enabled: root.passwordProtectEnabled && + !root.waitingForPasswordChange && + !root.waitingForPasswordProtectEnabledChange + + onAccepted: if(text !== root.password && text !== root.passwordPlaceholder) { + passwordErrorBoxLoader.message = ""; + root.setPassword(text); + root.waitingForPasswordChange = true; + } + + NCBusyIndicator { + anchors.fill: parent + visible: root.waitingForPasswordChange || + root.waitingForPasswordProtectEnabledChange + running: visible + z: 1 + } + } + } + + Loader { + id: passwordErrorBoxLoader + + property string message: "" + + anchors.left: parent.left + anchors.right: parent.right + height: message !== "" ? implicitHeight : 0 + + active: message !== "" + visible: active + + sourceComponent: Item { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + // Artificially add vertical padding + implicitHeight: passwordErrorBox.implicitHeight + (Style.smallSpacing * 2) + + ErrorBox { + id: passwordErrorBox + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + text: passwordErrorBoxLoader.message + } + } + } + + CheckBox { + id: expireDateEnabledMenuItem + + spacing: moreMenu.indicatorSpacing + padding: moreMenu.itemPadding + indicator.width: moreMenu.indicatorItemWidth + indicator.height: moreMenu.indicatorItemWidth + + checkable: true + checked: root.expireDateEnabled + text: qsTr("Set expiration date") + enabled: !root.waitingForExpireDateEnabledChange && !root.expireDateEnforced + + onClicked: { + root.toggleExpirationDate(checked); + root.waitingForExpireDateEnabledChange = true; + } + + NCBusyIndicator { + anchors.fill: parent + visible: root.waitingForExpireDateEnabledChange + running: visible + z: 1 + } + } + + RowLayout { + anchors.left: parent.left + anchors.leftMargin: moreMenu.itemPadding + anchors.right: parent.right + anchors.rightMargin: moreMenu.itemPadding + height: visible ? implicitHeight : 0 + spacing: moreMenu.indicatorSpacing + + visible: root.expireDateEnabled + + Image { + Layout.preferredWidth: moreMenu.indicatorItemWidth + Layout.fillHeight: true + + verticalAlignment: Image.AlignVCenter + horizontalAlignment: Image.AlignHCenter + fillMode: Image.Pad + + source: "image://svgimage-custom-color/calendar.svg/" + Style.menuBorder + sourceSize.width: moreMenu.rowIconWidth + sourceSize.height: moreMenu.rowIconWidth + } + + // QML dates are essentially JavaScript dates, which makes them very finicky and unreliable. + // Instead, we exclusively deal with msecs from epoch time to make things less painful when editing. + // We only use the QML Date when showing the nice string to the user. + SpinBox { + id: expireDateSpinBox + + // Work arounds the limitations of QML's 32 bit integer when handling msecs from epoch + // Instead, we handle everything as days since epoch + readonly property int dayInMSecs: 24 * 60 * 60 * 1000 + readonly property int expireDateReduced: Math.floor(root.expireDate / dayInMSecs) + // Reset the model data after binding broken on user interact + onExpireDateReducedChanged: value = expireDateReduced + + // We can't use JS's convenient Infinity or Number.MAX_VALUE as + // JS Number type is 64 bits, whereas QML's int type is only 32 bits + readonly property IntValidator intValidator: IntValidator {} + readonly property int maximumExpireDateReduced: root.expireDateEnforced ? + Math.floor(root.maximumExpireDate / dayInMSecs) : + intValidator.top + readonly property int minimumExpireDateReduced: { + const currentDate = new Date(); + const minDateUTC = new Date(Date.UTC(currentDate.getFullYear(), + currentDate.getMonth(), + currentDate.getDate() + 1)); + return Math.floor(minDateUTC / dayInMSecs) // Start of day at 00:00:0000 UTC + } + + // Taken from Kalendar 22.08 + // https://invent.kde.org/pim/kalendar/-/blob/release/22.08/src/contents/ui/KalendarUtils/dateutils.js + function parseDateString(dateString) { + function defaultParse() { + const defaultParsedDate = Date.fromLocaleDateString(Qt.locale(), dateString, Locale.NarrowFormat); + // JS always generates date in system locale, eliminate timezone difference to UTC + const msecsSinceEpoch = defaultParsedDate.getTime() - (defaultParsedDate.getTimezoneOffset() * 60 * 1000); + return new Date(msecsSinceEpoch); + } + + const dateStringDelimiterMatches = dateString.match(/\D/); + if(dateStringDelimiterMatches.length === 0) { + // Let the date method figure out this weirdness + return defaultParse(); + } + + const dateStringDelimiter = dateStringDelimiterMatches[0]; + + const localisedDateFormatSplit = Qt.locale().dateFormat(Locale.NarrowFormat).split(dateStringDelimiter); + const localisedDateDayPosition = localisedDateFormatSplit.findIndex((x) => /d/gi.test(x)); + const localisedDateMonthPosition = localisedDateFormatSplit.findIndex((x) => /m/gi.test(x)); + const localisedDateYearPosition = localisedDateFormatSplit.findIndex((x) => /y/gi.test(x)); + + let splitDateString = dateString.split(dateStringDelimiter); + let userProvidedYear = splitDateString[localisedDateYearPosition] + + const dateNow = new Date(); + const stringifiedCurrentYear = dateNow.getFullYear().toString(); + + // If we have any input weirdness, or if we have a fully-written year + // (e.g. 2022 instead of 22) then use default parse + if(splitDateString.length === 0 || + splitDateString.length > 3 || + userProvidedYear.length >= stringifiedCurrentYear.length) { + + return defaultParse(); + } + + let fullyWrittenYear = userProvidedYear.split(""); + const digitsToAdd = stringifiedCurrentYear.length - fullyWrittenYear.length; + for(let i = 0; i < digitsToAdd; i++) { + fullyWrittenYear.splice(i, 0, stringifiedCurrentYear[i]) + } + fullyWrittenYear = fullyWrittenYear.join(""); + + const fixedYearNum = Number(fullyWrittenYear); + const monthIndexNum = Number(splitDateString[localisedDateMonthPosition]) - 1; + const dayNum = Number(splitDateString[localisedDateDayPosition]); + + console.log(dayNum, monthIndexNum, fixedYearNum); + + // Modification: return date in UTC + return new Date(Date.UTC(fixedYearNum, monthIndexNum, dayNum)); + } + + Layout.fillWidth: true + height: visible ? implicitHeight : 0 + + + // We want all the internal benefits of the spinbox but don't actually want the + // buttons, so set an empty item as a dummy + up.indicator: Item {} + down.indicator: Item {} + + background: Rectangle { + radius: Style.slightlyRoundedButtonRadius + border.width: Style.normalBorderWidth + border.color: expireDateSpinBox.activeFocus ? Style.ncBlue : Style.menuBorder + color: Style.backgroundColor + } + + value: expireDateReduced + from: minimumExpireDateReduced + to: maximumExpireDateReduced + + textFromValue: (value, locale) => { + const dateFromValue = new Date(value * dayInMSecs); + return dateFromValue.toLocaleDateString(Qt.locale(), Locale.NarrowFormat); + } + valueFromText: (text, locale) => { + const dateFromText = parseDateString(text); + return Math.floor(dateFromText.getTime() / dayInMSecs); + } + + editable: true + inputMethodHints: Qt.ImhDate | Qt.ImhFormattedNumbersOnly + + enabled: root.expireDateEnabled && + !root.waitingForExpireDateChange && + !root.waitingForExpireDateEnabledChange + + onValueModified: { + if (!enabled || !activeFocus) { + return; + } + + root.setExpireDate(value * dayInMSecs); + root.waitingForExpireDateChange = true; + } + + NCBusyIndicator { + anchors.fill: parent + visible: root.waitingForExpireDateEnabledChange || + root.waitingForExpireDateChange + running: visible + z: 1 + } + } + } + + CheckBox { + id: noteEnabledMenuItem + + spacing: moreMenu.indicatorSpacing + padding: moreMenu.itemPadding + indicator.width: moreMenu.indicatorItemWidth + indicator.height: moreMenu.indicatorItemWidth + + checkable: true + checked: root.noteEnabled + text: qsTr("Note to recipient") + enabled: !root.waitingForNoteEnabledChange + + onClicked: { + root.toggleNoteToRecipient(checked); + root.waitingForNoteEnabledChange = true; + } + + NCBusyIndicator { + anchors.fill: parent + visible: root.waitingForNoteEnabledChange + running: visible + z: 1 + } + } + + RowLayout { + anchors.left: parent.left + anchors.leftMargin: moreMenu.itemPadding + anchors.right: parent.right + anchors.rightMargin: moreMenu.itemPadding + height: visible ? implicitHeight : 0 + spacing: moreMenu.indicatorSpacing + + visible: root.noteEnabled + + Image { + Layout.preferredWidth: moreMenu.indicatorItemWidth + Layout.fillHeight: true + + verticalAlignment: Image.AlignVCenter + horizontalAlignment: Image.AlignHCenter + fillMode: Image.Pad + + source: "image://svgimage-custom-color/edit.svg/" + Style.menuBorder + sourceSize.width: moreMenu.rowIconWidth + sourceSize.height: moreMenu.rowIconWidth + } + + NCInputTextEdit { + id: noteTextEdit + + Layout.fillWidth: true + height: visible ? Math.max(Style.talkReplyTextFieldPreferredHeight, contentHeight) : 0 + submitButton.height: Math.min(Style.talkReplyTextFieldPreferredHeight, height - 2) + + text: root.note + enabled: root.noteEnabled && + !root.waitingForNoteChange && + !root.waitingForNoteEnabledChange + + onEditingFinished: if(text !== root.note) { + root.setNote(text); + root.waitingForNoteChange = true; + } + + NCBusyIndicator { + anchors.fill: parent + visible: root.waitingForNoteChange || + root.waitingForNoteEnabledChange + running: visible + z: 1 + } + } + } + + MenuItem { + spacing: moreMenu.indicatorSpacing + padding: moreMenu.itemPadding + + icon.width: moreMenu.indicatorItemWidth + icon.height: moreMenu.indicatorItemWidth + icon.color: Style.ncTextColor + icon.source: "qrc:///client/theme/close.svg" + text: qsTr("Unshare") + + onTriggered: root.deleteShare() + } + + MenuItem { + height: visible ? implicitHeight : 0 + spacing: moreMenu.indicatorSpacing + padding: moreMenu.itemPadding + + icon.width: moreMenu.indicatorItemWidth + icon.height: moreMenu.indicatorItemWidth + icon.color: Style.ncTextColor + icon.source: "qrc:///client/theme/add.svg" + text: qsTr("Add another link") + + visible: root.isLinkShare && root.canCreateLinkShares + enabled: visible + + onTriggered: root.createNewLinkShare() + } + } + +} diff --git a/src/gui/filedetails/ShareView.qml b/src/gui/filedetails/ShareView.qml index ebb04d21c..8bc90ba66 100644 --- a/src/gui/filedetails/ShareView.qml +++ b/src/gui/filedetails/ShareView.qml @@ -71,6 +71,8 @@ ColumnLayout { } } + property StackView rootStackView: StackView {} + Dialog { id: shareRequiresPasswordDialog @@ -216,12 +218,13 @@ ColumnLayout { if(shareId !== model.shareId) { return; } - shareDelegate.resetMenu(); } } iconSize: root.iconSize + fileDetails: root.fileDetails + rootStackView: root.rootStackView canCreateLinkShares: root.publicLinkSharingPossible onCreateNewLinkShare: {