Dekko
Loading...
Searching...
No Matches
InviteCard.qml
1/* Copyright (C) 2026 Dekko Project
2
3 This file is part of Dekko email client for Ubuntu devices
4
5 This program is free software; you can redistribute it and/or
6 modify it under the terms of the GNU General Public License as
7 published by the Free Software Foundation; either version 2 of
8 the License or (at your option) version 3
9
10 This program is distributed in the hope that it will be useful,
11 but WITHOUT ANY WARRANTY; without even the implied warranty of
12 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 GNU General Public License for more details.
14
15 You should have received a copy of the GNU General Public License
16 along with this program. If not, see <http://www.gnu.org/licenses/>.
17*/
18import QtQuick 2.9
19import QtQuick.Controls.Suru 2.2
20import Lomiri.Components 1.3
21import Dekko.Mail 1.0
22import Dekko.Mail.API 1.0
23import Dekko.Mail.Stores.Accounts 1.0
24import Dekko.Lomiri.Constants 1.0
25
31Item {
32 id: card
33
35 property var invite: null
37 property int maxHeight: units.gu(40)
39 property var pickerRoot: null
40
41 readonly property bool active: invite !== null && (invite.valid || invite.loading)
42 property bool showDetails: false
43 // Set while an answer is being sent, so that a second tap cannot send it twice
44 property bool responding: false
45
46 function answer(response) {
47 if (responding) {
48 return
49 }
50 responding = true
51 invite.respond(response)
52 }
53
54 height: active ? Math.min(content.height, maxHeight) : 0
55 visible: active
56 clip: true
57
58 // The invitation has to know who we are to tell which of the invited people is the user
59 function shareIdentities() {
60 if (invite) {
61 invite.identities = AccountStore.identities
62 }
63 }
64 onInviteChanged: {
65 responding = false
66 shareIdentities()
67 }
68 Component.onCompleted: shareIdentities()
69
70 Connections {
71 target: card.invite
72 onResponseQueued: {
73 card.responding = false
74 ViewActions.orderSimpleToast(ViewKeys.toastMainWindow, qsTr("Your answer will be sent to the organizer."))
75 }
76 onResponseFailed: {
77 card.responding = false
78 ViewActions.orderSimpleToast(ViewKeys.toastMainWindow, reason)
79 }
80 }
81
82 function statusText(status) {
83 switch (status) {
84 case "ACCEPTED":
85 return qsTr("Accepted")
86 case "TENTATIVE":
87 return qsTr("Tentative")
88 case "DECLINED":
89 return qsTr("Declined")
90 case "DELEGATED":
91 return qsTr("Delegated")
92 default:
93 return qsTr("No answer yet")
94 }
95 }
96
97 function headline() {
98 if (!invite || !invite.valid) {
99 return ""
100 }
101 if (invite.cancelled) {
102 return qsTr("This event has been cancelled")
103 }
104 if (invite.method === "REPLY") {
105 switch (invite.replyStatus) {
106 case "ACCEPTED":
107 return qsTr("%1 has accepted").arg(invite.replyFrom)
108 case "TENTATIVE":
109 return qsTr("%1 has tentatively accepted").arg(invite.replyFrom)
110 case "DECLINED":
111 return qsTr("%1 has declined").arg(invite.replyFrom)
112 }
113 return qsTr("Answer to an invitation")
114 }
115 switch (invite.response) {
116 case "ACCEPTED":
117 return qsTr("You have accepted this invitation")
118 case "TENTATIVE":
119 return qsTr("You have tentatively accepted this invitation")
120 case "DECLINED":
121 return qsTr("You have declined this invitation")
122 }
123 if (invite.method === "REQUEST") {
124 return qsTr("Invitation from %1").arg(invite.organizerName)
125 }
126 return qsTr("Event")
127 }
128
129 Rectangle {
130 anchors.fill: parent
131 color: Suru.secondaryBackgroundColor
132 }
133
134 Flickable {
135 anchors.fill: parent
136 contentWidth: width
137 contentHeight: content.height
138 interactive: content.height > card.height
139 boundsBehavior: Flickable.StopAtBounds
140
141 Column {
142 id: content
143 x: Style.defaultSpacing
144 width: card.width - (2 * Style.defaultSpacing)
145 spacing: Style.smallSpacing
146 topPadding: Style.smallSpacing
147 bottomPadding: Style.smallSpacing
148
149 Row {
150 width: parent.width
151 spacing: Style.smallSpacing
152 visible: card.invite !== null && card.invite.loading
153
154 ActivityIndicator {
155 running: parent.visible
156 height: units.gu(3)
157 width: height
158 }
159 Label {
160 anchors.verticalCenter: parent.verticalCenter
161 text: qsTr("Loading invitation…")
162 }
163 }
164
165 Item {
166 width: parent.width
167 height: Math.max(calendarIcon.height, titles.height)
168 visible: card.invite !== null && card.invite.valid
169
170 Icon {
171 id: calendarIcon
172 name: "calendar"
173 height: Style.largeIconSize
174 width: height
175 color: Suru.foregroundColor
176 anchors {
177 left: parent.left
178 top: parent.top
179 }
180 }
181
182 Column {
183 id: titles
184 anchors {
185 left: calendarIcon.right
186 leftMargin: Style.smallSpacing
187 right: detailsToggle.left
188 rightMargin: Style.smallSpacing
189 top: parent.top
190 }
191
192 Label {
193 objectName: "inviteHeadline"
194 width: parent.width
195 text: card.headline()
196 fontSize: "small"
197 color: card.invite && card.invite.cancelled ? Suru.highlightColor : Suru.secondaryForegroundColor
198 Suru.highlightType: Suru.NegativeHighlight
199 wrapMode: Text.WordWrap
200 }
201 Label {
202 objectName: "inviteSummary"
203 width: parent.width
204 text: card.invite ? card.invite.summary : ""
205 visible: text !== ""
206 font.weight: Font.DemiBold
207 font.strikeout: card.invite ? card.invite.cancelled : false
208 wrapMode: Text.WordWrap
209 maximumLineCount: 3
210 elide: Text.ElideRight
211 }
212 Label {
213 objectName: "inviteWhen"
214 width: parent.width
215 text: {
216 if (!card.invite) {
217 return ""
218 }
219 var t = card.invite.when
220 if (card.invite.allDay) {
221 t += " " + qsTr("(all day)")
222 }
223 if (card.invite.recurring) {
224 t += " " + qsTr("(repeats)")
225 }
226 return t
227 }
228 wrapMode: Text.WordWrap
229 }
230 Label {
231 objectName: "inviteLocation"
232 width: parent.width
233 text: card.invite ? card.invite.location : ""
234 visible: text !== ""
235 color: Suru.secondaryForegroundColor
236 wrapMode: Text.WordWrap
237 maximumLineCount: 2
238 elide: Text.ElideRight
239 }
240 Label {
241 width: parent.width
242 visible: card.invite !== null && card.invite.valid && !card.invite.timeZoneKnown
243 text: card.invite
244 ? qsTr("The time zone \"%1\" is not known, the time shown may be wrong.").arg(card.invite.timeZoneId)
245 : ""
246 fontSize: "small"
247 color: Suru.highlightColor
248 Suru.highlightType: Suru.NegativeHighlight
249 wrapMode: Text.WordWrap
250 }
251 }
252
253 AbstractButton {
254 id: detailsToggle
255 objectName: "inviteDetailsToggle"
256 width: units.gu(4)
257 height: units.gu(4)
258 anchors {
259 right: parent.right
260 top: parent.top
261 }
262 onClicked: card.showDetails = !card.showDetails
263
264 Icon {
265 anchors.centerIn: parent
266 name: card.showDetails ? "up" : "down"
267 height: Style.defaultIconSize
268 width: height
269 color: Suru.foregroundColor
270 }
271 }
272 }
273
274 Column {
275 id: details
276 width: parent.width
277 spacing: units.gu(0.5)
278 visible: card.showDetails && card.invite !== null && card.invite.valid
279
280 Label {
281 width: parent.width
282 visible: card.invite !== null && card.invite.organizerEmail !== ""
283 text: card.invite ? qsTr("Organizer: %1").arg(
284 card.invite.organizerName === card.invite.organizerEmail
285 ? card.invite.organizerEmail
286 : card.invite.organizerName + " <" + card.invite.organizerEmail + ">")
287 : ""
288 wrapMode: Text.WrapAtWordBoundaryOrAnywhere
289 }
290
291 Label {
292 width: parent.width
293 visible: attendeeList.count > 0
294 text: qsTr("Invited:")
295 color: Suru.secondaryForegroundColor
296 }
297
298 Repeater {
299 id: attendeeList
300 model: card.showDetails && card.invite ? card.invite.attendees : []
301 delegate: Item {
302 width: details.width
303 height: Math.max(attendeeName.height, attendeeStatus.height)
304
305 Label {
306 id: attendeeName
307 anchors {
308 left: parent.left
309 leftMargin: Style.smallSpacing
310 right: attendeeStatus.left
311 rightMargin: Style.smallSpacing
312 }
313 text: modelData.isSelf ? qsTr("%1 (you)").arg(modelData.name) : modelData.name
314 elide: Text.ElideRight
315 }
316 Label {
317 id: attendeeStatus
318 anchors.right: parent.right
319 text: modelData.isOrganizer ? qsTr("Organizer") : card.statusText(modelData.status)
320 fontSize: "small"
321 color: Suru.secondaryForegroundColor
322 }
323 }
324 }
325
326 Label {
327 width: parent.width
328 visible: text !== ""
329 text: card.invite ? card.invite.description : ""
330 textFormat: Text.PlainText
331 wrapMode: Text.WrapAtWordBoundaryOrAnywhere
332 maximumLineCount: 12
333 elide: Text.ElideRight
334 }
335 }
336
337 Flow {
338 width: parent.width
339 spacing: Style.smallSpacing
340 visible: card.invite !== null && card.invite.valid
341
342 Button {
343 objectName: "inviteAccept"
344 visible: card.invite !== null && card.invite.canRespond
345 text: qsTr("Accept")
346 color: card.invite && card.invite.response === "ACCEPTED" ? theme.palette.normal.positive
347 : theme.palette.normal.foreground
348 enabled: !card.responding
349 onClicked: card.answer(CalendarInvite.Accepted)
350 }
351 Button {
352 objectName: "inviteTentative"
353 visible: card.invite !== null && card.invite.canRespond
354 text: qsTr("Maybe")
355 color: card.invite && card.invite.response === "TENTATIVE" ? theme.palette.normal.activity
356 : theme.palette.normal.foreground
357 enabled: !card.responding
358 onClicked: card.answer(CalendarInvite.Tentative)
359 }
360 Button {
361 objectName: "inviteDecline"
362 visible: card.invite !== null && card.invite.canRespond
363 text: qsTr("Decline")
364 color: card.invite && card.invite.response === "DECLINED" ? theme.palette.normal.negative
365 : theme.palette.normal.foreground
366 enabled: !card.responding
367 onClicked: card.answer(CalendarInvite.Declined)
368 }
369 Button {
370 objectName: "inviteExport"
371 visible: card.invite !== null && !card.invite.cancelled && card.invite.method !== "REPLY"
372 text: qsTr("Add to calendar")
373 color: theme.palette.normal.foreground
374 onClicked: {
375 const file = card.invite.saveToFile()
376 if (file === "") {
377 ViewActions.orderSimpleToast(ViewKeys.toastMainWindow, qsTr("The invitation could not be saved."))
378 return
379 }
380 const url = "file://" + file
381 if (isRunningOnMir) {
382 ContentActions.exportFile(card.pickerRoot, url)
383 } else {
384 Qt.openUrlExternally(url)
385 }
386 }
387 }
388 }
389 }
390 }
391
392 Rectangle {
393 anchors {
394 left: parent.left
395 right: parent.right
396 bottom: parent.bottom
397 }
398 height: Style.lineSize
399 color: Style.lineColor
400 }
401}