1+ import reminderDialogStylesURL from "../scss/reminder-dialog.styles.scss" ;
2+ import closeIconURL from "sumo/img/close.svg" ;
3+ import successIconUrl from "sumo/img/success.svg" ;
4+
5+ const NEW_DEVICE_DOWNLOAD_URL = "https://mzl.la/newdevice" ;
6+
7+ const CALENDAR_FORMATS = Object . freeze ( {
8+ ICAL : 1 ,
9+ OUTLOOK : 2 ,
10+ GCAL : 3 ,
11+ } ) ;
12+
13+ /**
14+ * The Form Wizard can open a ReminderDialog, which is a <dialog>
15+ * that lets the user create calendar events for themselves to
16+ * remind them download and install Firefox on their new devices.
17+ *
18+ * The ReminderDialog also lets users copy the download link to
19+ * their clipboard, in the event that they want to send themselves
20+ * the link via text or email.
21+ */
22+ export class ReminderDialog extends HTMLDialogElement {
23+ #shadow = null ;
24+
25+ static get REMINDER_DAYS_IN_THE_FUTURE ( ) {
26+ return 14 ;
27+ }
28+
29+ static get markup ( ) {
30+ return `
31+ <template>
32+ <div id="reminder-dialog-content">
33+ <div id="header" class="hbox">
34+ <h1 class="text-display-xxs">${ gettext ( "Add to calendar" ) } </h1>
35+ <button id="close" class="mzp-c-button mzp-t-neutral" aria-label="${ gettext ( "Close" ) } " data-event-category="device-migration-wizard" data-event-action="click" data-event-label="close-reminder-dialog"><img src="${ closeIconURL } " aria-hidden="true"/></button>
36+ </div>
37+ <div class="vbox">
38+ <div id="directions">${ gettext ( "Save the download link to your calendar and finish the download whenever you’re ready." ) } </div>
39+ <label for="choose-calendar">${ gettext ( "Choose calendar" ) } </label>
40+ <div class="hbox">
41+ <select id="choose-calendar">
42+ <option value="gcal">Google Calendar</option>
43+ <option value="outlook">Outlook.com</option>
44+ <option value="ics">${ gettext ( "Other calendar" ) } </option>
45+ </select>
46+ <button id="create-event" class="mzp-c-button mzp-t-product" data-event-category="device-migration-wizard" data-event-action="click" data-event-label="create-calendar-event">${ gettext ( "Save" ) } </button>
47+ </div>
48+ <hr>
49+
50+ <div class="hbox">
51+ <span id="copy-link-message">${ gettext ( "You can also access the link directly" ) } </span>
52+ <div id="copy-link-container" class="hbox">
53+ <button id="copy-link" class="mzp-c-button mzp-t-product mzp-t-secondary mzp-t-md" data-event-category="device-migration-wizard" data-event-action="click" data-event-label="copy-link-to-clipboard-button">${ gettext ( "Copy link" ) } </button>
54+ <span id="copied-message"><img src="${ successIconUrl } " aria-hidden="true">${ gettext ( "Copied!" ) } </span>
55+ </div>
56+ </div>
57+ </div>
58+ </div>
59+ </template>
60+ ` ;
61+ }
62+
63+ static get styles ( ) {
64+ let stylesheet = document . createElement ( "link" ) ;
65+ stylesheet . rel = "stylesheet" ;
66+ stylesheet . href = reminderDialogStylesURL ;
67+ return stylesheet ;
68+ }
69+
70+ constructor ( ) {
71+ super ( ) ;
72+ // We cannot create a shadow root on a <dialog>, so we'll create a root
73+ // <div> node and put the shadow root there instead.
74+ let rootNode = document . createElement ( "div" ) ;
75+ this . appendChild ( rootNode ) ;
76+ let shadow = rootNode . attachShadow ( { mode : "open" } ) ;
77+
78+ let parser = new DOMParser ( ) ;
79+ let doc = parser . parseFromString ( ReminderDialog . markup , "text/html" ) ;
80+ let template = doc . querySelector ( "template" ) ;
81+ shadow . append ( ReminderDialog . styles , template . content . cloneNode ( true ) ) ;
82+
83+ this . #shadow = shadow ;
84+ }
85+
86+ get shadow ( ) {
87+ return this . #shadow;
88+ }
89+
90+ connectedCallback ( ) {
91+ let close = this . #shadow. querySelector ( "#close" ) ;
92+ close . addEventListener ( "click" , this ) ;
93+
94+ let copyLinkButton = this . #shadow. querySelector ( "#copy-link" ) ;
95+ copyLinkButton . addEventListener ( "click" , this ) ;
96+
97+ let createEventButton = this . #shadow. querySelector ( "#create-event" ) ;
98+ createEventButton . addEventListener ( "click" , this ) ;
99+ }
100+
101+ handleEvent ( event ) {
102+ switch ( event . currentTarget . id ) {
103+ case "close" : {
104+ this . close ( ) ;
105+ break ;
106+ }
107+ case "copy-link" : {
108+ this . #copyLink( ) ;
109+ break ;
110+ }
111+ case "create-event" : {
112+ let calendarType = this . #shadow. querySelector ( "#choose-calendar" ) . value ;
113+ this . #createEvent( calendarType )
114+ }
115+ }
116+ }
117+
118+ /**
119+ * This is a thin wrapper around window.location so that our automated
120+ * tests can easily stub this out and override it (since the test
121+ * framework we use gets upset when writing to window.location).
122+ *
123+ * @param {string } url
124+ * The URL to send the browser to.
125+ */
126+ redirect ( url ) {
127+ window . location . href = url ;
128+ }
129+
130+ /**
131+ * Creates a summary and description string appropriate for a calendar
132+ * event for downloading and installing Firefox on a new device. This
133+ * will be translated to the current user's locale.
134+ *
135+ * @param {string } linkURL
136+ * The URL that the event should provide to download Firefox.
137+ * @param {integer } format
138+ * One of the CALENDAR_FORMATS constants.
139+ * @returns {object } result
140+ * @returns {string } result.summary
141+ * The summary for the event.
142+ * @returns {string } result.description
143+ * The description for the event, including the linkURL.
144+ */
145+ #generateEventSummaryAndDescription( linkURL , format ) {
146+ let summary = gettext ( "Reminder to complete your Firefox backup" ) ;
147+ let description = interpolate (
148+ gettext ( "Your Firefox data has been successfully backed up.\n\nFollow this link to start your download: %s" ) ,
149+ [ linkURL ]
150+ ) ;
151+
152+ if ( format == CALENDAR_FORMATS . ICAL ) {
153+ description = description . replace ( / \n / g, "\\n" ) ;
154+ }
155+
156+ return { summary, description } ;
157+ }
158+
159+ /**
160+ * Generates start and end dates for the reminder. This function
161+ * will compute the correct start and end dates to produce an
162+ * "All Day" event for the given CALENDAR_FORMAT.
163+ *
164+ * @param {number } format
165+ * One of the CALENDAR_FORMATS constants.
166+ * @returns {object } result
167+ * @returns {string } result.dtStart
168+ * The start date of the event in a format appropriate for
169+ * CALENDAR_FORMAT.
170+ * @returns {string } result.dtEnd
171+ * The end date of the event in a format appropriate for
172+ * CALENDAR_FORMAT.
173+ */
174+ #generateDTStartDTEnd( format ) {
175+ let startDate = new Date ( ) ;
176+ // Set the reminder for REMINDER_DAYS_IN_THE_FUTURE days in the future.
177+ startDate . setDate ( startDate . getDate ( ) + ReminderDialog . REMINDER_DAYS_IN_THE_FUTURE ) ;
178+
179+ let dtStart = this . #convertDateToDTFormat( format , startDate ) ;
180+
181+ // For Google Calendar, all-day events are encoded by not including
182+ // the time (handled by #generateDTStartDTEnd), and having the start
183+ // and end date match.
184+ // See https://stackoverflow.com/questions/37335415/link-to-add-all-day-event-to-google-calendar
185+ if ( format == CALENDAR_FORMATS . GCAL ) {
186+ return { dtStart, dtEnd : dtStart } ;
187+ }
188+
189+ // For other formats, it's expected that the end date for an
190+ // "All Day" event be the next day.
191+ let endDate = new Date ( startDate ) ;
192+ endDate . setDate ( startDate . getDate ( ) + 1 ) ;
193+ let dtEnd = this . #convertDateToDTFormat( format , endDate ) ;
194+
195+ return { dtStart, dtEnd } ;
196+ }
197+
198+ /**
199+ * Converts a DOM Date object into a string representation that
200+ * is compatible with the VEVENT format.
201+ *
202+ * @param {number } format
203+ * One of the CALENDAR_FORMATS constants.
204+ * @param {Date } date
205+ * The date to encode.
206+ * @returns {string }
207+ * The VEVENT-compatible date, set at midnight, with no timezone
208+ * information.
209+ */
210+ #convertDateToDTFormat( format , date ) {
211+ let year = date . getFullYear ( ) . toString ( ) ;
212+
213+ // In all cases, months or days with single digits are expected to start
214+ // with a 0.
215+
216+ // getMonth() is 0-indexed.
217+ let month = new String ( date . getMonth ( ) + 1 ) . padStart ( 2 , "0" ) ;
218+ let day = new String ( date . getDate ( ) ) . padStart ( 2 , "0" ) ;
219+
220+ switch ( format ) {
221+ case CALENDAR_FORMATS . ICAL : {
222+ return `${ year } ${ month } ${ day } T000000Z` ;
223+ }
224+ case CALENDAR_FORMATS . OUTLOOK : {
225+ return `${ year } -${ month } -${ day } `
226+ }
227+ case CALENDAR_FORMATS . GCAL : {
228+ return `${ year } ${ month } ${ day } `
229+ }
230+ default : {
231+ throw new Error ( "#convertDateToDTFormat wasn't given a format for the date." ) ;
232+ }
233+ }
234+ }
235+
236+ /**
237+ * Generates a URL to download a .ics file that can be interpreted
238+ * by calendaring software to add an event to a calendar that reminds
239+ * the user to download and install Firefox on their new device.
240+ *
241+ * @returns {string }
242+ * The blob URL for the .ics file download.
243+ */
244+ #generateICSFileDownload( ) {
245+ let now = this . #convertDateToDTFormat( CALENDAR_FORMATS . ICAL , new Date ( ) ) ;
246+ let { dtStart, dtEnd } = this . #generateDTStartDTEnd( CALENDAR_FORMATS . ICAL ) ;
247+ let timezone = Intl . DateTimeFormat ( ) . resolvedOptions ( ) . timeZone ;
248+
249+ // mozilla/sumo#1503: The NEW_DEVICE_DOWNLOAD_URL should be replaced with
250+ // a link that attributes the download to an event from an ICS file.
251+ let { summary, description } = this . #generateEventSummaryAndDescription( NEW_DEVICE_DOWNLOAD_URL , CALENDAR_FORMATS . ICAL ) ;
252+
253+ // The random value here is not meant to be cryptographically
254+ // secure. The randomValue is used to create a unique UID for
255+ // the VEVENT.
256+ let randomValue = dtStart + Math . random ( ) ;
257+
258+ let icsFile = `BEGIN:VCALENDAR
259+ VERSION:2.0
260+ PRODID:-//Mozilla.org/NONSGML support.mozilla.org switching devices wizard V1.0//EN
261+ METHOD:REQUEST
262+ BEGIN:VEVENT
263+ UID:${ randomValue }
264+ DTSTAMP:${ now }
265+ DTSTART;TZID=${ timezone } :${ dtStart }
266+ DTEND;TZID=${ timezone } :${ dtEnd }
267+ SUMMARY:${ summary }
268+ DESCRIPTION:${ description }
269+ END:VEVENT
270+ END:VCALENDAR
271+ ` ;
272+ let blob = new Blob ( [ icsFile ] , { type : "text/calendar;charset=utf-8;" } ) ;
273+ let blobURL = window . URL . createObjectURL ( blob ) ;
274+ return blobURL ;
275+ }
276+
277+ /**
278+ * Copies the NEW_DEVICE_DOWNLOAD_URL string to the clipboard,
279+ * and updates the UI to indicate that the copy was successful for
280+ * 5 seconds before returning to normal.
281+ */
282+ #copyLink( ) {
283+ let contentNode = this . #shadow. querySelector ( "#reminder-dialog-content" ) ;
284+ let copyLinkButton = this . #shadow. querySelector ( "#copy-link" ) ;
285+ navigator . clipboard . writeText ( NEW_DEVICE_DOWNLOAD_URL ) ;
286+ contentNode . toggleAttribute ( "data-copied" , true ) ;
287+ setTimeout ( ( ) => {
288+ contentNode . toggleAttribute ( "data-copied" , false ) ;
289+ } , 5000 ) ;
290+ }
291+
292+ #createEvent( calendarType ) {
293+ switch ( calendarType ) {
294+ case "gcal" : {
295+ this . #openGCalTab( ) ;
296+ break ;
297+ }
298+ case "outlook" : {
299+ this . #openOutlookTab( ) ;
300+ break ;
301+ }
302+ case "ics" : {
303+ let icsDownload = this . #generateICSFileDownload( ) ;
304+ this . redirect ( icsDownload ) ;
305+ break ;
306+ }
307+ }
308+ }
309+
310+ #openGCalTab( ) {
311+ const GCAL_ENDPOINT = "https://calendar.google.com/calendar/render?" ;
312+
313+ // mozilla/sumo#1503: The NEW_DEVICE_DOWNLOAD_URL should be replaced with
314+ // a link that attributes the download to an event from Google Calendar.
315+ let { summary, description } = this . #generateEventSummaryAndDescription( NEW_DEVICE_DOWNLOAD_URL , CALENDAR_FORMATS . GCAL ) ;
316+ let { dtStart, dtEnd } = this . #generateDTStartDTEnd( CALENDAR_FORMATS . GCAL ) ;
317+ let params = new URLSearchParams ( ) ;
318+ params . set ( "action" , "TEMPLATE" ) ;
319+ params . set ( "dates" , `${ dtStart } /${ dtEnd } ` ) ;
320+ params . set ( "text" , summary ) ;
321+ params . set ( "details" , description ) ;
322+ window . open ( GCAL_ENDPOINT + params ) ;
323+ }
324+
325+ #openOutlookTab( ) {
326+ const OUTLOOK_ENDPOINT = "https://outlook.live.com/calendar/0/deeplink/compose/?" ;
327+
328+ // mozilla/sumo#1503: The NEW_DEVICE_DOWNLOAD_URL should be replaced with
329+ // a link that attributes the download to an event from Microsoft Outlook.
330+ let { summary, description } = this . #generateEventSummaryAndDescription( NEW_DEVICE_DOWNLOAD_URL , CALENDAR_FORMATS . OUTLOOK ) ;
331+ let { dtStart, dtEnd } = this . #generateDTStartDTEnd( CALENDAR_FORMATS . OUTLOOK ) ;
332+ let params = new URLSearchParams ( ) ;
333+
334+ // The arguments here were supplied by
335+ // https://interactiondesignfoundation.github.io/add-event-to-calendar-docs/services/outlook-web.html.
336+ params . set ( "body" , description ) ;
337+ params . set ( "subject" , summary ) ;
338+ params . set ( "startdt" , dtStart ) ;
339+ params . set ( "enddt" , dtEnd ) ;
340+ params . set ( "rru" , "addevent" ) ;
341+ params . set ( "path" , "/calendar/action/compose" ) ;
342+ params . set ( "allday" , "true" ) ;
343+ window . open ( OUTLOOK_ENDPOINT + params ) ;
344+ }
345+ }
346+
347+ customElements . define ( "reminder-dialog" , ReminderDialog , { extends : "dialog" } ) ;
0 commit comments