lib/messageUtils.ts (319 lines of code) (raw):
import { FxMSMessageInfo } from "@/app/columns";
import { getLookerSubmissionTimestampDateFilter } from "./lookerUtils";
import { Platform } from "./types";
export type SurfaceData = {
surface: string;
platform: Platform;
lookerDateFilterPropertyName: string;
lookerDashboardId: string;
tagColor?: string;
docs?: string;
};
/**
* @param surfaceOrTemplate a desktop template or mobile surface
* @returns data pertaining to the given desktop template or mobile surface
* (ie. surface display name, platform, Looker date filter property name,
* Looker dashboard ID, surface tag background colour)
*/
export function getSurfaceData(surfaceOrTemplate: string): SurfaceData {
const surfaceData: Record<string, SurfaceData> = {
aboutwelcome: {
surface: "About:Welcome Page (1st screen)",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-red-400",
},
cfr: {
surface: "Contextual Feature Recommendation",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-red-200",
},
cfr_doorhanger: {
surface: "Doorhanger",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-orange-200",
docs: "https://experimenter.info/messaging/desktop-messaging-surfaces/#doorhanger",
},
defaultaboutwelcome: {
surface: "Default About:Welcome Message (1st screen)",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-orange-400",
},
feature_callout: {
surface: "Feature Callout (1st screen)",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-yellow-300",
docs: "https://experimenter.info/messaging/desktop-messaging-surfaces/#feature-callouts",
},
infobar: {
surface: "InfoBar",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "messaging_system.submission_date",
lookerDashboardId: "2267",
tagColor: "bg-lime-300",
docs: "https://experimenter.info/messaging/desktop-messaging-surfaces/#infobar",
},
menu: {
surface: "Menu Messages",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-pink-300",
},
milestone_message: {
surface: "Milestone Messages",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-green-400",
},
multi: {
surface: "1st of Multiple Messages",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-teal-300",
},
pb_newtab: {
surface: "Private Browsing New Tab",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-sky-400",
docs: "https://experimenter.info/messaging/desktop-messaging-surfaces/#privatebrowsing",
},
protections_panel: {
surface: "Protections Dropdown Panel",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-blue-500",
},
rtamo: {
surface: "Return to AMO",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-violet-200",
},
toast_notification: {
surface: "Toast Notification",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-indigo-400",
},
toolbar_badge: {
surface: "Toolbar Badge",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-purple-400",
},
// XXX Consider removing after we start reading JSON from remote settings
"toolbar-badge": {
surface: "Toolbar Badge",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-purple-400",
},
spotlight: {
surface: "Spotlight Modal Dialog",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-pink-400",
docs: "https://experimenter.info/messaging/desktop-messaging-surfaces/#multistage-spotlight",
},
survey: {
surface: "Survey",
platform: "fenix",
lookerDateFilterPropertyName: "events.submission_date",
lookerDashboardId: "2303",
tagColor: "bg-cyan-400",
},
update_action: {
surface: "Moments Page",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-rose-400",
docs: "https://experimenter.info/messaging/desktop-messaging-surfaces/#moments-pages",
},
"whats-new-panel": {
surface: "What's New Panel",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-sky-200",
},
whatsNewPage: {
surface: "What's New Page",
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
tagColor: "bg-fuchsia-300",
},
};
if (surfaceOrTemplate in surfaceData) {
return surfaceData[surfaceOrTemplate];
}
return {
surface: surfaceOrTemplate,
platform: "firefox-desktop",
lookerDateFilterPropertyName: "event_counts.submission_timestamp_date",
lookerDashboardId: "1818",
};
}
export function getTemplateFromMessage(msg: any): string {
if (!msg || !msg?.template) {
return "none";
}
return msg.template;
}
export function _isAboutWelcomeTemplate(template: string): boolean {
// XXX multi shouldn't really be here, but for now, we're going to assume
// it's a spotlight
const aboutWelcomeSurfaces = [
"aboutwelcome",
"defaultaboutwelcome",
"feature_callout",
"multi",
"spotlight",
];
return aboutWelcomeSurfaces.includes(template);
}
export function getAndroidDashboardLink(
surface: string,
msgIdPrefix: string,
channel?: string,
experiment?: string,
branchSlug?: string,
startDate?: string | null,
endDate?: string | null,
isCompleted?: boolean,
): string | undefined {
// The isCompleted value can be useful for messages that used to be in remote
// settings or old versions of Firefox.
const submissionDate = getLookerSubmissionTimestampDateFilter(
startDate,
endDate,
isCompleted,
);
const dashboardId = getSurfaceData(surface).lookerDashboardId; // messages/push notification
let baseUrl = `https://mozilla.cloud.looker.com/dashboards/${dashboardId}`;
let paramObj;
paramObj = {
"Submission Date": submissionDate,
"Normalized Channel": channel ? channel : "",
"Normalized OS": "",
"Client Info App Display Version": "",
"Normalized Country Code": "",
"Experiment Slug": experiment ? experiment : "", // XXX
"Experiment Branch": branchSlug ? branchSlug : "",
// XXX assumes last part of message id is something like
// "-en-us" and chops that off, since we want to know about
// all the messages in the experiment. Will break
// (in "no results" way) on experiment with messages not configured
// like that.
Value: msgIdPrefix.slice(0, -5) + "%", // XXX
};
// XXX we really handle all messaging surfaces, at least in theory
if (surface !== "survey") return undefined;
if (paramObj) {
const params = new URLSearchParams(Object.entries(paramObj));
let url = new URL(baseUrl);
url.search = params.toString();
return url.toString();
}
return undefined;
}
export function getDesktopDashboardLink(
template: string,
msgId: string,
channel?: string,
experiment?: string,
branchSlug?: string,
startDate?: string | null,
endDate?: string | null,
isCompleted?: boolean,
): string | undefined {
// The isCompleted value can be useful for messages that used to be in remote
// settings or old versions of Firefox.
const submissionDate = getLookerSubmissionTimestampDateFilter(
startDate,
endDate,
isCompleted,
);
const dashboardId = getDashboardIdForSurface(template);
let baseUrl = `https://mozilla.cloud.looker.com/dashboards/${dashboardId}`;
let paramObj;
if (_isAboutWelcomeTemplate(template)) {
paramObj = {
"Submission Timestamp Date": submissionDate,
"Message ID": `%${msgId}%`,
"Normalized Channel": channel ? channel : "",
Experiment: experiment ? experiment : "",
Branch: branchSlug ? branchSlug : "",
};
}
if (template === "infobar") {
paramObj = {
"Messaging System Ping Type": template,
"Submission Date": submissionDate,
"Messaging System Message Id": msgId,
"Normalized Channel": channel ? channel : "",
"Normalized OS": "",
"Client Info App Display Version": "",
"Normalized Country Code": "",
Experiment: experiment ? experiment : "",
"Experiment Branch": branchSlug ? branchSlug : "",
};
}
if (paramObj) {
const params = new URLSearchParams(Object.entries(paramObj));
let url = new URL(baseUrl);
url.search = params.toString();
return url.toString();
}
return undefined;
}
// Convert a UTF-8 string to a string in which only one byte of each
// 16-bit unit is occupied. This is necessary to comply with `btoa` API constraints.
export function toBinary(string: string): string {
const codeUnits = new Uint16Array(string.length);
for (let i = 0; i < codeUnits.length; i++) {
codeUnits[i] = string.charCodeAt(i);
}
return btoa(
String.fromCharCode(...Array.from(new Uint8Array(codeUnits.buffer))),
);
}
export function maybeCreateWelcomePreview(message: any): object {
if (message.template === "defaultaboutwelcome") {
//Shove the about:welcome message in a spotlight
let defaultWelcomeFake = {
id: message.id,
template: "spotlight",
targeting: true,
content: message,
};
// Add the modal property to the spotlight to mimic about:welcome
defaultWelcomeFake.content.modal = "tab";
// The recipe might have a backdrop, but if not, fall back to the default
defaultWelcomeFake.content.backdrop =
message.backdrop ||
"var(--mr-welcome-background-color) var(--mr-welcome-background-gradient)";
return defaultWelcomeFake;
}
// if the message isn't about:welcome, just send it back
return message;
}
export function getPreviewLink(message: any): string {
let previewLink = `about:messagepreview?json=${encodeURIComponent(
toBinary(JSON.stringify(message)),
)}`;
return previewLink;
}
/**
* XXX consider moving this function inside looker.ts
* @returns the Looker dashboard ID for a given desktop template or mobile surface
*/
export function getDashboardIdForSurface(surfaceOrTemplate: string) {
return getSurfaceData(surfaceOrTemplate).lookerDashboardId;
}
/**
* @returns true if the message has a microsurvey. Currently, this check
* only involves looking for the "survey" subtring inside the message id.
*/
export function messageHasMicrosurvey(messageId: string): boolean {
return messageId.toLowerCase().includes("survey");
}
/**
* A sorting function to determine the order of the message by their surfaces.
*
* @returns -1 if the surface for message a is alphabetically before the
* surface for message b, zero if they're equal, and 1 otherwise.
*/
export function compareSurfacesFn(
a: FxMSMessageInfo,
b: FxMSMessageInfo,
): number {
if (a.surface < b.surface) {
return -1;
} else if (a.surface > b.surface) {
return 1;
}
return 0;
}