Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/**
* Privacy widget message scheduling — catalog + selector.
*
* The widget always shows today's tracker count. Below it, it shows ONE
* secondary message — or nothing ("blank"). This module owns the catalog of
* messages and the pure logic that picks which one to show on each new tab.
* `PrivacyFeed` gathers the inputs (counts, streak, feature flags, persisted
* state), calls `selectPrivacyMessage`, persists the returned state, and
* broadcasts the decision to the component (Bug 2050954).
*
* ───────────────────────────── BUSINESS RULES ─────────────────────────────
*
* ZERO STATE
* No trackers blocked today → the empty state (shield icon, no count, no tip).
* A zero *site* count is not a zero state; the component drops that line instead.
*
* MESSAGE FAMILIES (the `category` field)
* info Education about tracking / Firefox protections. shield/planet/
* bolt icon by sub-theme.
* promo Suggest adopting another Firefox feature (passwords, VPN,
* Relay masks, sign-in, Monitor). star icon. `feature` tags it
* so we can suppress promos for features already in use.
* firstProtection / dailyCap / streak / milestone{Week,Month,Year,Total}
* Earned "celebration" moments. kit icon.
*
* CELEBRATIONS
* firstProtection The very first time the count goes above zero (once ever).
* dailyCap Today's count reaches the display cap (the "100+" ceiling).
* streak Protected 3, 5, or 7 days in a row (count-block layout,
* like blank but with a message + kit icon). Each of 3/5/7
* fires once per streak; a broken streak re-arms them.
* milestone* Cumulative total crosses a tier (see MILESTONE_TIERS):
* week 100
* month 250 / 500 / 1K / 2K
* year 5K / 10K / 25K
* total 10K / 25K
* Fires on the next new tab after crossing, at most once per
* day. The highest crossed tier wins; the watermark prevents
* re-firing until the calendar period rolls (week/month/year)
* or never (total).
*
* FREQUENCY (ordinary info + promo only — celebrations bypass all of this)
* Normal profile: ≤ 5/day, ≤ 1/hour. (day = UTC day, see below)
* New profile (< 48h): ≤ 10/day, ≤ 1/15min. Anchored to profile age.
* Over a limit → blank. A blank never consumes a slot (only real messages do).
* "Seen" = rendered: a message counts the moment it is selected for a new tab.
*
* BLANK CHANCE
* Even when an info message is eligible, show blank `blankChance` of the time
* (default 0.4, pref-controlled) for a calm, high-signal experience. Promos
* are not subject to the random blank.
*
* PROMO LIMITS (on top of the frequency rules)
* ≤ 1 promo/day; the same promo repeats ≤ once/month; and promos are skipped
* for features the user already uses (sign-in/Sync, saved logins, Relay masks
* are detectable; VPN and Monitor have no local signal so they still rotate).
*
* PRECEDENCE (when several qualify on the same new tab)
* 1. empty (count 0)
* 2. firstProtection (once ever)
* 3. streak (3/5/7)
* 4. milestone (≤ once/day)
* 5. dailyCap (100+)
* 6. otherwise an ordinary info/promo message subject to the limits — or blank
*
* DAY BOUNDARIES are UTC (matching how the tracker count is stored; same as
* about:protections), so week/month/year totals reset on UTC boundaries.
*
* NOTE (Bug 2048389): the catalog `id`s are real Fluent message ids; the
* component resolves each as an l10n id with `countArg`. The scheduling logic
* keys off `category`/`icon`/`countSource`, never the id.
*/
export const CATEGORY = {
EMPTY: "empty",
INFO: "info",
PROMO: "promo",
FIRST_PROTECTION: "firstProtection",
DAILY_CAP: "dailyCap",
STREAK: "streak",
MILESTONE_WEEK: "milestoneWeek",
MILESTONE_MONTH: "milestoneMonth",
MILESTONE_YEAR: "milestoneYear",
MILESTONE_TOTAL: "milestoneTotal",
};
// Cumulative tiers per period. Highest crossed tier wins; ascending order.
export const MILESTONE_TIERS = {
week: [100],
month: [250, 500, 1000, 2000],
year: [5000, 10000, 25000],
total: [10000, 25000],
};
// Consecutive-day streak lengths that fire a celebration.
export const STREAK_DAYS = [3, 5, 7];
const DAY_MS = 24 * 60 * 60 * 1000;
const NEW_PROFILE_WINDOW_MS = 48 * 60 * 60 * 1000;
const PROMO_REPEAT_MS = 30 * DAY_MS;
const CAPS = {
normal: { perDay: 5, intervalMs: 60 * 60 * 1000 },
newProfile: { perDay: 10, intervalMs: 15 * 60 * 1000 },
};
// The empty-state message (count 0). Fixed, not part of the rotation pool.
export const EMPTY_MESSAGE = {
id: "newtab-privacy-empty",
category: CATEGORY.EMPTY,
icon: "shield",
countSource: "none",
};
// CTA actions are SpecialMessageAction descriptors (see
// SpecialMessageActions.sys.mjs). The component dispatches them to the parent
// (PrivacyFeed) on click, which calls SpecialMessageActions.handleAction. A
// message with no `cta` renders no button. Small builders keep the catalog
// readable; the destinations come from the Phase 1 message spec.
const ctaAboutPage = args => ({
type: "OPEN_ABOUT_PAGE",
data: { args, where: "tab" },
});
const ctaUrl = url => ({ type: "OPEN_URL", data: { args: url, where: "tab" } });
const CTA_PROTECTIONS = ctaAboutPage("protections");
const CTA_LOGINS = ctaAboutPage("logins");
const CTA_USER_PRIVACY = ctaUrl("https://www.firefox.com/en-US/user-privacy/");
const CTA_MISSION = ctaUrl("https://www.mozilla.org/en-US/mission/");
// Monitor/Relay are external properties: tag the referral so they can attribute
// signups to this widget (Bug 2061524). utm_content identifies the message.
// `base` must end in "/" since the query string is appended directly to it.
const ctaAttributedUrl = (base, utmContent) =>
ctaUrl(
`${base}?utm_medium=referral&utm_source=firefox-desktop&utm_campaign=widget&utm_content=${utmContent}`
);
const MONITOR = "https://monitor.mozilla.org/";
const RELAY = "https://relay.firefox.com/";
const CTA_SET_DEFAULT = { type: "SET_DEFAULT_BROWSER" };
const CTA_SIGNIN = {
type: "FXA_SIGNIN_FLOW",
data: { entrypoint: "newtab-privacy-widget" },
};
const CTA_VPN = { type: "IPPROTECTION_ENROLL" };
const CTA_PRIVATE_WINDOW = { type: "OPEN_PRIVATE_BROWSER_WINDOW" };
/**
* Message catalog. Each entry:
* id the Fluent message id (Bug 2048389); the component resolves it
* with `countArg` as the l10n args. The `-cta` companion string
* shares this id stem.
* category one of CATEGORY — drives scheduling, NOT inferred from the id
* icon shield | shieldCheck | planet | bolt | star | kit
* countSource none | today | week | month | year | total | streak | sites
* feature promos only: signin | relay | vpn | monitor | private-window
* (only signin/relay are locally suppressible — see isFeatureInUse)
* cta optional SpecialMessageAction descriptor for the CTA button; the
* button's label is the `-cta` companion Fluent id. Omitted → no
* button (the zero state and the random blank have none).
*/
export const PRIVACY_MESSAGES = [
// — Celebrations —
{
id: "newtab-privacy-message-first-protection",
category: CATEGORY.FIRST_PROTECTION,
icon: "kit",
countSource: "none",
cta: CTA_PROTECTIONS,
},
{
id: "newtab-privacy-message-daily-cap",
category: CATEGORY.DAILY_CAP,
icon: "kit",
countSource: "none",
cta: CTA_PROTECTIONS,
},
{
id: "newtab-privacy-message-streak",
category: CATEGORY.STREAK,
icon: "kit",
countSource: "streak",
cta: CTA_PROTECTIONS,
},
{
id: "newtab-privacy-message-milestone-week",
category: CATEGORY.MILESTONE_WEEK,
icon: "kit",
countSource: "week",
cta: CTA_PROTECTIONS,
},
{
id: "newtab-privacy-message-milestone-month",
category: CATEGORY.MILESTONE_MONTH,
icon: "kit",
countSource: "month",
cta: CTA_PROTECTIONS,
},
{
id: "newtab-privacy-message-milestone-year",
category: CATEGORY.MILESTONE_YEAR,
icon: "kit",
countSource: "year",
cta: CTA_PROTECTIONS,
},
{
id: "newtab-privacy-message-milestone-total",
category: CATEGORY.MILESTONE_TOTAL,
icon: "kit",
countSource: "total",
cta: CTA_PROTECTIONS,
},
// — Info (shield: blocking trackers; planet: values/choice; bolt: data/speed) —
// gist comments name the message; copy lives in newtab.ftl under the id.
{
// blocks trackers automatically as you browse
id: "newtab-privacy-message-info-1",
category: CATEGORY.INFO,
icon: "shield",
countSource: "none",
cta: CTA_PROTECTIONS,
},
{
// tracker blocking helps stop companies following you
id: "newtab-privacy-message-info-2",
category: CATEGORY.INFO,
icon: "shield",
countSource: "none",
cta: CTA_PROTECTIONS,
},
{
// many sites have trackers
id: "newtab-privacy-message-info-3",
category: CATEGORY.INFO,
icon: "shield",
countSource: "none",
cta: CTA_PROTECTIONS,
},
{
// choosing Firefox = protection by default
id: "newtab-privacy-message-info-4",
category: CATEGORY.INFO,
icon: "planet",
countSource: "none",
cta: CTA_PROTECTIONS,
},
{
// blocked trackers = fewer companies can follow
id: "newtab-privacy-message-info-5",
category: CATEGORY.INFO,
icon: "shield",
countSource: "none",
cta: CTA_PROTECTIONS,
},
{
// we never sell your data
id: "newtab-privacy-message-info-6",
category: CATEGORY.INFO,
icon: "planet",
countSource: "none",
cta: CTA_USER_PRIVACY,
},
{
// see which trackers Firefox blocked
id: "newtab-privacy-message-info-7",
category: CATEGORY.INFO,
icon: "shield",
countSource: "none",
cta: CTA_PROTECTIONS,
},
{
// browsing with Firefox supports Mozilla's mission
id: "newtab-privacy-message-info-8",
category: CATEGORY.INFO,
icon: "planet",
countSource: "none",
cta: CTA_MISSION,
},
{
// make Firefox your default browser
id: "newtab-privacy-message-info-9",
category: CATEGORY.INFO,
icon: "planet",
countSource: "none",
cta: CTA_SET_DEFAULT,
},
{
// save passwords in Firefox
id: "newtab-privacy-message-info-10",
category: CATEGORY.INFO,
icon: "planet",
countSource: "none",
cta: CTA_LOGINS,
},
{
// find out how Firefox helps keep browsing private
id: "newtab-privacy-message-info-11",
category: CATEGORY.INFO,
icon: "planet",
countSource: "none",
cta: CTA_USER_PRIVACY,
},
{
// save bandwidth on limited data plans
id: "newtab-privacy-message-info-12",
category: CATEGORY.INFO,
icon: "bolt",
countSource: "none",
cta: CTA_PROTECTIONS,
},
{
// freeing bandwidth for smoother streaming
id: "newtab-privacy-message-info-13",
category: CATEGORY.INFO,
icon: "bolt",
countSource: "none",
cta: CTA_PROTECTIONS,
},
// — Promo (star icon; `feature` drives suppression) —
{
// Monitor: find out if your info is in a data breach
id: "newtab-privacy-message-promo-monitor-1",
category: CATEGORY.PROMO,
icon: "star",
countSource: "none",
feature: "monitor",
cta: ctaAttributedUrl(MONITOR, "get-breach-alerts-global"),
},
{
// Monitor: free data breach monitoring, up to 20 emails
id: "newtab-privacy-message-promo-monitor-2",
category: CATEGORY.PROMO,
icon: "star",
countSource: "none",
feature: "monitor",
cta: ctaAttributedUrl(MONITOR, "protect-your-info-global"),
},
{
// sign in: encrypt bookmarks/passwords/tabs across devices
id: "newtab-privacy-message-promo-signin-1",
category: CATEGORY.PROMO,
icon: "star",
countSource: "none",
feature: "signin",
cta: CTA_SIGNIN,
},
{
// VPN: public Wi-Fi
id: "newtab-privacy-message-promo-vpn-1",
category: CATEGORY.PROMO,
icon: "star",
countSource: "none",
feature: "vpn",
cta: CTA_VPN,
},
{
// VPN: airport Wi-Fi
id: "newtab-privacy-message-promo-vpn-2",
category: CATEGORY.PROMO,
icon: "star",
countSource: "none",
feature: "vpn",
cta: CTA_VPN,
},
{
// VPN: keep location more private
id: "newtab-privacy-message-promo-vpn-3",
category: CATEGORY.PROMO,
icon: "star",
countSource: "none",
feature: "vpn",
cta: CTA_VPN,
},
{
// private window: shared computer
id: "newtab-privacy-message-promo-private-window-1",
category: CATEGORY.PROMO,
icon: "star",
countSource: "none",
feature: "private-window",
cta: CTA_PRIVATE_WINDOW,
},
{
// Relay: email mask for sign-ups
id: "newtab-privacy-message-promo-relay-1",
category: CATEGORY.PROMO,
icon: "star",
countSource: "none",
feature: "relay",
cta: ctaAttributedUrl(RELAY, "use-email-mask-global"),
},
{
// Relay: protect real address from spam
id: "newtab-privacy-message-promo-relay-2",
category: CATEGORY.PROMO,
icon: "star",
countSource: "none",
feature: "relay",
cta: ctaAttributedUrl(RELAY, "protect-your-inbox-global"),
},
{
// Relay: 50 free email masks
id: "newtab-privacy-message-promo-relay-3",
category: CATEGORY.PROMO,
icon: "star",
countSource: "none",
feature: "relay",
cta: ctaAttributedUrl(RELAY, "50-free-email-mask-global"),
},
];
function startOfUTCDay(ms) {
const d = new Date(ms);
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
}
function utcDayKey(ms) {
return new Date(startOfUTCDay(ms)).toISOString().slice(0, 10);
}
/**
* UTC period windows for the milestone totals. The feed queries each total over
* `startMs..now`; the selector uses `key` to reset a period's watermark when the
* calendar period rolls. Computed in one place so the two never drift.
*
* @param {number} now - Timestamp in ms.
* @returns {{week: object, month: object, year: object}}
*/
export function periodBounds(now) {
const d = new Date(now);
const dayStart = startOfUTCDay(now);
const mondayOffset = (d.getUTCDay() + 6) % 7; // 0 = Sun -> 6 days back to Mon
const weekStartMs = dayStart - mondayOffset * DAY_MS;
const year = d.getUTCFullYear();
const month = d.getUTCMonth() + 1;
return {
week: { startMs: weekStartMs, key: utcDayKey(weekStartMs) },
month: {
startMs: Date.UTC(year, d.getUTCMonth(), 1),
key: `${year}-${month}`,
},
year: { startMs: Date.UTC(year, 0, 1), key: `${year}` },
};
}
function normalizeState(prev) {
const s = prev || {};
return {
firstProtectionShown: !!s.firstProtectionShown,
lastShownMs: s.lastShownMs || 0,
dayStamp: s.dayStamp || null,
shownToday: s.shownToday || 0,
lastCelebrationDay: s.lastCelebrationDay || null,
streakFiredAt: s.streakFiredAt || 0,
milestoneWatermark: { ...(s.milestoneWatermark || {}) },
promoLastDay: s.promoLastDay || null,
messageLastShown: { ...(s.messageLastShown || {}) },
recentId: s.recentId || null,
};
}
function countArgFor(msg, ctx) {
switch (msg.countSource) {
case "today":
return { count: ctx.trackersToday };
case "week":
return { count: ctx.weekTotal };
case "month":
return { count: ctx.monthTotal };
case "year":
return { count: ctx.yearTotal };
case "total":
return { count: ctx.allTimeTotal };
case "streak":
return { count: ctx.streakDays };
case "sites":
return { count: ctx.sitesToday };
default:
return null;
}
}
function toDecision(variant, msg, countArg, countCeiling = null) {
return {
variant,
messageId: msg.id,
// The message family (CATEGORY) so the component can tell a celebration
// from an ordinary tip without matching ids.
category: msg.category,
icon: msg.icon,
countArg,
// SpecialMessageAction the CTA button dispatches; null → no button.
cta: msg.cta ?? null,
// When set, the count readout shows "{countCeiling}+" instead of the real
// number — only the daily-cap render caps the display (see Privacy.jsx).
countCeiling,
};
}
function blankDecision() {
return {
variant: "blank",
messageId: null,
category: null,
icon: "shieldCheck",
countArg: null,
// Blank still offers a "View protections" CTA. messageId is null (no tip
// copy), so the component supplies the label id; the action rides here.
// TODO(follow-up): give the blank state its own CTA string.
cta: CTA_PROTECTIONS,
countCeiling: null,
};
}
function firstByCategory(category) {
return PRIVACY_MESSAGES.find(m => m.category === category) || null;
}
// Layout variant a message renders as (see Privacy.jsx). Empty and streak have
// their own layouts; everything else is a "tip".
function variantForCategory(category) {
if (category === "empty") {
return "empty";
}
if (category === CATEGORY.STREAK) {
return "streak";
}
return "tip";
}
// Debug override result for widgets.privacy.forceMessageId: the forced message's
// decision (bypassing the ladder/caps, no state mutation), or null when unset or
// the id is unknown (falls through to normal scheduling).
function forcedResult(ctx, state) {
if (!ctx.forceMessageId) {
return null;
}
const forced =
ctx.forceMessageId === EMPTY_MESSAGE.id
? EMPTY_MESSAGE
: PRIVACY_MESSAGES.find(m => m.id === ctx.forceMessageId);
if (!forced) {
return null;
}
return {
decision: toDecision(
variantForCategory(forced.category),
forced,
countArgFor(forced, ctx)
),
nextState: state,
};
}
function isFeatureInUse(feature, features = {}) {
switch (feature) {
case "signin":
return !!features.signedIn;
case "logins":
return !!features.hasLogins;
case "relay":
return !!features.relayMasks;
// vpn / monitor: no reliable local signal — never suppressed.
default:
return false;
}
}
// Highest un-fired milestone crossing, scanned widest scope first. Advances the
// watermark for the period it fires (and resets a rolled period's watermark).
function detectMilestone(ctx, bounds, state) {
const checks = [
["total", CATEGORY.MILESTONE_TOTAL, ctx.allTimeTotal, null],
["year", CATEGORY.MILESTONE_YEAR, ctx.yearTotal, bounds.year.key],
["month", CATEGORY.MILESTONE_MONTH, ctx.monthTotal, bounds.month.key],
["week", CATEGORY.MILESTONE_WEEK, ctx.weekTotal, bounds.week.key],
];
for (const [period, category, total, key] of checks) {
const wm = state.milestoneWatermark[period] || { key: null, tier: 0 };
const rolled = key !== null && wm.key !== key;
const baseTier = rolled ? 0 : wm.tier;
const crossed = MILESTONE_TIERS[period].filter(
t => total >= t && t > baseTier
);
if (crossed.length) {
state.milestoneWatermark[period] = {
key,
tier: crossed[crossed.length - 1],
};
return category;
}
if (rolled) {
state.milestoneWatermark[period] = { key, tier: 0 };
}
}
return null;
}
function buildPool(ctx, state, today, now) {
const recent = state.recentId;
const pool = PRIVACY_MESSAGES.filter(
m => m.category === CATEGORY.INFO && m.id !== recent
);
if (state.promoLastDay !== today) {
for (const m of PRIVACY_MESSAGES) {
if (
m.category === CATEGORY.PROMO &&
m.id !== recent &&
!isFeatureInUse(m.feature, ctx.features) &&
// VPN promos need both gates: showVpnMessages (operator/experiment
// opt-in, pref or trainhopConfig) AND vpnEnabled (the per-user VPN
// availability signal, browser.ipProtection.enabled). Don't promote a
// feature the user can't actually use.
(m.feature !== "vpn" || (ctx.showVpnMessages && ctx.vpnEnabled)) &&
now - (state.messageLastShown[m.id] || 0) >= PROMO_REPEAT_MS
) {
pool.push(m);
}
}
}
return pool;
}
/**
* Pick the message (or blank) to show on a new tab. Pure: never reads prefs or
* the clock — `ctx`, `now`, and `rand` carry everything. Returns the render
* decision plus the next persisted scheduler state.
*
* @param {object} ctx - trackersToday, sitesToday, weekTotal, monthTotal,
* yearTotal, allTimeTotal, streakDays, maxCount, blankChance,
* showVpnMessages, profileCreatedMs, features {signedIn, hasLogins,
* relayMasks}, and optional forceMessageId (debug override).
* @param {object} prevState - Persisted scheduler state (or null/{}).
* @param {number} now - Timestamp in ms.
* @param {function(): number} rand - Returns 0..1 (Math.random in prod).
* @returns {{decision: object, nextState: object}}
*/
export function selectPrivacyMessage(ctx, prevState, now, rand) {
const state = normalizeState(prevState);
const today = utcDayKey(now);
const bounds = periodBounds(now);
// Debug override (widgets.privacy.forceMessageId): pin the widget to one
// catalog message for QA / design review, bypassing the ladder and all caps.
const forced = forcedResult(ctx, state);
if (forced) {
return forced;
}
if (state.dayStamp !== today) {
state.dayStamp = today;
state.shownToday = 0;
}
// 1. EMPTY
if (!ctx.trackersToday) {
return {
decision: toDecision("empty", EMPTY_MESSAGE, null),
nextState: state,
};
}
// 2. FIRST PROTECTION (once ever) — bypasses caps.
if (!state.firstProtectionShown) {
state.firstProtectionShown = true;
state.lastCelebrationDay = today;
const msg = firstByCategory(CATEGORY.FIRST_PROTECTION);
return {
decision: toDecision("tip", msg, countArgFor(msg, ctx)),
nextState: state,
};
}
// Celebrations are ≤ 1/day and bypass the rotation caps.
if (state.lastCelebrationDay !== today) {
// 3. STREAK (count-block layout). A broken streak re-arms 3/5/7.
if (ctx.streakDays < state.streakFiredAt) {
state.streakFiredAt = 0;
}
if (
STREAK_DAYS.includes(ctx.streakDays) &&
ctx.streakDays > state.streakFiredAt
) {
state.streakFiredAt = ctx.streakDays;
state.lastCelebrationDay = today;
const msg = firstByCategory(CATEGORY.STREAK);
return {
decision: toDecision("streak", msg, countArgFor(msg, ctx)),
nextState: state,
};
}
// 4. MILESTONE crossing.
const milestone = detectMilestone(ctx, bounds, state);
if (milestone) {
state.lastCelebrationDay = today;
const msg = firstByCategory(milestone);
return {
decision: toDecision("tip", msg, countArgFor(msg, ctx)),
nextState: state,
};
}
// 5. DAILY CAP. On this render the count readout caps to "{maxCount}+";
// subsequent loads show the real number again.
if (ctx.trackersToday >= ctx.maxCount) {
state.lastCelebrationDay = today;
const msg = firstByCategory(CATEGORY.DAILY_CAP);
return {
decision: toDecision("tip", msg, countArgFor(msg, ctx), ctx.maxCount),
nextState: state,
};
}
}
// 6. ORDINARY info/promo, subject to frequency caps.
const isNewProfile =
typeof ctx.profileCreatedMs === "number" &&
now - ctx.profileCreatedMs < NEW_PROFILE_WINDOW_MS;
const cap = isNewProfile ? CAPS.newProfile : CAPS.normal;
if (
state.shownToday >= cap.perDay ||
(state.lastShownMs && now - state.lastShownMs < cap.intervalMs)
) {
return { decision: blankDecision(), nextState: state };
}
const pool = buildPool(ctx, state, today, now);
if (!pool.length) {
return { decision: blankDecision(), nextState: state };
}
const pick = pool[Math.floor(rand() * pool.length)];
// Random blank damper on info (does NOT consume a slot).
if (pick.category === CATEGORY.INFO && rand() < ctx.blankChance) {
return { decision: blankDecision(), nextState: state };
}
state.shownToday += 1;
state.lastShownMs = now;
state.recentId = pick.id;
state.messageLastShown[pick.id] = now;
if (pick.category === CATEGORY.PROMO) {
state.promoLastDay = today;
}
return {
decision: toDecision("tip", pick, countArgFor(pick, ctx)),
nextState: state,
};
}