Merge pull request #5194 from nextcloud/feature/share-details-page

Replace share settings popup with a page on a StackView
This commit is contained in:
Claudio Cambra 2022-12-09 13:32:55 +01:00 committed by GitHub
commit 0aea5cb0d0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 889 additions and 608 deletions

View file

@ -9,12 +9,14 @@
<file>src/gui/ErrorBox.qml</file>
<file>src/gui/filedetails/FileActivityView.qml</file>
<file>src/gui/filedetails/FileDetailsPage.qml</file>
<file>src/gui/filedetails/FileDetailsView.qml</file>
<file>src/gui/filedetails/FileDetailsWindow.qml</file>
<file>src/gui/filedetails/NCInputTextEdit.qml</file>
<file>src/gui/filedetails/NCInputTextField.qml</file>
<file>src/gui/filedetails/NCTabButton.qml</file>
<file>src/gui/filedetails/ShareeDelegate.qml</file>
<file>src/gui/filedetails/ShareDelegate.qml</file>
<file>src/gui/filedetails/ShareDetailsPage.qml</file>
<file>src/gui/filedetails/ShareeSearchField.qml</file>
<file>src/gui/filedetails/ShareView.qml</file>
<file>src/gui/tray/Window.qml</file>

View file

@ -24,7 +24,7 @@ Page {
id: root
property var accountState: ({})
property string localPath: ({})
property string localPath: ""
// We want the SwipeView to "spill" over the edges of the window to really
// make it look nice. If we apply page-wide padding, however, the swipe
@ -33,6 +33,7 @@ Page {
// padding, which we have to apply selectively to achieve our desired effect.
property int intendedPadding: Style.standardSpacing * 2
property int iconSize: 32
property StackView rootStackView: StackView {}
property FileDetails fileDetails: FileDetails {
id: fileDetails
@ -186,6 +187,7 @@ Page {
fileDetails: root.fileDetails
horizontalPadding: root.intendedPadding
iconSize: root.iconSize
rootStackView: root.rootStackView
}
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (C) 2022 by Claudio Cambra <claudio.cambra@nextcloud.com>
*
* 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.Layouts 1.15
import QtQuick.Controls 2.15
import com.nextcloud.desktopclient 1.0
import Style 1.0
StackView {
id: root
property var accountState: ({})
property string localPath: ""
initialItem: FileDetailsPage {
width: parent.width
height: parent.height
accountState: root.accountState
localPath: root.localPath
rootStackView: root
}
}

View file

@ -33,7 +33,7 @@ ApplicationWindow {
title: qsTr("File details of %1 · %2").arg(fileDetailsPage.fileDetails.name).arg(Systray.windowTitle)
FileDetailsPage {
FileDetailsView {
id: fileDetailsPage
anchors.fill: parent
accountState: root.accountState

View file

@ -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,6 +44,20 @@ GridLayout {
signal setPassword(string password)
signal setNote(string note)
property int iconSize: 32
property FileDetails fileDetails: FileDetails {}
property StackView rootStackView: StackView {}
property bool canCreateLinkShares: true
readonly property bool isLinkShare: model.shareType === ShareModel.ShareTypeLink
readonly property bool isPlaceholderLinkShare: model.shareType === ShareModel.ShareTypePlaceholderLink
readonly property string text: model.display ?? ""
readonly property string detailText: model.detailText ?? ""
readonly property string iconUrl: model.iconUrl ?? ""
readonly property string avatarUrl: model.avatarUrl ?? ""
anchors.left: parent.left
anchors.right: parent.right
@ -49,122 +67,6 @@ GridLayout {
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 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;
}
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()
Item {
id: imageItem
@ -310,506 +212,50 @@ GridLayout {
visible: !root.isPlaceholderLinkShare
enabled: visible
onClicked: moreMenu.popup()
onClicked: root.rootStackView.push(shareDetailsPageComponent, {}, StackView.PushTransition)
Menu {
id: moreMenu
Component {
id: shareDetailsPageComponent
ShareDetailsPage {
id: shareDetailsPage
property int rowIconWidth: 16
property int indicatorItemWidth: 20
property int indicatorSpacing: Style.standardSpacing
property int itemPadding: Style.smallSpacing
width: parent.width
height: parent.height
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
}
fileDetails: root.fileDetails
shareModelData: model
RowLayout {
anchors.left: parent.left
anchors.leftMargin: moreMenu.itemPadding
anchors.right: parent.right
anchors.rightMargin: moreMenu.itemPadding
height: visible ? implicitHeight : 0
spacing: moreMenu.indicatorSpacing
canCreateLinkShares: root.canCreateLinkShares
visible: root.isLinkShare
onCloseShareDetails: root.rootStackView.pop(root.rootStackView.initialItem, StackView.PopTransition)
Image {
Layout.preferredWidth: moreMenu.indicatorItemWidth
Layout.fillHeight: true
onToggleAllowEditing: root.toggleAllowEditing(enable)
onToggleAllowResharing: root.toggleAllowResharing(enable)
onTogglePasswordProtect: root.togglePasswordProtect(enable)
onToggleExpirationDate: root.toggleExpirationDate(enable)
onToggleNoteToRecipient: root.toggleNoteToRecipient(enable)
verticalAlignment: Image.AlignVCenter
horizontalAlignment: Image.AlignHCenter
fillMode: Image.Pad
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)
source: "image://svgimage-custom-color/edit.svg/" + Style.menuBorder
sourceSize.width: moreMenu.rowIconWidth
sourceSize.height: moreMenu.rowIconWidth
onDeleteShare: {
root.deleteShare();
closeShareDetails();
}
onCreateNewLinkShare: {
root.createNewLinkShare();
closeShareDetails();
}
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
}
Connections {
target: root
function onResetMenu() { shareDetailsPage.resetMenu() }
function onResetPasswordField() { shareDetailsPage.resetPasswordField() }
function onShowPasswordSetError(errorMessage) { shareDetailsPage.showPasswordSetError(errorMessage) }
}
}
// 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()
}
}
}
}

View file

@ -0,0 +1,787 @@
/*
* Copyright (C) 2022 by Claudio Cambra <claudio.cambra@nextcloud.com>
*
* 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.padding
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
}
EnforcedPlainTextLabel {
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()
}
EnforcedPlainTextLabel {
id: secondaryLabel
Layout.fillWidth: true
Layout.rightMargin: root.padding
text: root.fileDetails.name
color: Style.ncSecondaryTextColor
wrapMode: Text.Wrap
}
}
}
contentItem: ScrollView {
contentWidth: availableWidth
clip: true
ColumnLayout {
id: moreMenu
property int rowIconWidth: 16
property int indicatorItemWidth: 20
property int indicatorSpacing: Style.standardSpacing
property int itemPadding: Style.smallSpacing
width: parent.width
RowLayout {
Layout.fillWidth: true
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
Layout.fillWidth: true
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
Layout.fillWidth: true
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 {
Layout.fillWidth: true
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: ""
Layout.fillWidth: true
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
Layout.fillWidth: true
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 {
Layout.fillWidth: true
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 {}
padding: 0
background: null
contentItem: NCInputTextField {
text: expireDateSpinBox.textFromValue(expireDateSpinBox.value, expireDateSpinBox.locale)
readOnly: !expireDateSpinBox.editable
validator: expireDateSpinBox.validator
inputMethodHints: Qt.ImhFormattedNumbersOnly
onAccepted: {
expireDateSpinBox.value = expireDateSpinBox.valueFromText(text, expireDateSpinBox.locale);
expireDateSpinBox.valueModified();
}
}
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
Layout.fillWidth: true
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 {
Layout.fillWidth: true
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
}
}
}
CustomButton {
height: Style.standardPrimaryButtonHeight
imageSource: "image://svgimage-custom-color/close.svg/" + Style.errorBoxBackgroundColor
imageSourceHover: "image://svgimage-custom-color/close.svg/" + Style.ncHeaderTextColor
text: qsTr("Unshare")
textColor: Style.errorBoxBackgroundColor
textColorHovered: "white"
contentsFont.bold: true
bgNormalColor: Style.buttonBackgroundColor
bgHoverColor: Style.errorBoxBackgroundColor
bgNormalOpacity: 1.0
bgHoverOpacity: 1.0
onClicked: root.deleteShare()
}
CustomButton {
height: Style.standardPrimaryButtonHeight
imageSource: "image://svgimage-custom-color/add.svg/" + Style.ncBlue
imageSourceHover: "image://svgimage-custom-color/add.svg/" + Style.ncHeaderTextColor
text: qsTr("Add another link")
textColor: Style.ncBlue
textColorHovered: Style.ncHeaderTextColor
contentsFont.bold: true
bgNormalColor: Style.buttonBackgroundColor
bgHoverColor: Style.ncBlue
bgNormalOpacity: 1.0
bgHoverOpacity: 1.0
visible: root.isLinkShare && root.canCreateLinkShares
enabled: visible
onClicked: root.createNewLinkShare()
}
}
}
footer: DialogButtonBox {
topPadding: 0
bottomPadding: root.padding
rightPadding: root.padding
leftPadding: root.padding
alignment: Qt.AlignRight | Qt.AlignVCenter
visible: copyShareLinkButton.visible
CustomButton {
id: copyShareLinkButton
height: Style.standardPrimaryButtonHeight
imageSource: "image://svgimage-custom-color/copy.svg/" + Style.ncHeaderTextColor
text: qsTr("Copy share link")
textColor: Style.ncHeaderTextColor
contentsFont.bold: true
bgColor: Style.ncBlue
bgNormalOpacity: 1.0
bgHoverOpacity: Style.hoverOpacity
visible: root.isLinkShare
enabled: visible
onClicked: {
clipboardHelper.text = root.link;
clipboardHelper.selectAll();
clipboardHelper.copy();
clipboardHelper.clear();
}
TextEdit { id: clipboardHelper; visible: false }
}
}
}

View file

@ -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: {

View file

@ -18,6 +18,8 @@ Button {
property alias contentsFont: contents.font
property alias bgColor: bgRectangle.color
property alias bgNormalColor: bgRectangle.normalColor
property alias bgHoverColor: bgRectangle.hoverColor
property alias bgNormalOpacity: bgRectangle.normalOpacity
property alias bgHoverOpacity: bgRectangle.hoverOpacity

View file

@ -14,12 +14,16 @@
import QtQuick 2.15
import Style 1.0
Rectangle {
property bool hovered: false
property real normalOpacity: 0.3
property real hoverOpacity: 1.0
property color normalColor: Style.buttonBackgroundColor
property color hoverColor: Style.buttonBackgroundColor
color: "transparent"
color: hovered ? hoverColor : normalColor
opacity: hovered ? hoverOpacity : normalOpacity
radius: width / 2
}