Skip to content

Commit 681cd6a

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 5b2418b commit 681cd6a

File tree

15 files changed

+264
-109
lines changed

15 files changed

+264
-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"
@@ -70,6 +70,46 @@ export class ThemeController {
7070
}
7171
}
7272

73+
/**
74+
* Color token mapper from the old to the new.
75+
* If there are colors explicitly defined for the new tokens, they will be respected and the mapped colors will be overwritten.
76+
* 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.
77+
* This mapper could be removed after all users who have whitelabel color customization have migrated to the new color tokens.
78+
*/
79+
public static mapOldToNewColorTokens(customizations: Partial<ThemeCustomizations>): ThemeCustomizations {
80+
let mappedCustomizations: Record<string, string> = {}
81+
for (const [oldToken, hex] of Object.entries(customizations)) {
82+
if (!oldToken || !hex) continue
83+
const newToken: string | undefined = oldToNewColorTokenMap[oldToken]
84+
if (newToken) {
85+
mappedCustomizations[newToken] = hex
86+
}
87+
mappedCustomizations[oldToken] = hex
88+
}
89+
return downcast(mappedCustomizations)
90+
}
91+
92+
/**
93+
* Color token mapper from the new to the old.
94+
* This mapper could be removed after all users who have whitelabel color customization have migrated to the new color tokens.
95+
*/
96+
public static mapNewToOldColorTokens(customizations: Partial<ThemeCustomizations>): ThemeCustomizations {
97+
let mappedCustomizations: Record<string, string> = {}
98+
99+
for (const [newToken, hex] of Object.entries(customizations)) {
100+
if (!newToken || !hex) continue
101+
const mappedOldTokens: string[] | undefined = newToOldColorTokenMap[newToken]
102+
if (mappedOldTokens) {
103+
for (const oldToken of mappedOldTokens) {
104+
mappedCustomizations[oldToken] = hex
105+
}
106+
}
107+
mappedCustomizations[newToken] = hex
108+
}
109+
110+
return downcast(mappedCustomizations)
111+
}
112+
73113
/**
74114
* Determines if the current theme is a light theme.
75115
*
@@ -181,7 +221,7 @@ export class ThemeController {
181221
* Apply the custom theme, if permanent === true, then the new theme will be saved
182222
*/
183223
async applyCustomizations(customizations: ThemeCustomizations, permanent: boolean = true): Promise<Theme> {
184-
const updatedTheme = this.assembleTheme(customizations)
224+
const updatedTheme = this.assembleTheme(ThemeController.mapOldToNewColorTokens(customizations))
185225
// Set no logo until we sanitize it.
186226
const filledWithoutLogo = Object.assign({}, updatedTheme, {
187227
logo: "",
@@ -329,3 +369,53 @@ export class WebThemeFacade implements ThemeFacade {
329369
this.mediaQuery?.addEventListener("change", listener)
330370
}
331371
}
372+
373+
const oldToNewColorTokenMap: Record<string, string> = {
374+
button_bubble_bg: "secondary",
375+
button_bubble_fg: "on_secondary",
376+
content_bg: "surface",
377+
content_fg: "on_surface",
378+
content_button: "on_surface_variant",
379+
content_button_selected: "primary",
380+
content_button_icon: "on_primary",
381+
content_button_icon_selected: "on_primary",
382+
content_accent: "primary",
383+
content_border: "outline",
384+
content_message_bg: "on_surface_fade",
385+
header_bg: "surface",
386+
header_box_shadow_bg: "outline",
387+
header_button: "on_surface_variant",
388+
header_button_selected: "primary",
389+
list_bg: "surface",
390+
list_alternate_bg: "surface_container",
391+
list_accent_fg: "primary",
392+
list_message_bg: "on_surface_fade",
393+
list_border: "outline_variant",
394+
modal_bg: "scrim",
395+
elevated_bg: "surface",
396+
navigation_bg: "surface_container",
397+
navigation_border: "outline_variant",
398+
navigation_button: "on_surface_variant",
399+
navigation_button_icon: "on_primary",
400+
navigation_button_selected: "primary",
401+
navigation_button_icon_selected: "on_primary",
402+
navigation_menu_bg: "secondary",
403+
navigation_menu_icon: "on_secondary",
404+
error_color: "error",
405+
} as const
406+
407+
const newToOldColorTokenMap: Record<string, string[]> = {
408+
secondary: ["button_bubble_bg", "navigation_menu_bg"],
409+
on_secondary: ["button_bubble_fg", "navigation_menu_icon"],
410+
surface: ["content_bg", "header_bg", "list_bg", "elevated_bg"],
411+
on_surface: ["content_fg"],
412+
on_surface_variant: ["content_button", "header_button", "navigation_button"],
413+
primary: ["content_accent", "content_button_selected", "header_button_selected", "list_accent_fg", "navigation_button_selected"],
414+
on_primary: ["content_button_icon", "content_button_icon_selected", "navigation_button_icon", "navigation_button_icon_selected"],
415+
outline: ["content_border", "header_box_shadow_bg"],
416+
on_surface_fade: ["content_message_bg", "list_message_bg"],
417+
surface_container: ["list_alternate_bg", "navigation_bg"],
418+
outline_variant: ["list_border", "navigation_border"],
419+
scrim: ["modal_bg"],
420+
error: ["error_color"],
421+
} as const

src/common/misc/TranslationKey.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2012,3 +2012,6 @@ export type TranslationKeyType =
20122012
| "zoomIn_action"
20132013
| "zoomOut_action"
20142014
| "emptyString_msg"
2015+
| "updateColorCustomizationNews_title"
2016+
| "updateColorCustomizationNews_msg"
2017+
| "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)