Skip to content

Commit d8d0ca4

Browse files
committed
feat(theme): Mitigate color token update for whitelabel users
- Added mapper of new/old color tokens. - Added a message to prompt admin users with whitelabel customisation to check and update their settings.
1 parent c960723 commit d8d0ca4

File tree

15 files changed

+274
-109
lines changed

15 files changed

+274
-109
lines changed

src/common/gui/ThemeController.ts

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import Stream from "mithril/stream"
55
import { assertMainOrNodeBoot, isApp, isDesktop } from "../api/common/Env"
66
import { downcast, findAndRemove, LazyLoaded, mapAndFilterNull, typedValues } from "@tutao/tutanota-utils"
77
import m from "mithril"
8-
import type { BaseThemeId, Theme, ThemeId, ThemePreference } from "./theme"
8+
import { BaseThemeId, theme, Theme, ThemeId, ThemePreference } from "./theme"
99
import { logoDefaultGrey, themes } from "./builtinThemes"
10-
import type { ThemeCustomizations } from "../misc/WhitelabelCustomizations"
10+
import { ThemeCustomizations } from "../misc/WhitelabelCustomizations"
1111
import { getWhitelabelCustomizations } from "../misc/WhitelabelCustomizations"
1212
import { getCalendarLogoSvg, getMailLogoSvg } from "./base/Logo"
1313
import { ThemeFacade } from "../native/common/generatedipc/ThemeFacade"
@@ -69,6 +69,46 @@ export class ThemeController {
6969
}
7070
}
7171

