diff -Nru thunderbird-115.4.2+build1/BUILDID thunderbird-115.4.3+build1/BUILDID --- thunderbird-115.4.2+build1/BUILDID 2023-11-08 07:25:35.000000000 +0000 +++ thunderbird-115.4.3+build1/BUILDID 2023-11-14 08:21:52.000000000 +0000 @@ -1 +1 @@ -20231104124805 \ No newline at end of file +20231113191723 \ No newline at end of file diff -Nru thunderbird-115.4.2+build1/comm/calendar/base/content/imip-bar.js thunderbird-115.4.3+build1/comm/calendar/base/content/imip-bar.js --- thunderbird-115.4.2+build1/comm/calendar/base/content/imip-bar.js 2023-11-08 07:24:33.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/calendar/base/content/imip-bar.js 2023-11-14 08:20:47.000000000 +0000 @@ -386,221 +386,22 @@ * @returns {boolean} true, if the action succeeded */ executeAction(aParticipantStatus, aResponse) { - // control to avoid processing _execAction on later user changes on the item - let isFirstProcessing = true; - - /** - * Internal function to trigger an scheduling operation - * - * @param {Function} aActionFunc The function to call to do the - * scheduling operation - * @param {calIItipItem} aItipItem Scheduling item - * @param {nsIWindow} aWindow The current window - * @param {string} aPartStat partstat string as per RFC 5545 - * @param {object} aExtResponse JS object containing at least - * an responseMode property - * @returns {boolean} true, if the action succeeded - */ - function _execAction(aActionFunc, aItipItem, aWindow, aPartStat, aExtResponse) { - let method = aActionFunc.method; - if (cal.itip.promptCalendar(aActionFunc.method, aItipItem, aWindow)) { - if ( - method == "REQUEST" && - !cal.itip.promptInvitedAttendee(window, aItipItem, Ci.calIItipItem[aResponse]) - ) { - return false; - } - - let isDeclineCounter = aPartStat == "X-DECLINECOUNTER"; - // filter out fake partstats - if (aPartStat.startsWith("X-")) { - aParticipantStatus = ""; + return cal.itip.executeAction( + window, + aParticipantStatus, + aResponse, + calImipBar.actionFunc, + calImipBar.itipItem, + calImipBar.foundItems, + ({ resetButtons, label }) => { + if (label != undefined) { + calImipBar.label = label; } - // hide the buttons now, to disable pressing them twice... - if (aPartStat == aParticipantStatus) { + if (resetButtons) { calImipBar.resetButtons(); } - - let opListener = { - QueryInterface: ChromeUtils.generateQI(["calIOperationListener"]), - onOperationComplete(aCalendar, aStatus, aOperationType, aId, aDetail) { - isFirstProcessing = false; - if (Components.isSuccessCode(aStatus) && isDeclineCounter) { - // TODO: move the DECLINECOUNTER stuff to actionFunc - aItipItem.getItemList().forEach(aItem => { - // we can rely on the received itipItem to reply at this stage - // already, the checks have been done in cal.itip.processFoundItems - // when setting up the respective aActionFunc - let attendees = cal.itip.getAttendeesBySender( - aItem.getAttendees(), - aItipItem.sender - ); - let status = true; - if ( - attendees.length == 1 && - calImipBar.foundItems && - calImipBar.foundItems.length - ) { - // we must return a message with the same sequence number as the - // counterproposal - to make it easy, we simply use the received - // item and just remove a comment, if any - try { - let item = aItem.clone(); - item.calendar = calImipBar.foundItems[0].calendar; - item.deleteProperty("COMMENT"); - // once we have full support to deal with for multiple items - // in a received invitation message, we should send this - // from outside outside of the forEach context - status = cal.itip.sendDeclineCounterMessage(item, "DECLINECOUNTER", attendees, { - value: false, - }); - } catch (e) { - cal.ERROR(e); - status = false; - } - } else { - status = false; - } - if (!status) { - cal.ERROR("Failed to send DECLINECOUNTER reply!"); - } - }); - } - // For now, we just state the status for the user something very simple - let label = cal.itip.getCompleteText(aStatus, aOperationType); - imipBar.label = label; - - if (!Components.isSuccessCode(aStatus)) { - cal.showError(label); - return; - } - - if (Services.prefs.getBoolPref("calendar.itip.newInvitationDisplay")) { - window.dispatchEvent( - new CustomEvent("onItipItemActionFinished", { detail: aItipItem }) - ); - } - }, - onGetResult(calendar, status, itemType, detail, items) {}, - }; - - try { - aActionFunc(opListener, aParticipantStatus, aExtResponse); - } catch (exc) { - console.error(exc); - } - return true; } - return false; - } - - if (aParticipantStatus == null) { - aParticipantStatus = ""; - } - if (aParticipantStatus == "X-SHOWDETAILS" || aParticipantStatus == "X-RESCHEDULE") { - let counterProposal; - let items = calImipBar.foundItems; - if (items && items.length) { - let item = items[0].isMutable ? items[0] : items[0].clone(); - - if (aParticipantStatus == "X-RESCHEDULE") { - // TODO most of the following should be moved to the actionFunc defined in - // calItipUtils - let proposedItem = calImipBar.itipItem.getItemList()[0]; - let proposedRID = proposedItem.getProperty("RECURRENCE-ID"); - if (proposedRID) { - // if this is a counterproposal for a specific occurrence, we use - // that to compare with - item = item.recurrenceInfo.getOccurrenceFor(proposedRID).clone(); - } - let parsedProposal = cal.invitation.parseCounter(proposedItem, item); - let potentialProposers = cal.itip.getAttendeesBySender( - proposedItem.getAttendees(), - calImipBar.itipItem.sender - ); - let proposingAttendee = potentialProposers.length == 1 ? potentialProposers[0] : null; - if ( - proposingAttendee && - ["OK", "OUTDATED", "NOTLATESTUPDATE"].includes(parsedProposal.result.type) - ) { - counterProposal = { - attendee: proposingAttendee, - proposal: parsedProposal.differences, - oldVersion: - parsedProposal.result == "OLDVERSION" || parsedProposal.result == "NOTLATESTUPDATE", - onReschedule: () => { - imipBar.label = cal.l10n.getLtnString("imipBarCounterPreviousVersionText"); - // TODO: should we hide the buttons in this case, too? - }, - }; - } else { - imipBar.label = cal.l10n.getLtnString("imipBarCounterErrorText"); - calImipBar.resetButtons(); - if (proposingAttendee) { - cal.LOG(parsedProposal.result.descr); - } else { - cal.LOG("Failed to identify the sending attendee of the counterproposal."); - } - - return false; - } - } - // if this a rescheduling operation, we suppress the occurrence - // prompt here - modifyEventWithDialog(item, aParticipantStatus != "X-RESCHEDULE", null, counterProposal); - } - } else { - let response; - if (aResponse) { - if (aResponse == "AUTO" || aResponse == "NONE" || aResponse == "USER") { - response = { responseMode: Ci.calIItipItem[aResponse] }; - } - // Open an extended response dialog to enable the user to add a comment, make a - // counterproposal, delegate the event or interact in another way. - // Instead of a dialog, this might be implemented as a separate container inside the - // imip-overlay as proposed in bug 458578 - } - let delmgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService( - Ci.calIDeletedItems - ); - let items = calImipBar.itipItem.getItemList(); - if (items && items.length) { - let delTime = delmgr.getDeletedDate(items[0].id); - let dialogText = cal.l10n.getLtnString("confirmProcessInvitation"); - let dialogTitle = cal.l10n.getLtnString("confirmProcessInvitationTitle"); - if (delTime && !Services.prompt.confirm(window, dialogTitle, dialogText)) { - return false; - } - } - - if (aParticipantStatus == "X-SAVECOPY") { - // we create and adopt copies of the respective events - let saveitems = calImipBar.itipItem - .getItemList() - .map(cal.itip.getPublishLikeItemCopy.bind(cal)); - if (saveitems.length > 0) { - let methods = { receivedMethod: "PUBLISH", responseMethod: "PUBLISH" }; - let newItipItem = cal.itip.getModifiedItipItem(calImipBar.itipItem, saveitems, methods); - // setup callback and trigger re-processing - let storeCopy = function (aItipItem, aRc, aActionFunc, aFoundItems) { - if (isFirstProcessing && aActionFunc && Components.isSuccessCode(aRc)) { - _execAction(aActionFunc, aItipItem, window, aParticipantStatus); - } - }; - cal.itip.processItipItem(newItipItem, storeCopy); - } - // we stop here to not process the original item - return false; - } - return _execAction( - calImipBar.actionFunc, - calImipBar.itipItem, - window, - aParticipantStatus, - response - ); - } - return false; + ); }, /** @@ -615,41 +416,6 @@ } } }, - - /** - * Open (or focus if already open) the calendar tab, even if the imip bar is - * in a message window, and even if there is no main three pane Thunderbird - * window open. Called when clicking the imip bar's calendar button. - */ - goToCalendar() { - let openCal = mainWindow => { - mainWindow.focus(); - mainWindow.document.getElementById("tabmail").openTab("calendar"); - }; - - let mainWindow = Services.wm.getMostRecentWindow("mail:3pane"); - - if (mainWindow) { - openCal(mainWindow); - } else { - mainWindow = Services.ww.openWindow( - null, - "chrome://messenger/content/messenger.xhtml", - "_blank", - "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar", - null - ); - - // Wait until calendar is set up in the new window. - let calStartupObserver = { - observe(subject, topic, data) { - openCal(mainWindow); - Services.obs.removeObserver(calStartupObserver, "calendar-startup-done"); - }, - }; - Services.obs.addObserver(calStartupObserver, "calendar-startup-done"); - } - }, }; { diff -Nru thunderbird-115.4.2+build1/comm/calendar/base/content/imip-bar-overlay.inc.xhtml thunderbird-115.4.3+build1/comm/calendar/base/content/imip-bar-overlay.inc.xhtml --- thunderbird-115.4.2+build1/comm/calendar/base/content/imip-bar-overlay.inc.xhtml 2023-11-08 07:24:33.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/calendar/base/content/imip-bar-overlay.inc.xhtml 2023-11-14 08:20:47.000000000 +0000 @@ -107,7 +107,7 @@ label="&lightning.imipbar.btnGoToCalendar.label;" tooltiptext="&lightning.imipbar.btnGoToCalendar.tooltiptext;" class="toolbarbutton-1 message-header-view-button imipGoToCalendarButton" - oncommand="calImipBar.goToCalendar();" + oncommand="cal.window.goToCalendar();" hidden="true"/> diff -Nru thunderbird-115.4.2+build1/comm/calendar/base/modules/utils/calItipUtils.jsm thunderbird-115.4.3+build1/comm/calendar/base/modules/utils/calItipUtils.jsm --- thunderbird-115.4.2+build1/comm/calendar/base/modules/utils/calItipUtils.jsm 2023-11-08 07:24:33.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/calendar/base/modules/utils/calItipUtils.jsm 2023-11-14 08:20:47.000000000 +0000 @@ -598,6 +598,249 @@ }, /** + * Executes an action from a calandar message. + * + * @param {nsIWindow} aWindow - The current window + * @param {string} aParticipantStatus - A partstat string as per RfC 5545 + * @param {string} aResponse - Either 'AUTO', 'NONE' or 'USER', see + * calItipItem interface + * @param {Function} aActionFunc - The function to call to do the scheduling + * operation + * @param {calIItipItem} aItipItem - Scheduling item + * @param {array} aFoundItems - The items found when looking for the calendar item + * @param {Function} aUpdateFunction - A function to call which will update the UI + * @returns {boolean} true, if the action succeeded + */ + executeAction( + aWindow, + aParticipantStatus, + aResponse, + aActionFunc, + aItipItem, + aFoundItems, + aUpdateFunction + ) { + // control to avoid processing _execAction on later user changes on the item + let isFirstProcessing = true; + + /** + * Internal function to trigger an scheduling operation + * + * @param {Function} aActionFunc - The function to call to do the + * scheduling operation + * @param {calIItipItem} aItipItem - Scheduling item + * @param {nsIWindow} aWindow - The current window + * @param {string} aPartStat - partstat string as per RFC 5545 + * @param {object} aExtResponse - JS object containing at least an responseMode + * property + * @returns {boolean} true, if the action succeeded + */ + function _execAction(aActionFunc, aItipItem, aWindow, aPartStat, aExtResponse) { + let method = aActionFunc.method; + if (lazy.cal.itip.promptCalendar(aActionFunc.method, aItipItem, aWindow)) { + if ( + method == "REQUEST" && + !lazy.cal.itip.promptInvitedAttendee(aWindow, aItipItem, Ci.calIItipItem[aResponse]) + ) { + return false; + } + + let isDeclineCounter = aPartStat == "X-DECLINECOUNTER"; + // filter out fake partstats + if (aPartStat.startsWith("X-")) { + aParticipantStatus = ""; + } + // hide the buttons now, to disable pressing them twice... + if (aPartStat == aParticipantStatus) { + aUpdateFunction({ resetButtons: true }); + } + + let opListener = { + QueryInterface: ChromeUtils.generateQI(["calIOperationListener"]), + onOperationComplete(aCalendar, aStatus, aOperationType, aId, aDetail) { + isFirstProcessing = false; + if (Components.isSuccessCode(aStatus) && isDeclineCounter) { + // TODO: move the DECLINECOUNTER stuff to actionFunc + aItipItem.getItemList().forEach(aItem => { + // we can rely on the received itipItem to reply at this stage + // already, the checks have been done in cal.itip.processFoundItems + // when setting up the respective aActionFunc + let attendees = lazy.cal.itip.getAttendeesBySender( + aItem.getAttendees(), + aItipItem.sender + ); + let status = true; + if (attendees.length == 1 && aFoundItems?.length) { + // we must return a message with the same sequence number as the + // counterproposal - to make it easy, we simply use the received + // item and just remove a comment, if any + try { + let item = aItem.clone(); + item.calendar = aFoundItems[0].calendar; + item.deleteProperty("COMMENT"); + // once we have full support to deal with for multiple items + // in a received invitation message, we should send this + // from outside outside of the forEach context + status = lazy.cal.itip.sendDeclineCounterMessage( + item, + "DECLINECOUNTER", + attendees, + { + value: false, + } + ); + } catch (e) { + lazy.cal.ERROR(e); + status = false; + } + } else { + status = false; + } + if (!status) { + lazy.cal.ERROR("Failed to send DECLINECOUNTER reply!"); + } + }); + } + // For now, we just state the status for the user something very simple + let label = lazy.cal.itip.getCompleteText(aStatus, aOperationType); + aUpdateFunction({ label }); + + if (!Components.isSuccessCode(aStatus)) { + lazy.cal.showError(label); + return; + } + + if (Services.prefs.getBoolPref("calendar.itip.newInvitationDisplay")) { + aWindow.dispatchEvent( + new CustomEvent("onItipItemActionFinished", { detail: aItipItem }) + ); + } + }, + onGetResult(calendar, status, itemType, detail, items) {}, + }; + + try { + aActionFunc(opListener, aParticipantStatus, aExtResponse); + } catch (exc) { + console.error(exc); + } + return true; + } + return false; + } + + if (aParticipantStatus == null) { + aParticipantStatus = ""; + } + if (aParticipantStatus == "X-SHOWDETAILS" || aParticipantStatus == "X-RESCHEDULE") { + let counterProposal; + if (aFoundItems?.length) { + let item = aFoundItems[0].isMutable ? aFoundItems[0] : aFoundItems[0].clone(); + + if (aParticipantStatus == "X-RESCHEDULE") { + // TODO most of the following should be moved to the actionFunc defined in + // calItipUtils + let proposedItem = aItipItem.getItemList()[0]; + let proposedRID = proposedItem.getProperty("RECURRENCE-ID"); + if (proposedRID) { + // if this is a counterproposal for a specific occurrence, we use + // that to compare with + item = item.recurrenceInfo.getOccurrenceFor(proposedRID).clone(); + } + let parsedProposal = lazy.cal.invitation.parseCounter(proposedItem, item); + let potentialProposers = lazy.cal.itip.getAttendeesBySender( + proposedItem.getAttendees(), + aItipItem.sender + ); + let proposingAttendee = potentialProposers.length == 1 ? potentialProposers[0] : null; + if ( + proposingAttendee && + ["OK", "OUTDATED", "NOTLATESTUPDATE"].includes(parsedProposal.result.type) + ) { + counterProposal = { + attendee: proposingAttendee, + proposal: parsedProposal.differences, + oldVersion: + parsedProposal.result == "OLDVERSION" || parsedProposal.result == "NOTLATESTUPDATE", + onReschedule: () => { + aUpdateFunction({ + label: lazy.cal.l10n.getLtnString("imipBarCounterPreviousVersionText"), + }); + // TODO: should we hide the buttons in this case, too? + }, + }; + } else { + aUpdateFunction({ + label: lazy.cal.l10n.getLtnString("imipBarCounterErrorText"), + resetButtons: true, + }); + if (proposingAttendee) { + lazy.cal.LOG(parsedProposal.result.descr); + } else { + lazy.cal.LOG("Failed to identify the sending attendee of the counterproposal."); + } + + return false; + } + } + // if this a rescheduling operation, we suppress the occurrence + // prompt here + aWindow.modifyEventWithDialog( + item, + aParticipantStatus != "X-RESCHEDULE", + null, + counterProposal + ); + } + } else { + let response; + if (aResponse) { + if (aResponse == "AUTO" || aResponse == "NONE" || aResponse == "USER") { + response = { responseMode: Ci.calIItipItem[aResponse] }; + } + // Open an extended response dialog to enable the user to add a comment, make a + // counterproposal, delegate the event or interact in another way. + // Instead of a dialog, this might be implemented as a separate container inside the + // imip-overlay as proposed in bug 458578 + } + let delmgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService( + Ci.calIDeletedItems + ); + let items = aItipItem.getItemList(); + if (items && items.length) { + let delTime = delmgr.getDeletedDate(items[0].id); + let dialogText = lazy.cal.l10n.getLtnString("confirmProcessInvitation"); + let dialogTitle = lazy.cal.l10n.getLtnString("confirmProcessInvitationTitle"); + if (delTime && !Services.prompt.confirm(aWindow, dialogTitle, dialogText)) { + return false; + } + } + + if (aParticipantStatus == "X-SAVECOPY") { + // we create and adopt copies of the respective events + let saveitems = aItipItem + .getItemList() + .map(lazy.cal.itip.getPublishLikeItemCopy.bind(lazy.cal)); + if (saveitems.length > 0) { + let methods = { receivedMethod: "PUBLISH", responseMethod: "PUBLISH" }; + let newItipItem = lazy.cal.itip.getModifiedItipItem(aItipItem, saveitems, methods); + // setup callback and trigger re-processing + let storeCopy = function (aItipItem, aRc, aActionFunc, aFoundItems) { + if (isFirstProcessing && aActionFunc && Components.isSuccessCode(aRc)) { + _execAction(aActionFunc, aItipItem, aWindow, aParticipantStatus); + } + }; + lazy.cal.itip.processItipItem(newItipItem, storeCopy); + } + // we stop here to not process the original item + return false; + } + return _execAction(aActionFunc, aItipItem, aWindow, aParticipantStatus, response); + } + return false; + }, + + /** * Scope: iTIP message receiver * * Prompt for the target calendar, if needed for the given method. This calendar will be set on diff -Nru thunderbird-115.4.2+build1/comm/calendar/base/modules/utils/calWindowUtils.jsm thunderbird-115.4.3+build1/comm/calendar/base/modules/utils/calWindowUtils.jsm --- thunderbird-115.4.2+build1/comm/calendar/base/modules/utils/calWindowUtils.jsm 2023-11-08 07:24:33.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/calendar/base/modules/utils/calWindowUtils.jsm 2023-11-14 08:20:47.000000000 +0000 @@ -71,6 +71,41 @@ }, /** + * Open (or focus if already open) the calendar tab, even if the imip bar is + * in a message window, and even if there is no main three pane Thunderbird + * window open. Called when clicking the imip bar's calendar button. + */ + goToCalendar() { + let openCal = mainWindow => { + mainWindow.focus(); + mainWindow.document.getElementById("tabmail").openTab("calendar"); + }; + + let mainWindow = Services.wm.getMostRecentWindow("mail:3pane"); + + if (mainWindow) { + openCal(mainWindow); + } else { + mainWindow = Services.ww.openWindow( + null, + "chrome://messenger/content/messenger.xhtml", + "_blank", + "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar", + null + ); + + // Wait until calendar is set up in the new window. + let calStartupObserver = { + observe(subject, topic, data) { + openCal(mainWindow); + Services.obs.removeObserver(calStartupObserver, "calendar-startup-done"); + }, + }; + Services.obs.addObserver(calStartupObserver, "calendar-startup-done"); + } + }, + + /** * Brings up a dialog prompting the user about the deletion of the passed * item(s). * diff -Nru thunderbird-115.4.2+build1/comm/calendar/itip/CalItipMessageSender.jsm thunderbird-115.4.3+build1/comm/calendar/itip/CalItipMessageSender.jsm --- thunderbird-115.4.2+build1/comm/calendar/itip/CalItipMessageSender.jsm 2023-11-08 07:24:33.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/calendar/itip/CalItipMessageSender.jsm 2023-11-14 08:20:47.000000000 +0000 @@ -250,8 +250,9 @@ } if (opType == Ci.calIOperationListener.DELETE) { + let attendees = this.#filterOwnerFromAttendees(item.getAttendees(), item.calendar); this.pendingMessages.push( - new CalItipOutgoingMessage("CANCEL", item.getAttendees(), item, null, autoResponse) + new CalItipOutgoingMessage("CANCEL", attendees, item, null, autoResponse) ); return this.pendingMessageCount; } // else ADD, MODIFY: @@ -336,10 +337,7 @@ // Since this is a REQUEST, it is being sent from the event creator to // attendees. We do not need to send a message to the creator, even // though they may also be an attendee. - const calendarEmail = cal.provider.getEmailIdentityOfCalendar(item.calendar)?.email; - recipients = recipients.filter( - attendee => cal.email.removeMailTo(attendee.id) != calendarEmail - ); + recipients = this.#filterOwnerFromAttendees(recipients, item.calendar); if (recipients.length > 0) { this.pendingMessages.push( @@ -356,6 +354,7 @@ for (let att of canceledAttendees) { cancelItem.addAttendee(att); } + canceledAttendees = this.#filterOwnerFromAttendees(canceledAttendees, cancelItem.calendar); this.pendingMessages.push( new CalItipOutgoingMessage("CANCEL", canceledAttendees, cancelItem, null, autoResponse) ); @@ -375,6 +374,19 @@ send(transport) { return this.pendingMessages.every(msg => msg.send(transport)); } + + /** + * Filter out calendar owner from a list of event attendees to prevent the + * owner from receiving messages about changes they have made. + * + * @param {calIAttendee[]} attendees - The attendees. + * @param {calICalendar} calendar - The calendar the event belongs to. + * @returns {calIAttendee[]} the attendees with calendar owner removed. + */ + #filterOwnerFromAttendees(attendees, calendar) { + const calendarEmail = cal.provider.getEmailIdentityOfCalendar(calendar)?.email; + return attendees.filter(attendee => cal.email.removeMailTo(attendee.id) != calendarEmail); + } } /** diff -Nru thunderbird-115.4.2+build1/comm/calendar/test/unit/test_itip_message_sender.js thunderbird-115.4.3+build1/comm/calendar/test/unit/test_itip_message_sender.js --- thunderbird-115.4.2+build1/comm/calendar/test/unit/test_itip_message_sender.js 2023-11-08 07:24:33.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/calendar/test/unit/test_itip_message_sender.js 2023-11-14 08:20:47.000000000 +0000 @@ -143,6 +143,29 @@ ); await calendar.deleteItem(modifiedItem); + + // Now also cancel the event. No mail should be sent to self. + const targetItem2 = modifiedItem.clone(); + + targetItem2.setProperty("STATUS", "CANCELLED"); + targetItem2.setProperty("SEQUENCE", "2"); + const modifiedItem2 = await calendar.addItem(targetItem2); + const sender2 = new CalItipMessageSender(modifiedItem2, null); + + const result2 = sender2.buildOutgoingMessages(Ci.calIOperationListener.MODIFY, modifiedItem2); + Assert.equal(result2, 1, "return value should indicate there are pending messages"); + Assert.equal(sender2.pendingMessageCount, 1, "there should be one pending message"); + + const [msg2] = sender2.pendingMessages; + Assert.equal(msg2.method, "CANCEL", "deletion message method should be 'CANCEL'"); + Assert.equal(msg2.recipients.length, 1, "deletion message should have one recipient"); + + const [recipient2] = msg2.recipients; + Assert.equal( + recipient2.id, + cal.email.prependMailTo(newAttendeeEmail), + "for deletion message, recipient should be the non-organizer attendee" + ); }); add_task(async function testAddAdditionalAttendee() { diff -Nru thunderbird-115.4.2+build1/comm/mail/base/content/about3Pane.js thunderbird-115.4.3+build1/comm/mail/base/content/about3Pane.js --- thunderbird-115.4.2+build1/comm/mail/base/content/about3Pane.js 2023-11-08 07:24:34.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/mail/base/content/about3Pane.js 2023-11-14 08:20:47.000000000 +0000 @@ -2538,7 +2538,6 @@ !gViewWrapper?.showThreaded && !gViewWrapper?.showGroupedBySort ); threadPane.restoreSortIndicator(); - threadPane.restoreSelection(); threadPaneHeader.onFolderSelected(); } @@ -4290,6 +4289,22 @@ */ scrollToNewMessage: false, + /** + * Set to true when a scrolling event (presumably by the user) is detected + * while messages are still loading in a newly created view. + * + * @type {boolean} + */ + scrollDetected: false, + + /** + * The first detected scrolling event is triggered by creating the view + * itself. This property is then set to false. + * + * @type {boolean} + */ + isFirstScroll: true, + columns: getDefaultColumns(gFolder), cardColumns: getDefaultColumnsForCardsView(gFolder), @@ -4332,7 +4347,7 @@ window.addEventListener("uidensitychange", () => { this.densityChange(); - threadTree.invalidate(); + threadTree.reset(); }); this.densityChange(); @@ -4406,6 +4421,7 @@ threadTree.addEventListener("drop", this); threadTree.addEventListener("expanded", this); threadTree.addEventListener("collapsed", this); + threadTree.addEventListener("scroll", this); }, uninit() { @@ -4450,6 +4466,13 @@ threadTree.dispatchEvent(new CustomEvent("select")); } break; + case "scroll": + if (this.isFirstScroll) { + this.isFirstScroll = false; + break; + } + this.scrollDetected = true; + break; } }, observe(subject, topic, data) { @@ -4491,7 +4514,7 @@ */ releaseSelection() { threadTree._selection.selectEventsSuppressed = true; - this.restoreSelection(undefined, false); + this.restoreSelection({ notify: false }); threadTree._selection.selectEventsSuppressed = false; }, @@ -4841,7 +4864,12 @@ }, invalidate() { if (!this._inBatch) { - threadTree.invalidate(); + threadTree.reset(); + if (threadPane) { + threadPane.isFirstScroll = true; + threadPane.scrollDetected = false; + threadPane.scrollToLatestRowIfNoSelection(); + } } }, invalidateRange(startIndex, endIndex) { @@ -4927,10 +4955,17 @@ * Store the current thread tree selection. */ saveSelection() { - if (gFolder && gDBView) { + // Identifying messages by key doesn't reliably work on on cross-folder views since + // the msgKey may not be unique. + if (gFolder && gDBView && !gViewWrapper?.isMultiFolder) { this._savedSelections.set(gFolder.URI, { currentKey: gDBView.getKeyAt(threadTree.currentIndex), - selectedKeys: threadTree.selectedIndices.map(gDBView.getKeyAt), + // In views which are "grouped by sort", getting the key for collapsed dummy rows + // returns the key of the first group member, so we would restore something that + // wasn't selected. So filter them out. + selectedKeys: threadTree.selectedIndices + .filter(i => !gViewWrapper.isGroupedByHeaderAtIndex(i)) + .map(gDBView.getKeyAt), }); } }, @@ -4948,12 +4983,15 @@ /** * Restore the previously saved thread tree selection. * - * @param {boolean} [discard=true] - If false, the selection data should be - * kept after restoring the selection, otherwise it is forgotten. + * @param {boolean} [discard=true] - If false, the selection data is kept for + * another call of this function, unless all selections could already be + * restored in this run. * @param {boolean} [notify=true] - Whether a change in "select" event * should be fired. + * @param {boolean} [expand=true] - Try to expand threads containing selected + * messages. */ - restoreSelection(discard = true, notify = true) { + restoreSelection({ discard = true, notify = true, expand = true } = {}) { if (!this._savedSelections.has(gFolder?.URI) || !threadTree.view) { return; } @@ -4962,21 +5000,35 @@ let currentIndex = nsMsgViewIndex_None; let indices = new Set(); for (let key of selectedKeys) { - let index = gDBView.findIndexFromKey(key, false); - if (index != nsMsgViewIndex_None) { + let index = gDBView.findIndexFromKey(key, expand); + // While the first message in a collapsed group returns the index of the + // dummy row, other messages return none. To be consistent, we don't + // select the dummy row in any case. + if ( + index != nsMsgViewIndex_None && + !gViewWrapper.isGroupedByHeaderAtIndex(index) + ) { indices.add(index); if (key == currentKey) { currentIndex = index; } continue; } - + // Since it does not seem to be possible to reliably find the dummy row + // for a message in a group, we continue. + if (gViewWrapper.showGroupedBySort) { + continue; + } // The message for this key can't be found. Perhaps the thread it's in // has been collapsed? Select the root message in that case. try { - let msgHdr = gFolder.GetMessageHeader(key); - let thread = gDBView.getThreadContainingMsgHdr(msgHdr); - let rootMsgHdr = thread.getRootHdr(); + const folder = + gViewWrapper.isVirtual && gViewWrapper.isSingleFolder + ? gViewWrapper._underlyingFolders[0] + : gFolder; + const msgHdr = folder.GetMessageHeader(key); + const thread = gDBView.getThreadContainingMsgHdr(msgHdr); + const rootMsgHdr = thread.getRootHdr(); index = gDBView.findIndexOfMsgHdr(rootMsgHdr, false); if (index != nsMsgViewIndex_None) { indices.add(index); @@ -4991,17 +5043,54 @@ threadTree.setSelectedIndices(indices.values(), !notify); if (currentIndex != nsMsgViewIndex_None) { - // Do an instant scroll before setting the index to avoid animation. - threadTree.scrollToIndex(currentIndex, true); + threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll. threadTree.currentIndex = currentIndex; + threadTree.style.scrollBehavior = null; } - if (discard) { + // If all selections have already been restored, discard them as well. + if (discard || gDBView.selection.count == selectedKeys.length) { this._savedSelections.delete(gFolder.URI); } }, /** + * Scroll to the most relevant end of the tree, but only if no rows are + * selected. + */ + scrollToLatestRowIfNoSelection() { + if (!gDBView || gDBView.selection.count > 0 || gDBView.rowCount <= 0) { + return; + } + if ( + gViewWrapper.sortImpliesTemporalOrdering && + gViewWrapper.isSortedAscending + ) { + threadTree.scrollToIndex(gDBView.rowCount - 1, true); + } else { + threadTree.scrollToIndex(0, true); + } + }, + + /** + * Re-collapse threads expanded by nsMsgQuickSearchDBView if necessary. + */ + ensureThreadStateForQuickSearchView() { + // nsMsgQuickSearchDBView::SortThreads leaves all threads expanded in any + // case. + if ( + gViewWrapper.isSingleFolder && + gViewWrapper.search.hasSearchTerms && + gViewWrapper.showThreaded && + !gViewWrapper._threadExpandAll + ) { + window.threadPane.saveSelection(); + gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll); + window.threadPane.restoreSelection(); + } + }, + + /** * Restore the collapsed or expanded state of threads. */ restoreThreadState() { @@ -5130,7 +5219,7 @@ this.cardColumns = getDefaultColumnsForCardsView(gFolder); this.updateClassList(); this.updateColumns(); - threadTree.invalidate(); + threadTree.reset(); this.persistColumnStates(); }, @@ -5171,7 +5260,7 @@ this.persistColumnStates(); this.updateColumns(true); - threadTree.invalidate(); + threadTree.reset(); // Swap the DOM elements. const originalTH = document.getElementById(column); @@ -5189,7 +5278,7 @@ this.persistColumnStates(); this.updateColumns(true); - threadTree.invalidate(); + threadTree.reset(); }, /** @@ -5206,7 +5295,7 @@ this.persistColumnStates(); this.updateColumns(true); - threadTree.invalidate(); + threadTree.reset(); }, /** @@ -5523,7 +5612,7 @@ }); // Invalidation should be unnecessary but the back end doesn't // notify us properly and resists attempts to fix this. - threadTree.invalidate(); + threadTree.reset(); threadTree.table.body.focus(); return false; // Close notification. }, @@ -6036,17 +6125,7 @@ let ariaLabelPromises = []; const propertiesSet = new Set(properties.value.split(" ")); - - if (propertiesSet.has("dummy")) { - const cell = this.querySelector(".subjectcol-column"); - const textIndex = textColumns.indexOf("subjectCol"); - const label = cellTexts[textIndex]; - const span = cell.querySelector(".subject-line span"); - cell.title = span.textContent = label; - this.setAttribute("aria-label", label); - this.dataset.properties = "dummy"; - return; - } + const isDummyRow = propertiesSet.has("dummy"); this.dataset.properties = properties.value.trim(); @@ -6064,11 +6143,14 @@ const div = cell.querySelector(".subject-line"); // Indent child message of this thread. - div.style.setProperty("--thread-level", threadLevel.value); + div.style.setProperty( + "--thread-level", + gViewWrapper.showGroupedBySort ? 0 : threadLevel.value + ); let imageFluentID = this.#getMessageIndicatorString(propertiesSet); const image = div.querySelector("img"); - if (imageFluentID) { + if (imageFluentID && !isDummyRow) { document.l10n.setAttributes(image, imageFluentID); } else { image.removeAttribute("data-l10n-id"); @@ -6156,6 +6238,10 @@ } if (textIndex >= 0) { + if (isDummyRow) { + cell.textContent = ""; + continue; + } cell.textContent = cellTexts[textIndex]; ariaLabelPromises.push(cellTexts[textIndex]); } @@ -6596,6 +6682,8 @@ } // Restore Grouped By selection post sort direction change. threadPane.restoreSelection(); + // Refresh dummy rows in case of collapseAll. + threadTree.invalidate(); } threadPane.restoreThreadState(); }, @@ -6630,6 +6718,7 @@ threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll. gViewWrapper.sortAscending(); + threadPane.ensureThreadStateForQuickSearchView(); threadTree.style.scrollBehavior = null; }, sortDescending() { @@ -6642,6 +6731,7 @@ threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll. gViewWrapper.sortDescending(); + threadPane.ensureThreadStateForQuickSearchView(); threadTree.style.scrollBehavior = null; }, convertSortTypeToColumnID(sortKey) { @@ -6746,6 +6836,7 @@ () => { threadPane.saveSelection(); gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.expandAll); + gViewWrapper._threadExpandAll = true; threadPane.restoreSelection(); }, () => !!gViewWrapper?.dbView @@ -6755,7 +6846,8 @@ () => { threadPane.saveSelection(); gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll); - threadPane.restoreSelection(); + gViewWrapper._threadExpandAll = false; + threadPane.restoreSelection({ expand: false }); }, () => !!gViewWrapper?.dbView ); @@ -6924,7 +7016,7 @@ commandController._navigate(Ci.nsMsgNavigationType.toggleThreadKilled); // Invalidation should be unnecessary but the back end doesn't notify us // properly and resists attempts to fix this. - threadTree.invalidate(); + threadTree.reset(); }, () => gDBView?.numSelected >= 1 && (gFolder || gViewWrapper.isSynthetic) ); @@ -6941,7 +7033,7 @@ commandController._navigate(Ci.nsMsgNavigationType.toggleSubthreadKilled); // Invalidation should be unnecessary but the back end doesn't notify us // properly and resists attempts to fix this. - threadTree.invalidate(); + threadTree.reset(); }, () => gDBView?.numSelected >= 1 && (gFolder || gViewWrapper.isSynthetic) ); diff -Nru thunderbird-115.4.2+build1/comm/mail/base/content/mailCommon.js thunderbird-115.4.3+build1/comm/mail/base/content/mailCommon.js --- thunderbird-115.4.2+build1/comm/mail/base/content/mailCommon.js 2023-11-08 07:24:33.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/mail/base/content/mailCommon.js 2023-11-14 08:20:47.000000000 +0000 @@ -502,9 +502,14 @@ } return false; case "cmd_openConversation": - return gDBView - .getSelectedMsgHdrs() - .some(m => ConversationOpener.isMessageIndexed(m)); + return ( + // This (instead of numSelectedMessages) is necessary to be able to + // also open a collapsed thread in conversation. + gDBView.selection.count == 1 && + ConversationOpener.isMessageIndexed( + gDBView.hdrForFirstSelectedMessage + ) + ); case "cmd_replylist": if (hasIdentities && numSelectedMessages == 1) { const aboutMessage = @@ -512,17 +517,13 @@ return aboutMessage?.currentHeaderData?.["list-post"]; } return false; - case "cmd_forward": - if (!hasIdentities) { - return false; - } - // Falls through. case "cmd_viewPageSource": case "cmd_saveAsTemplate": return numSelectedMessages == 1; case "cmd_reply": case "cmd_replySender": case "cmd_replyall": + case "cmd_forward": case "cmd_forwardInline": case "cmd_forwardAttachment": case "cmd_redirect": @@ -817,28 +818,48 @@ let resultKey = { value: nsMsgKey_None }; let resultIndex = { value: nsMsgViewIndex_None }; let threadIndex = {}; - gViewWrapper.dbView.viewNavigate( - navigationType, - resultKey, - resultIndex, - threadIndex, - true - ); - if (resultIndex.value == nsMsgViewIndex_None) { - if (CrossFolderNavigation(navigationType)) { - this._navigate(navigationType); - } - return; - } - if (resultKey.value == nsMsgKey_None) { - return; + let expandCurrentThread = false; + let currentIndex = window.threadTree ? window.threadTree.currentIndex : -1; + + // If we're doing next unread, and a collapsed thread is selected, and + // the top level message is unread, just set the result manually to + // the top level message, without using viewNavigate. + if ( + navigationType == Ci.nsMsgNavigationType.nextUnreadMessage && + currentIndex != -1 && + gViewWrapper.isCollapsedThreadAtIndex(currentIndex) && + !( + gViewWrapper.dbView.getFlagsAt(currentIndex) & Ci.nsMsgMessageFlags.Read + ) + ) { + expandCurrentThread = true; + resultIndex.value = currentIndex; + resultKey.value = gViewWrapper.dbView.getKeyAt(currentIndex); + } else { + gViewWrapper.dbView.viewNavigate( + navigationType, + resultKey, + resultIndex, + threadIndex, + true + ); + if (resultIndex.value == nsMsgViewIndex_None) { + if (CrossFolderNavigation(navigationType)) { + this._navigate(navigationType); + } + return; + } + if (resultKey.value == nsMsgKey_None) { + return; + } } if (window.threadTree) { if ( gDBView.selection.count == 1 && - window.threadTree.selectedIndex == resultIndex.value + window.threadTree.selectedIndex == resultIndex.value && + !expandCurrentThread ) { return; } @@ -925,15 +946,10 @@ onCreatedView() { if (window.threadTree) { window.threadPane.setTreeView(gViewWrapper.dbView); - - if ( - gViewWrapper.sortImpliesTemporalOrdering && - gViewWrapper.isSortedAscending - ) { - window.threadTree.scrollToIndex(gDBView.rowCount - 1, true); - } else { - window.threadTree.scrollToIndex(0, true); - } + window.threadPane.restoreThreadState(); + window.threadPane.isFirstScroll = true; + window.threadPane.scrollDetected = false; + window.threadPane.scrollToLatestRowIfNoSelection(); } }, onDestroyingView(folderIsComingBack) { @@ -958,23 +974,33 @@ onDisplayingFolder() {}, onLeavingFolder() {}, onMessagesLoaded(all) { + if (!window.threadPane) { + return; + } // Try to restore what was selected. Keep the saved selection (if there is - // one) until we have all of the messages. - window.threadPane?.restoreSelection(all); - - if (all) { - if (window.threadPane?.scrollToNewMessage) { + // one) until we have all of the messages. This will also reveal selected + // messages in collapsed threads. + window.threadPane.restoreSelection({ discard: all }); + + if (all || gViewWrapper.search.hasSearchTerms) { + window.threadPane.ensureThreadStateForQuickSearchView(); + let newMessageFound = false; + if (window.threadPane.scrollToNewMessage) { try { let index = gDBView.findIndexOfMsgHdr(gFolder.firstNewMessage, true); if (index != nsMsgViewIndex_None) { window.threadTree.scrollToIndex(index, true); + newMessageFound = true; } } catch (ex) { console.error(ex); } window.threadPane.scrollToNewMessage = false; } - window.threadTree?.invalidate(); + window.threadTree.reset(); + if (!newMessageFound && !window.threadPane.scrollDetected) { + window.threadPane.scrollToLatestRowIfNoSelection(); + } } window.quickFilterBar?.onMessagesChanged(); }, @@ -982,17 +1008,8 @@ window.dispatchEvent(new CustomEvent("MailViewChanged")); }, onSortChanged() { - if (window.threadTree && gDBView.selection.count == 0) { - // If there is no selection, scroll to the most relevant end. - if ( - gViewWrapper.sortImpliesTemporalOrdering && - gViewWrapper.isSortedAscending - ) { - window.threadTree.scrollToIndex(gDBView.rowCount - 1, true); - } else { - window.threadTree.scrollToIndex(0, true); - } - } + // If there is no selection, scroll to the most relevant end. + window.threadPane?.scrollToLatestRowIfNoSelection(); }, onMessagesRemoved() { window.quickFilterBar?.onMessagesChanged(); diff -Nru thunderbird-115.4.2+build1/comm/mail/base/content/mailContext.js thunderbird-115.4.3+build1/comm/mail/base/content/mailContext.js --- thunderbird-115.4.2+build1/comm/mail/base/content/mailContext.js 2023-11-08 07:24:33.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/mail/base/content/mailContext.js 2023-11-14 08:20:47.000000000 +0000 @@ -183,7 +183,7 @@ } if (this._selectionIsOverridden) { window.threadTree._selection.selectEventsSuppressed = true; - window.threadPane.restoreSelection(undefined, false); + window.threadPane.restoreSelection({ notify: false }); this._selectionIsOverridden = false; window.threadTree.invalidate(); } @@ -360,6 +360,7 @@ "mailContext-openContainingFolder", (!isDummyMessage && !inAbout3Pane) || gViewWrapper.isSynthetic ); + setSingleSelection("mailContext-forward", !onSpecialItem); setSingleSelection("mailContext-forwardAsMenu", !onSpecialItem); showItem( "mailContext-multiForwardAsAttachment", diff -Nru thunderbird-115.4.2+build1/comm/mail/base/content/widgets/tree-view.mjs thunderbird-115.4.3+build1/comm/mail/base/content/widgets/tree-view.mjs --- thunderbird-115.4.2+build1/comm/mail/base/content/widgets/tree-view.mjs 2023-11-08 07:24:33.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/mail/base/content/widgets/tree-view.mjs 2023-11-14 08:20:47.000000000 +0000 @@ -228,7 +228,7 @@ this.#calculateToleranceBufferSize(); if (this._view) { - this.invalidate(); + this.reset(); } } @@ -533,7 +533,7 @@ // Clear the height of the top spacer to avoid confusing // `_ensureVisibleRowsAreDisplayed`. this.table.spacerTop.setHeight(0); - this.invalidate(); + this.reset(); this.dispatchEvent(new CustomEvent("viewchange")); } @@ -578,12 +578,21 @@ /** * Clear all rows from the list and create them again. */ - invalidate() { + reset() { this.#resetRowBuffer(); this._ensureVisibleRowsAreDisplayed(); } /** + * Updates all existing rows in place, without removing all the rows and + * starting again. This can be used if the row element class hasn't changed + * and its `index` setter is capable of handling any modifications required. + */ + invalidate() { + this.invalidateRange(this.#firstBufferRowIndex, this.#lastBufferRowIndex); + } + + /** * Perform the actions necessary to invalidate the specified row. Implemented * separately to allow {@link invalidateRange} to handle testing event fires * on its own. @@ -591,18 +600,18 @@ * @param {integer} index */ #doInvalidateRow(index) { + const rowCount = this._view?.rowCount ?? 0; let row = this.getRowAtIndex(index); if (row) { - if (index >= this._view.rowCount) { - row.remove(); - this._rows.delete(index); + if (index >= rowCount) { + this._removeRowAtIndex(index); } else { row.index = index; row.selected = this._selection.isSelected(index); } } else if ( index >= this.#firstBufferRowIndex && - index <= this.#lastBufferRowIndex + index <= Math.min(rowCount - 1, this.#lastBufferRowIndex) ) { this._addRowAtIndex(index); } @@ -670,9 +679,12 @@ } #createToleranceFillCallback() { - this.#bufferFillIdleCallbackHandle = requestIdleCallback(deadline => - this.#fillToleranceBuffer(deadline) - ); + // Don't schedule a new buffer fill callback if we already have one. + if (!this.#bufferFillIdleCallbackHandle) { + this.#bufferFillIdleCallbackHandle = requestIdleCallback(deadline => + this.#fillToleranceBuffer(deadline) + ); + } } #cancelToleranceFillCallback() { @@ -884,6 +896,8 @@ * request filling of the tolerance buffer when idle. */ _ensureVisibleRowsAreDisplayed() { + this.#cancelToleranceFillCallback(); + let rowCount = this._view?.rowCount ?? 0; this.placeholder?.classList.toggle("show", !rowCount); @@ -950,8 +964,7 @@ const pruneBeforeRow = this.getRowAtIndex(ranges.pruneBefore); let rowToPrune = pruneBeforeRow.previousElementSibling; while (rowToPrune) { - rowToPrune.remove(); - this._rows.delete(rowToPrune.index); + this._removeRowAtIndex(rowToPrune.index); rowToPrune = pruneBeforeRow.previousElementSibling; } } @@ -960,8 +973,7 @@ const pruneAfterRow = this.getRowAtIndex(ranges.pruneAfter); let rowToPrune = pruneAfterRow.nextElementSibling; while (rowToPrune) { - rowToPrune.remove(); - this._rows.delete(rowToPrune.index); + this._removeRowAtIndex(rowToPrune.index); rowToPrune = pruneAfterRow.nextElementSibling; } } @@ -991,10 +1003,7 @@ // we may throw them away very quickly. To save the expense, only fill the // buffer while idle. - // Don't schedule a new buffer fill callback if we already have one. - if (!this.#bufferFillIdleCallbackHandle) { - this.#createToleranceFillCallback(); - } + this.#createToleranceFillCallback(); } /** @@ -1118,6 +1127,17 @@ } /** + * Removes the row element at `index` from the DOM and map of rows. + * + * @param {integer} index + */ + _removeRowAtIndex(index) { + const row = this._rows.get(index); + row?.remove(); + this._rows.delete(index); + } + + /** * Returns the row element at `index` or null if `index` is out of range. * * @param {integer} index @@ -1270,7 +1290,7 @@ _selectSingle(index, delaySelect = false) { let changeSelection = this._selection.count != 1 || !this._selection.isSelected(index); - // Update the TreeSelection selection to trigger a tree invalidate(). + // Update the TreeSelection selection to trigger a tree reset(). if (changeSelection) { this._selection.select(index); } @@ -2465,6 +2485,9 @@ * fill layout based on values from the list's view. Always call back to * this class's getter/setter when inheriting. * + * @note Don't short-circuit the setter if the given index is equal to the + * existing index. Rows can be reused to display new data at the same index. + * * @type {integer} */ get index() { diff -Nru thunderbird-115.4.2+build1/comm/mail/base/test/browser/browser_mailContext.js thunderbird-115.4.3+build1/comm/mail/base/test/browser/browser_mailContext.js --- thunderbird-115.4.2+build1/comm/mail/base/test/browser/browser_mailContext.js 2023-11-08 07:24:33.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/mail/base/test/browser/browser_mailContext.js 2023-11-14 08:20:47.000000000 +0000 @@ -91,7 +91,11 @@ ], "mailContext-openNewTab": singleSelectionThreadPane, "mailContext-openNewWindow": singleSelectionThreadPane, - "mailContext-openConversation": notExternal, + "mailContext-openConversation": [ + ...singleSelectionMessagePane, + ...singleSelectionThreadPane, + ...onePane, + ], "mailContext-openContainingFolder": [ "syntheticFolderDraft", "syntheticFolderDraftTree", diff -Nru thunderbird-115.4.2+build1/comm/mail/base/test/browser/browser_messageMenu.js thunderbird-115.4.3+build1/comm/mail/base/test/browser/browser_messageMenu.js --- thunderbird-115.4.2+build1/comm/mail/base/test/browser/browser_messageMenu.js 2023-11-08 07:24:34.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/mail/base/test/browser/browser_messageMenu.js 2023-11-14 08:20:47.000000000 +0000 @@ -20,7 +20,7 @@ replySenderMainMenu: { hidden: true }, menu_replyToAll: { disabled: nothingSelected }, menu_replyToList: { disabled: true }, - menu_forwardMsg: { disabled: nothingOrMultiSelected }, + menu_forwardMsg: { disabled: nothingSelected }, forwardAsMenu: { disabled: nothingSelected }, menu_forwardAsInline: { disabled: nothingSelected }, menu_forwardAsAttachment: { disabled: nothingSelected }, @@ -33,7 +33,7 @@ disabled: [...nothingSelected, "message", "externalMessage"], }, openConversationMenuitem: { - disabled: [...nothingSelected, "externalMessage"], + disabled: [...nothingOrMultiSelected, "externalMessage"], }, openFeedMessage: { hidden: true }, menu_openFeedWebPage: { disabled: nothingSelected }, diff -Nru thunderbird-115.4.2+build1/comm/mail/base/test/browser/browser_navigation.js thunderbird-115.4.3+build1/comm/mail/base/test/browser/browser_navigation.js --- thunderbird-115.4.2+build1/comm/mail/base/test/browser/browser_navigation.js 2023-11-08 07:24:34.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/mail/base/test/browser/browser_navigation.js 2023-11-14 08:20:47.000000000 +0000 @@ -198,6 +198,15 @@ [folderCMessages[500], folderCMessages[501], folderCMessages[504]], false ); + folderD.markMessagesRead( + [ + folderDMessages[3], + folderDMessages[4], + folderDMessages[6], + folderDMessages[7], + ], + false + ); about3Pane.displayFolder(folderA.URI); threadTree.selectedIndex = -1; @@ -248,6 +257,43 @@ assertSelectedMessage(folderCMessages[504]); await assertDisplayedMessage(aboutMessage, folderCMessages[504]); + // Select the first message in folder D and make sure all threads are + // collapsed. + about3Pane.displayFolder(folderD.URI); + threadTree.selectedIndex = 0; + let selectPromise = BrowserTestUtils.waitForEvent(threadTree, "select"); + goDoCommand("cmd_collapseAllThreads"); + await selectPromise; + assertSelectedMessage(folderDMessages[0]); + + // Go to the next thread without expanding it. + EventUtils.synthesizeKey("KEY_ArrowDown"); + assertSelectedMessage(folderDMessages[3]); + + // The next displayed message should be the root message of the now expanded + // thread. + goDoCommand("cmd_nextUnreadMsg"); + assertSelectedMessage(folderDMessages[3]); + await assertDisplayedMessage(aboutMessage, folderDMessages[3]); + + // Select the next unread message in the thread. + goDoCommand("cmd_nextUnreadMsg"); + assertSelectedMessage(folderDMessages[4]); + await assertDisplayedMessage(aboutMessage, folderDMessages[4]); + + // Select the next unread message. + goDoCommand("cmd_nextUnreadMsg"); + assertSelectedMessage(folderDMessages[6]); + await assertDisplayedMessage(aboutMessage, folderDMessages[6]); + + // Select the next unread message. + goDoCommand("cmd_nextUnreadMsg"); + assertSelectedMessage(folderDMessages[7]); + await assertDisplayedMessage(aboutMessage, folderDMessages[7]); + + // Mark folder D read again. + folderD.markAllMessagesRead(null); + // Go back to the first folder. The previous selection should be restored. about3Pane.displayFolder(folderA.URI); assertSelectedMessage(folderAMessages[3]); diff -Nru thunderbird-115.4.2+build1/comm/mail/base/test/browser/browser_threadTreeDeleting.js thunderbird-115.4.3+build1/comm/mail/base/test/browser/browser_threadTreeDeleting.js --- thunderbird-115.4.2+build1/comm/mail/base/test/browser/browser_threadTreeDeleting.js 2023-11-08 07:24:34.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/mail/base/test/browser/browser_threadTreeDeleting.js 2023-11-14 08:20:47.000000000 +0000 @@ -228,6 +228,136 @@ await subtest(); }); +/** + * Tests that deleting the selected row while smooth-scrolling does not break + * the scrolling and leave the tree in a bad scroll position. + */ +add_task(async function testDeletionWhileScrolling() { + let folderI = rootFolder + .createLocalSubfolder("threadTreeDeletingI") + .QueryInterface(Ci.nsIMsgLocalMailFolder); + folderI.addMessageBatch( + generator + .makeMessages({ count: 500 }) + .map(message => message.toMboxString()) + ); + + await ensure_table_view(); + about3Pane.restoreState({ + messagePaneVisible: false, + folderURI: folderI.URI, + }); + + const scrollListener = { + async promiseScrollingStopped() { + this.lastTime = Date.now(); + await TestUtils.waitForCondition( + () => Date.now() - this.lastTime > 1000, + "waiting for scrolling to stop" + ); + delete this.direction; + delete this.lastPosition; + }, + setScrollExpectation(direction) { + this.direction = direction; + this.lastPosition = threadTree.scrollTop; + }, + setNoScrollExpectation() { + this.direction = 0; + }, + handleEvent(event) { + if (this.direction === 0) { + Assert.report(true, undefined, undefined, "unexpected scroll event"); + return; + } + + const position = threadTree.scrollTop; + if (this.direction == -1) { + Assert.lessOrEqual(position, this.lastPosition); + } else if (this.direction == 1) { + Assert.greaterOrEqual(position, this.lastPosition); + } + this.lastPosition = position; + this.lastTime = Date.now(); + }, + }; + + async function delayThenPress(millis, key) { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, millis)); + if (key) { + EventUtils.synthesizeKey(key, {}, about3Pane); + await TestUtils.waitForTick(); + } + } + + threadTree.addEventListener("scroll", scrollListener); + threadTree.table.body.focus(); + threadTree.selectedIndex = 299; + await scrollListener.promiseScrollingStopped(); + + let stopPromise = scrollListener.promiseScrollingStopped(); + scrollListener.setScrollExpectation(-1); + + // Page up a few times then delete some messages. + + await delayThenPress(0, "VK_PAGE_UP"); + await delayThenPress(60, "VK_PAGE_UP"); + await delayThenPress(60, "VK_PAGE_UP"); + await delayThenPress(400, "VK_DELETE"); + await delayThenPress(80, "VK_DELETE"); + + await stopPromise; + Assert.equal( + threadTree.getFirstVisibleIndex(), + threadTree.selectedIndex, + "selected row should be the first visible row" + ); + + // Page down a few times then delete some messages. + + stopPromise = scrollListener.promiseScrollingStopped(); + scrollListener.setScrollExpectation(1); + + await delayThenPress(60, "VK_PAGE_DOWN"); + await delayThenPress(60, "VK_PAGE_DOWN"); + await delayThenPress(60, "VK_PAGE_DOWN"); + await delayThenPress(300, "VK_DELETE"); + await delayThenPress(80, "VK_DELETE"); + await delayThenPress(80, "VK_DELETE"); + await delayThenPress(80, "VK_DELETE"); + await delayThenPress(80, "VK_DELETE"); + + await stopPromise; + Assert.equal( + threadTree.getLastVisibleIndex(), + threadTree.selectedIndex, + "selected row should be the last visible row" + ); + + // Select a message somewhere in the middle then delete it. + + scrollListener.setNoScrollExpectation(); + threadTree.selectedIndex -= 10; + await delayThenPress(80, "VK_DELETE"); + await delayThenPress(80, "VK_DELETE"); + await delayThenPress(80, "VK_DELETE"); + + await delayThenPress(1000); + Assert.less( + threadTree.getFirstVisibleIndex(), + threadTree.selectedIndex, + "selected row should be below the first visible row" + ); + Assert.greater( + threadTree.getLastVisibleIndex(), + threadTree.selectedIndex, + "selected row should be above the last visible row" + ); + + threadTree.removeEventListener("scroll", scrollListener); +}); + async function subtest() { await TestUtils.waitForCondition( () => threadTree.table.body.rows.length == 15, diff -Nru thunderbird-115.4.2+build1/comm/mail/base/test/browser/browser_treeView.js thunderbird-115.4.3+build1/comm/mail/base/test/browser/browser_treeView.js --- thunderbird-115.4.2+build1/comm/mail/base/test/browser/browser_treeView.js 2023-11-08 07:24:34.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/mail/base/test/browser/browser_treeView.js 2023-11-14 08:20:47.000000000 +0000 @@ -151,16 +151,20 @@ seenEvent: null, currentAtEvent: null, selectedAtEvent: null, + t0: Date.now(), + time: 0, reset() { this.seenEvent = null; this.currentAtEvent = null; this.selectedAtEvent = null; + this.t0 = Date.now(); }, handleEvent(event) { this.seenEvent = event; this.currentAtEvent = list.currentIndex; this.selectedAtEvent = list.selectedIndices; + this.time = Date.now() - this.t0; }, }; @@ -331,19 +335,19 @@ selectHandler.reset(); list.addEventListener("select", selectHandler, { once: true }); EventUtils.synthesizeKey(key, modifiers, content); - // We don't enforce any delay on multiselection. - if (!modifiers.shiftKey && !modifiers.accelKey) { - content.setTimeout(() => { - Assert.ok( - !selectHandler.seenEvent, - "'select' event didn't fire before the delay" - ); - }, 240); - } await TestUtils.waitForCondition( () => !!selectHandler.seenEvent == expectEvent, `'select' event should ${expectEvent ? "" : "not "}get fired` ); + // We don't enforce any delay on multiselection. + let multiselect = + (AppConstants.platform == "macosx" && key == " ") || + modifiers.shiftKey || + modifiers.accelKey; + if (expectEvent && !multiselect) { + // We have data-select-delay="250" in treeView.xhtml + Assert.greater(selectHandler.time, 240, "should select only after delay"); + } } await pressKey("VK_UP"); diff -Nru thunderbird-115.4.2+build1/comm/mail/base/test/browser/head.js thunderbird-115.4.3+build1/comm/mail/base/test/browser/head.js --- thunderbird-115.4.2+build1/comm/mail/base/test/browser/head.js 2023-11-08 07:24:34.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/mail/base/test/browser/head.js 2023-11-14 08:20:47.000000000 +0000 @@ -357,6 +357,7 @@ } resetSmartMailboxes(); + ensure_cards_view(); // Some tests that open new windows don't return focus to the main window // in a way that satisfies mochitest, and the test times out. diff -Nru thunderbird-115.4.2+build1/comm/mail/components/addrbook/content/aboutAddressBook.js thunderbird-115.4.3+build1/comm/mail/components/addrbook/content/aboutAddressBook.js --- thunderbird-115.4.2+build1/comm/mail/components/addrbook/content/aboutAddressBook.js 2023-11-08 07:24:34.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/mail/components/addrbook/content/aboutAddressBook.js 2023-11-14 08:20:47.000000000 +0000 @@ -1488,7 +1488,7 @@ tableRowClass.ROW_HEIGHT = 22; break; } - this.cardsList.invalidate(); + this.cardsList.reset(); }, searchInput: null, @@ -1656,7 +1656,7 @@ } this.table.updateColumns(cardsPane.COLUMNS); - this.cardsList.invalidate(); + this.cardsList.reset(); Services.xulStore.setValue( cardsPane.URL, diff -Nru thunderbird-115.4.2+build1/comm/mail/components/addrbook/content/abView-new.js thunderbird-115.4.3+build1/comm/mail/components/addrbook/content/abView-new.js --- thunderbird-115.4.2+build1/comm/mail/components/addrbook/content/abView-new.js 2023-11-08 07:24:34.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/mail/components/addrbook/content/abView-new.js 2023-11-14 08:20:47.000000000 +0000 @@ -136,7 +136,7 @@ // Restore what was selected. if (this._tree) { - this._tree.invalidate(); + this._tree.reset(); if (selectionExists) { for (let i = 0; i < this._rowMap.length; i++) { this._tree.toggleSelectionAtIndex( @@ -249,7 +249,7 @@ this._rowMap[i].wasCurrent = currentIndex == i; } - this._tree.invalidate(); + this._tree.reset(); for (let i = 0; i < this._rowMap.length; i++) { this._tree.toggleSelectionAtIndex( i, diff -Nru thunderbird-115.4.2+build1/comm/mail/config/version_display.txt thunderbird-115.4.3+build1/comm/mail/config/version_display.txt --- thunderbird-115.4.2+build1/comm/mail/config/version_display.txt 2023-11-08 07:24:38.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/mail/config/version_display.txt 2023-11-14 08:20:47.000000000 +0000 @@ -1 +1 @@ -115.4.2 +115.4.3 diff -Nru thunderbird-115.4.2+build1/comm/mail/config/version.txt thunderbird-115.4.3+build1/comm/mail/config/version.txt --- thunderbird-115.4.2+build1/comm/mail/config/version.txt 2023-11-08 07:24:38.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/mail/config/version.txt 2023-11-14 08:20:47.000000000 +0000 @@ -1 +1 @@ -115.4.2 +115.4.3 diff -Nru thunderbird-115.4.2+build1/comm/mail/modules/DBViewWrapper.jsm thunderbird-115.4.3+build1/comm/mail/modules/DBViewWrapper.jsm --- thunderbird-115.4.2+build1/comm/mail/modules/DBViewWrapper.jsm 2023-11-08 07:24:34.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/mail/modules/DBViewWrapper.jsm 2023-11-14 08:20:47.000000000 +0000 @@ -885,6 +885,7 @@ this._sort = [ [Ci.nsMsgViewSortType.byNone, Ci.nsMsgViewSortOrder.ascending], ]; + this.__viewFlags = Ci.nsMsgViewFlagsType.kNone; FolderNotificationHelper.noteCuriosity(this); this._applyViewChanges(); diff -Nru thunderbird-115.4.2+build1/comm/mailnews/base/content/msgAccountCentral.js thunderbird-115.4.3+build1/comm/mailnews/base/content/msgAccountCentral.js --- thunderbird-115.4.2+build1/comm/mailnews/base/content/msgAccountCentral.js 2023-11-08 07:24:34.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/mailnews/base/content/msgAccountCentral.js 2023-11-14 08:20:47.000000000 +0000 @@ -13,6 +13,8 @@ var gSelectedServer = null; var gSelectedFolder = null; +window.addEventListener("DOMContentLoaded", OnInit); + /** * Set up the whole page depending on the selected folder/account. * The folder is passed in via the document URL. @@ -82,19 +84,12 @@ // Is this an NNTP account? let isNNTPAccount = gSelectedServer?.type == "nntp"; - // It can read messages (does it have an Inbox)?. - let canGetMessages = false; - try { - canGetMessages = protocolInfo && protocolInfo.canGetMessages; - document - .getElementById("readButton") - .toggleAttribute( - "hidden", - !canGetMessages || isRssAccount || isNNTPAccount - ); - } catch (e) { - exceptions.push(e); - } + // Is this a Local Folders account? + const isLocalFoldersAccount = gSelectedServer?.type == "none"; + + document + .getElementById("readButton") + .toggleAttribute("hidden", !getReadMessagesFolder()); // It can compose messages. let showComposeMsgLink = false; @@ -142,7 +137,10 @@ // It can have End-to-end Encryption. document .getElementById("e2eButton") - .toggleAttribute("hidden", !canGetMessages || isRssAccount); + .toggleAttribute( + "hidden", + isNNTPAccount || isRssAccount || isLocalFoldersAccount + ); // Check if we collected any exception. while (exceptions.length) { @@ -153,19 +151,31 @@ } /** - * Open the Inbox for selected server. If needed, open the twisty and - * select the Inbox menuitem. + * For the selected server, check for new messges and display first + * suitable folder (genrally Inbox) for reading. */ function readMessages() { - if (!gSelectedServer) { - return; - } + const folder = getReadMessagesFolder(); + top.MsgGetMessage([folder]); + parent.displayFolder(folder); +} - try { - parent.displayFolder(MailUtils.getInboxFolder(gSelectedServer)); - } catch (ex) { - console.error("Error opening Inbox for server: " + ex + "\n"); - } +/** + * Find the folder Read Messages should use. + * + * @returns {?nsIMsgFolder} folder to use, if we have a suitable one. + */ +function getReadMessagesFolder() { + const folder = MailUtils.getInboxFolder(gSelectedServer); + if (folder) { + return folder; + } + // For feeds and nntp, show the first non-trash folder. Don't use Outbox. + return gSelectedServer.rootFolder.descendants.find( + f => + !(f.flags & Ci.nsMsgFolderFlags.Trash) && + !(f.flags & Ci.nsMsgFolderFlags.Queue) + ); } /** diff -Nru thunderbird-115.4.2+build1/comm/mailnews/base/content/msgAccountCentral.xhtml thunderbird-115.4.3+build1/comm/mailnews/base/content/msgAccountCentral.xhtml --- thunderbird-115.4.2+build1/comm/mailnews/base/content/msgAccountCentral.xhtml 2023-11-08 07:24:34.000000000 +0000 +++ thunderbird-115.4.3+build1/comm/mailnews/base/content/msgAccountCentral.xhtml 2023-11-14 08:20:47.000000000 +0000 @@ -6,7 +6,6 @@ - - - + + - +