72+
/**
73+
* Color token mapper from the old to the new.
74+
* If there are colors explicitly defined for the new tokens, they will be respected and the mapped colors will be overwritten.
75+
* Since the number of new tokens is less than the number of old tokens, it is impossible to map all colors. Thus, this is just for mitigation purposes.
76+
* This mapper could be removed after all users who have whitelabel color customization have migrated to the new color tokens.
77+
*/
78+
public static mapOldToNewColorTokens(customizations: Partial<ThemeCustomizations>): ThemeCustomizations {
79+
let mappedCustomizations: Record<string, string> = {}
80+
for (const [oldToken, hex] of Object.entries(customizations)) {
81+
if (!oldToken || !hex) continue
82+
const newToken: string | undefined = oldToNewColorTokenMap[oldToken]
83+
if (newToken) {
84+
mappedCustomizations[newToken] = hex
85+
}
86+
mappedCustomizations[oldToken] = hex
87+
}
88+
return downcast(mappedCustomizations)
89+
}
90+
91+
/**
92+
* Color token mapper from the new to the old.
93+
* This mapper could be removed after all users who have whitelabel color customization have migrated to the new color tokens.
94+
*/
95+
public static mapNewToOldColorTokens(customizations: Partial<ThemeCustomizations>): ThemeCustomizations {
96+
let mappedCustomizations: Record<string, string> = {}
97+
98+
for (const [newToken, hex] of Object.entries(customizations)) {
99+
if (!newToken || !hex) continue
100+
const mappedOldTokens: string[] | undefined = newToOldColorTokenMap[newToken]
101+
if (mappedOldTokens) {
102+
for (const oldToken of mappedOldTokens) {
103+
mappedCustomizations[oldToken] = hex
104+
}
105+
}
106+
mappedCustomizations[newToken] = hex
107+
}
108+
109+
return downcast(mappedCustomizations)
110+
}
111+
72112
private parseCustomizations(stringTheme: string): ThemeCustomizations {
73113
// Filter out __proto__ to avoid prototype pollution. We use Object.assign() which is not susceptible to it but it doesn't hurt.
74114
return JSON.parse(stringTheme, (k, v) => (k === "__proto__" ? undefined : v))
@@ -168,7 +208,7 @@ export class ThemeController {
168208
* Apply the custom theme, if permanent === true, then the new theme will be saved
169209
*/
170210
async applyCustomizations(customizations: ThemeCustomizations, permanent: boolean = true): Promise<Theme> {
171-
const updatedTheme = this.assembleTheme(customizations)
211+
const updatedTheme = this.assembleTheme(ThemeController.mapOldToNewColorTokens(customizations))
172212
// Set no logo until we sanitize it.
173213
const filledWithoutLogo = Object.assign({}, updatedTheme, {
174214
logo: "",
@@ -316,3 +356,53 @@ export class WebThemeFacade implements ThemeFacade {
316356
this.mediaQuery?.addEventListener("change", listener)
317357
}
318358
}
359+
360+
const oldToNewColorTokenMap: Record<string, string> = {
361+
button_bubble_bg: "secondary",
362+
button_bubble_fg: "on_secondary",
363+
content_bg: "surface",
364+
content_fg: "on_surface",
365+
content_button: "on_surface_variant",
366+
content_button_selected: "primary",
367+
content_button_icon: "on_primary",
368+
content_button_icon_selected: "on_primary",
369+
content_accent: "primary",
370+
content_border: "outline",
371+
content_message_bg: "on_surface_fade",
372+
header_bg: "surface",
373+
header_box_shadow_bg: "outline",
374+
header_button: "on_surface_variant",
375+
header_button_selected: "primary",
376+
list_bg: "surface",
377+
list_alternate_bg: "surface_container",
378+
list_accent_fg: "primary",
379+
list_message_bg: "on_surface_fade",
380+
list_border: "outline_variant",
381+
modal_bg: "scrim",
382+
elevated_bg: "surface",
383+
navigation_bg: "surface_container",
384+
navigation_border: "outline_variant",
385+
navigation_button: "on_surface_variant",
386+
navigation_button_icon: "on_primary",
387+
navigation_button_selected: "primary",
388+
navigation_button_icon_selected: "on_primary",
389+
navigation_menu_bg: "secondary",
390+
navigation_menu_icon: "on_secondary",
391+
error_color: "error",
392+
} as const
393+
394+
const newToOldColorTokenMap: Record<string, string[]> = {
395+
secondary: ["button_bubble_bg", "navigation_menu_bg"],
396+
on_secondary: ["button_bubble_fg", "navigation_menu_icon"],
397+
surface: ["content_bg", "header_bg", "list_bg", "elevated_bg"],
398+
on_surface: ["content_fg"],
399+
on_surface_variant: ["content_button", "header_button", "navigation_button"],
400+
primary: ["content_accent", "content_button_selected", "header_button_selected", "list_accent_fg", "navigation_button_selected"],
401+
on_primary: ["content_button_icon", "content_button_icon_selected", "navigation_button_icon", "navigation_button_icon_selected"],
402+
outline: ["content_border", "header_box_shadow_bg"],
403+
on_surface_fade: ["content_message_bg", "list_message_bg"],
404+
surface_container: ["list_alternate_bg", "navigation_bg"],
405+
outline_variant: ["list_border", "navigation_border"],
406+
scrim: ["modal_bg"],
407+
error: ["error_color"],
408+
} as const

src/common/misc/TranslationKey.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2000,3 +2000,6 @@ export type TranslationKeyType =
20002000
| "zoomIn_action"
20012001
| "zoomOut_action"
20022002
| "emptyString_msg"
2003+
| "updateColorCustomizationNews_title"
2004+
| "updateColorCustomizationNews_msg"
2005+
| "updateColorCustomizationNewsButton_label"

src/common/misc/WhitelabelCustomizations.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import type { BaseThemeId, Theme } from "../gui/theme"
1+
import { BaseThemeId, theme, Theme } from "../gui/theme"
22
import { assertMainOrNodeBoot } from "../api/common/Env"
33
import type { WhitelabelConfig } from "../api/entities/sys/TypeRefs.js"
4+
import { ThemeController } from "../gui/ThemeController.js"
45

56
assertMainOrNodeBoot()
67
export type ThemeCustomizations = Partial<Theme> & {
@@ -27,5 +28,5 @@ export function getWhitelabelCustomizations(window: Window): WhitelabelCustomiza
2728
}
2829

2930
export function getThemeCustomizations(whitelabelConfig: WhitelabelConfig): ThemeCustomizations {
30-
return JSON.parse(whitelabelConfig.jsonTheme, (k, v) => (k === "__proto__" ? undefined : v))
31+
return ThemeController.mapOldToNewColorTokens(JSON.parse(whitelabelConfig.jsonTheme, (k, v) => (k === "__proto__" ? undefined : v)))
3132
}

src/common/misc/news/NewsDialog.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export function showNewsDialog(newsModel: NewsModel) {
3939
? m(NewsList, {
4040
liveNewsIds: newsModel.liveNewsIds,
4141
liveNewsListItems: newsModel.liveNewsListItems,
42+
dialog,
4243
})
4344
: m(
4445
".flex-center.mt-l",

src/common/misc/news/NewsList.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import { NewsListItem } from "./NewsListItem.js"
44
import ColumnEmptyMessageBox from "../../gui/base/ColumnEmptyMessageBox.js"
55
import { theme } from "../../gui/theme.js"
66
import { Icons } from "../../gui/base/icons/Icons.js"
7+
import { Dialog } from "../../gui/base/Dialog.js"
78

89
export interface NewsListAttrs {
910
liveNewsListItems: Record<string, NewsListItem>
1011
liveNewsIds: NewsId[]
12+
dialog: Dialog
1113
}
1214

1315
/**
@@ -28,7 +30,11 @@ export class NewsList implements Component<NewsListAttrs> {
2830
vnode.attrs.liveNewsIds.map((liveNewsId) => {
2931
const newsListItem = vnode.attrs.liveNewsListItems[liveNewsId.newsItemName]
3032

31-
return m(".pt.pl-l.pr-l.flex.fill.border-grey.left.list-border-bottom", { key: liveNewsId.newsItemId }, newsListItem.render(liveNewsId))
33+
return m(
34+
".pt.pl-l.pr-l.flex.fill.border-grey.left.list-border-bottom",
35+
{ key: liveNewsId.newsItemId },
36+
newsListItem.render(liveNewsId, vnode.attrs.dialog),
37+
)
3238
}),
3339
)
3440
}

src/common/misc/news/NewsListItem.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Children } from "mithril"
22
import { NewsId } from "../../api/entities/tutanota/TypeRefs.js"
3+
import { Dialog } from "../../gui/base/Dialog.js"
34

45
/**
56
* News items must implement this interface to be rendered.
@@ -8,7 +9,7 @@ export interface NewsListItem {
89
/**
910
* Returns the rendered NewsItem. Should display a button that acknowledges the news via NewsModel.acknowledge().
1011
*/
11-
render(newsId: NewsId): Children
12+
render(newsId: NewsId, dialog?: Dialog): Children
1213

1314
/**
1415
* Return true iff the news should be shown to the logged-in user.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { NewsListItem } from "../NewsListItem.js"
2+
import m, { Children } from "mithril"
3+
import { NewsId } from "../../../api/entities/tutanota/TypeRefs.js"
4+
import { lang } from "../../LanguageViewModel.js"
5+
import { Button, ButtonType } from "../../../gui/base/Button.js"
6+
import { NewsModel } from "../NewsModel.js"
7+
import { UserController } from "../../../api/main/UserController.js"
8+
import { Dialog } from "../../../gui/base/Dialog.js"
9+
10+
/**
11+
* News item that informs admin users that the new pricing offer is ending soon.
12+
*/
13+
export class UpdateColorCustomizationNews implements NewsListItem {
14+
constructor(private readonly newsModel: NewsModel, private readonly userController: UserController) {}
15+
16+
async isShown(): Promise<boolean> {
17+
return this.userController.isGlobalAdmin() && this.userController.isWhitelabelAccount()
18+
}
19+
20+
render(newsId: NewsId, dialog: Dialog): Children {
21+
const acknowledge = () => {
22+
this.newsModel.acknowledgeNews(newsId.newsItemId).then(m.redraw)
23+
}
24+
25+
return m(".full-width", [
26+
m(".h4.pb", lang.get("updateColorCustomizationNews_title")),
27+
m(".pb", lang.get("updateColorCustomizationNews_msg")),
28+
m(
29+
".flex-end.gap-hpad.flex-no-grow-no-shrink-auto.flex-wrap",
30+
m(Button, {
31+
label: "updateColorCustomizationNewsButton_label",
32+
click: async () => {
33+
m.route.set("/settings/whitelabel")
34+
acknowledge()
35+
dialog.close()
36+
},
37+
type: ButtonType.Primary,
38+
}),
39+
m(Button, {
40+
label: "close_alt",
41+
click: acknowledge,
42+
type: ButtonType.Secondary,
43+
}),
44+
),
45+
])
46+
}
47+
}

src/common/settings/whitelabel/CustomColorEditor.ts

Lines changed: 14 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,6 @@ import { BaseThemeId } from "../../gui/theme"
1717
export type SimpleCustomColorEditorAttrs = {
1818
model: CustomColorsEditorViewModel
1919
}
20-
export type ColorCategories = {
21-
content: Array<CustomColor>
22-
header: Array<CustomColor>
23-
navigation: Array<CustomColor>
24-
other: Array<CustomColor>
25-
}
2620
export const COLOR_PICKER_WIDTH = 400
2721
export const ADVANCED_TEXTFIELD_WIDTH = 344
2822
export const CATEGORY_WIDTH = 750
@@ -44,33 +38,14 @@ export class CustomColorEditor implements Component<SimpleCustomColorEditorAttrs
4438
label: "accentColor_label",
4539
value: vnode.attrs.model.accentColor,
4640
injectionsRight: () =>
47-
renderColorPicker(
48-
(inputEvent) => {
49-
vnode.attrs.model.changeAccentColor(downcast<HTMLInputElement>(inputEvent.target).value)
50-
m.redraw()
51-
},
52-
vnode.attrs.model.accentColor,
53-
({ dom }) => (this._colorPickerDom = dom as HTMLInputElement),
54-
),
41+
renderColorPicker((inputEvent) => {
42+
vnode.attrs.model.changeAccentColor(downcast<HTMLInputElement>(inputEvent.target).value)
43+
m.redraw()
44+
}, vnode.attrs.model.accentColor),
5545
maxWidth: COLOR_PICKER_WIDTH,
5646
isReadOnly: true,
5747
}
5848

59-
/*
60-
Currently:
61-
Button:
62-
Content:
63-
Elevated:
64-
Header:
65-
List:
66-
Modal:
67-
Navigation:
68-
Then:
69-
Content: Content, List
70-
Header: Header
71-
Navigation: Navigation
72-
Other: Button, Elevated, Modal
73-
*/
7449
return m("", [
7550
m("", [
7651
m(".flex", [
@@ -112,52 +87,23 @@ export class CustomColorEditor implements Component<SimpleCustomColorEditorAttrs
11287
[
11388
m(".small.mt", lang.get("customColorsInfo_msg")),
11489
m(".flex.flex-column", [
115-
Object.entries(this._getGroupedColors(model.customColors)).map(([name, colors]) => {
116-
return m("", [
117-
m(".h4.mt-l", capitalizeFirstLetterOfString(name)),
118-
m(
119-
".editor-border.text-break.wrapping-row",
120-
{
121-
style: {
122-
maxWidth: px(CATEGORY_WIDTH),
123-
},
90+
m("", [
91+
m(
92+
".editor-border.text-break.wrapping-row",
93+
{
94+
style: {
95+
maxWidth: px(CATEGORY_WIDTH),
12496
},
125-
[colors.map((c) => renderCustomColorField(model, c))],
126-
),
127-
])
128-
}),
97+
},
98+
[model.customColors.map((c) => renderCustomColorField(model, c))],
99+
),
100+
]),
129101
]),
130102
],
131103
),
132104
]),
133105
])
134106
}
135-
136-
/**
137-
*
138-
*/
139-
_getGroupedColors(colors: ReadonlyArray<CustomColor>): ColorCategories {
140-
const groupedColors: ColorCategories = {
141-
content: [],
142-
header: [],
143-
navigation: [],
144-
other: [],
145-
}
146-
147-
for (const color of colors) {
148-
if (color.name.startsWith("content") || color.name.startsWith("list")) {
149-
groupedColors.content.push(color)
150-
} else if (color.name.startsWith("header")) {
151-
groupedColors.header.push(color)
152-
} else if (color.name.startsWith("navigation")) {
153-
groupedColors.navigation.push(color)
154-
} else {
155-
groupedColors.other.push(color)
156-
}
157-
}
158-
159-
return groupedColors
160-
}
161107
}
162108

163109
function renderCustomColorField(model: CustomColorsEditorViewModel, { name, value, defaultValue, valid }: CustomColor): Child {

0 commit comments

Comments
 (0)