Use AlertCircle icon to indicate verification failures in MailRecipientsTextField

Co-authored-by: bed <bed@tutao.de>
This commit is contained in:
mab 2025-02-26 10:54:40 +01:00
parent d1acc25aab
commit 1be2f61578
9 changed files with 90 additions and 67 deletions

View file

@ -1,8 +1,8 @@
import m, { Children, ClassComponent, Vnode } from "mithril" import m, { Children, ClassComponent, Vnode } from "mithril"
import { BubbleTextField } from "./base/BubbleTextField.js" import { BubbleTextField, BubbleTextFieldAttrs } from "./base/BubbleTextField.js"
import { Recipient } from "../api/common/recipients/Recipient.js" import { Recipient } from "../api/common/recipients/Recipient.js"
import { px, size } from "./size.js" import { px, size } from "./size.js"
import { Icon, progressIcon } from "./base/Icon.js" import { Icon, IconSize, progressIcon } from "./base/Icon.js"
import { lang, TranslationKey } from "../misc/LanguageViewModel.js" import { lang, TranslationKey } from "../misc/LanguageViewModel.js"
import { stringToNameAndMailAddress } from "../misc/parsing/MailAddressParser.js" import { stringToNameAndMailAddress } from "../misc/parsing/MailAddressParser.js"
import { DropdownChildAttrs } from "./base/Dropdown.js" import { DropdownChildAttrs } from "./base/Dropdown.js"
@ -11,11 +11,11 @@ import { RecipientsSearchModel } from "../misc/RecipientsSearchModel.js"
import { getFirstOrThrow, lazy } from "@tutao/tutanota-utils" import { getFirstOrThrow, lazy } from "@tutao/tutanota-utils"
import { Dialog } from "./base/Dialog.js" import { Dialog } from "./base/Dialog.js"
import { SearchDropDown } from "./SearchDropDown.js" import { SearchDropDown } from "./SearchDropDown.js"
import { findRecipientWithAddress } from "../api/common/utils/CommonCalendarUtils.js"
import { Icons } from "./base/icons/Icons.js" import { Icons } from "./base/icons/Icons.js"
import { theme } from "./theme.js" import { theme } from "./theme.js"
import { getMailAddressDisplayText } from "../mailFunctionality/SharedMailUtils.js" import { getMailAddressDisplayText } from "../mailFunctionality/SharedMailUtils.js"
import { KeyVerificationState } from "../api/worker/facades/lazy/KeyVerificationFacade" import { KeyVerificationState } from "../api/worker/facades/lazy/KeyVerificationFacade"
import { WARNING_RED } from "./builtinThemes"
export interface MailRecipientsTextFieldAttrs { export interface MailRecipientsTextFieldAttrs {
label: TranslationKey label: TranslationKey
@ -47,7 +47,7 @@ export class MailRecipientsTextField implements ClassComponent<MailRecipientsTex
} }
private renderTextField(attrs: MailRecipientsTextFieldAttrs): Children { private renderTextField(attrs: MailRecipientsTextFieldAttrs): Children {
return m(BubbleTextField, { const bubbleTextFieldAttrs: BubbleTextFieldAttrs<Recipient> = {
label: attrs.label, label: attrs.label,
text: attrs.text, text: attrs.text,
helpLabel: attrs.helpLabel, helpLabel: attrs.helpLabel,
@ -74,23 +74,41 @@ export class MailRecipientsTextField implements ClassComponent<MailRecipientsTex
attrs.onTextChanged(remainingText) attrs.onTextChanged(remainingText)
} }
}, },
items: attrs.recipients.map((recipient) => recipient.address), items: attrs.recipients,
renderBubbleText: (address: string) => { getBubbleIcon: (recipient: Recipient) => {
const recipient = findRecipientWithAddress(attrs.recipients, address) if (recipient.verificationState === KeyVerificationState.MISMATCH) {
if (recipient == null) { return m(Icon, {
return lang.makeTranslation(address, getMailAddressDisplayText(null, address, false)) icon: Icons.AlertCircle,
size: IconSize.Large, // we want 20px
style: {
fill: WARNING_RED,
position: "relative",
top: "4px",
right: "1px",
},
})
} else if (recipient.verificationState === KeyVerificationState.VERIFIED) {
return m(Icon, {
icon: Icons.Shield,
size: IconSize.Normal,
style: {
fill: theme.content_accent,
position: "relative",
top: "2px",
right: "1px",
},
})
} else { } else {
const name = recipient.name return null
let verified: string = ""
if (recipient.verificationState === KeyVerificationState.MISMATCH) {
verified = " ✘"
} else if (recipient.verificationState === KeyVerificationState.VERIFIED) {
verified = " ✔"
}
return lang.makeTranslation(address, getMailAddressDisplayText(name, address, false) + verified)
} }
}, },
getBubbleDropdownAttrs: async (address) => (await attrs.getRecipientClickedDropdownAttrs?.(address)) ?? [], renderBubbleText: (recipient: Recipient) => {
const name = recipient.name
let verified: string = ""
return lang.makeTranslation(recipient.address, getMailAddressDisplayText(name, recipient.address, false) + verified)
},
getBubbleDropdownAttrs: async (recipient) => (await attrs.getRecipientClickedDropdownAttrs?.(recipient.address)) ?? [],
onBackspace: () => { onBackspace: () => {
if (attrs.text === "" && attrs.recipients.length > 0) { if (attrs.text === "" && attrs.recipients.length > 0) {
const { address } = attrs.recipients.slice().pop()! const { address } = attrs.recipients.slice().pop()!
@ -136,7 +154,9 @@ export class MailRecipientsTextField implements ClassComponent<MailRecipientsTex
), ),
attrs.injectionsRight, attrs.injectionsRight,
]), ]),
}) }
return m(BubbleTextField, bubbleTextFieldAttrs)
} }
private renderSuggestions(attrs: MailRecipientsTextFieldAttrs): Children { private renderSuggestions(attrs: MailRecipientsTextFieldAttrs): Children {

View file

@ -1,16 +1,17 @@
import m, { Children, ClassComponent, Vnode } from "mithril" import m, { Children, ClassComponent, Vnode } from "mithril"
import { Autocomplete, TextField, TextFieldType } from "./TextField.js" import { Autocomplete, TextField, TextFieldType } from "./TextField.js"
import { Translation, MaybeTranslation } from "../../misc/LanguageViewModel" import { MaybeTranslation, Translation } from "../../misc/LanguageViewModel"
import { Keys } from "../../api/common/TutanotaConstants" import { Keys } from "../../api/common/TutanotaConstants"
import { createAsyncDropdown, DropdownChildAttrs } from "./Dropdown.js" import { createAsyncDropdown, DropdownChildAttrs } from "./Dropdown.js"
import { lazy } from "@tutao/tutanota-utils" import { lazy } from "@tutao/tutanota-utils"
import { BaseButton } from "./buttons/BaseButton.js" import { BaseButton, BaseButtonAttrs } from "./buttons/BaseButton.js"
export interface BubbleTextFieldAttrs { export interface BubbleTextFieldAttrs<T> {
label: MaybeTranslation label: MaybeTranslation
items: Array<string> items: ReadonlyArray<T>
renderBubbleText: (item: string) => Translation renderBubbleText: (item: T) => Translation
getBubbleDropdownAttrs: (item: string) => Promise<DropdownChildAttrs[]> getBubbleIcon: (item: T) => Children
getBubbleDropdownAttrs: (item: T) => Promise<DropdownChildAttrs[]>
text: string text: string
onInput: (text: string) => void onInput: (text: string) => void
onBackspace: () => boolean onBackspace: () => boolean
@ -24,11 +25,11 @@ export interface BubbleTextFieldAttrs {
helpLabel?: lazy<Children> | null helpLabel?: lazy<Children> | null
} }
export class BubbleTextField implements ClassComponent<BubbleTextFieldAttrs> { export class BubbleTextField<T> implements ClassComponent<BubbleTextFieldAttrs<T>> {
private active: boolean = false private active: boolean = false
private domInput: HTMLInputElement | null = null private domInput: HTMLInputElement | null = null
view({ attrs }: Vnode<BubbleTextFieldAttrs>) { view({ attrs }: Vnode<BubbleTextFieldAttrs<T>>) {
return m(".bubble-text-field", [ return m(".bubble-text-field", [
m(TextField, { m(TextField, {
label: attrs.label, label: attrs.label,
@ -43,25 +44,29 @@ export class BubbleTextField implements ClassComponent<BubbleTextFieldAttrs> {
// We need overflow: hidden on both so that ellipsis on button works. // We need overflow: hidden on both so that ellipsis on button works.
// flex is for reserving space for the comma. align-items: end so that comma is pushed to the bottom. // flex is for reserving space for the comma. align-items: end so that comma is pushed to the bottom.
const bubbleText = attrs.renderBubbleText(item) const bubbleText = attrs.renderBubbleText(item)
const bubbleIcon = attrs.getBubbleIcon(item)
const baseButtonAttrs: BaseButtonAttrs = {
label: bubbleText,
text: bubbleText.text,
class: "text-bubble button-content content-fg text-ellipsis flash",
style: {
"max-width": "100%",
},
onclick: (e: MouseEvent) => {
e.stopPropagation() // do not focus the text field
createAsyncDropdown({
lazyButtons: () => attrs.getBubbleDropdownAttrs(item),
width: 250,
})(e, e.target as HTMLElement)
},
}
if (bubbleIcon) {
baseButtonAttrs.icon = bubbleIcon
}
return m(".flex.overflow-hidden.items-end", [ return m(".flex.overflow-hidden.items-end", [
m( m(".flex-no-grow-shrink-auto.overflow-hidden", m(BaseButton, baseButtonAttrs)),
".flex-no-grow-shrink-auto.overflow-hidden",
m(BaseButton, {
label: bubbleText,
text: bubbleText.text,
class: "text-bubble button-content content-fg text-ellipsis flash",
style: {
"max-width": "100%",
},
onclick: (e: MouseEvent) => {
e.stopPropagation() // do not focus the text field
createAsyncDropdown({
lazyButtons: () => attrs.getBubbleDropdownAttrs(item),
width: 250,
})(e, e.target as HTMLElement)
},
}),
),
// Comma is shown when there's text/another bubble afterwards or if the field is active // Comma is shown when there's text/another bubble afterwards or if the field is active
this.active || idx < items.length - 1 || attrs.text !== "" ? m("span.pr", ",") : null, this.active || idx < items.length - 1 || attrs.text !== "" ? m("span.pr", ",") : null,
]) ])

View file

@ -10,8 +10,7 @@ import type { lazy } from "@tutao/tutanota-utils"
import { isNotNull } from "@tutao/tutanota-utils" import { isNotNull } from "@tutao/tutanota-utils"
import { Icons } from "./icons/Icons.js" import { Icons } from "./icons/Icons.js"
import { px, size } from "../size.js" import { px, size } from "../size.js"
import { WARNING_RED } from "../builtinThemes"
const WARNING_RED = "#ca0606"
export const enum BannerType { export const enum BannerType {
Info = "info", Info = "info",

View file

@ -108,6 +108,7 @@ export const enum Icons {
SpeechBubbleFill = "SpeechBubbleFill", SpeechBubbleFill = "SpeechBubbleFill",
KeyboardFill = "KeyboardFill", KeyboardFill = "KeyboardFill",
Shield = "Shield", Shield = "Shield",
AlertCircle = "AlertCircle",
} }
export const IconsSvg: Record<Icons, string> = Object.freeze({ export const IconsSvg: Record<Icons, string> = Object.freeze({
@ -220,6 +221,7 @@ export const IconsSvg: Record<Icons, string> = Object.freeze({
SpeechBubbleFill: `<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 26 26"><path d="M23.34 10.932a10.28 10.28 0 0 0-3.62-5.844 10.66 10.66 0 0 0-6.665-2.336c-2.855 0-5.522 1.1-7.516 3.098-1.927 1.933-2.984 4.485-2.975 7.185 0 1.979.581 3.915 1.671 5.567l.204.283-1.125 4.867 5.381-1.339s.108.036.188.067c.08.03.765.293 1.493.496.604.17 1.862.422 2.848.422 2.796 0 5.407-1.082 7.352-3.05a10.33 10.33 0 0 0 2.988-7.308c0-.708-.075-1.415-.224-2.108m-14.776 3.82a1.5 1.5 0 1 1 0-2.999 1.5 1.5 0 0 1 0 2.999m4.5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m4.5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3"/></svg>`, SpeechBubbleFill: `<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 26 26"><path d="M23.34 10.932a10.28 10.28 0 0 0-3.62-5.844 10.66 10.66 0 0 0-6.665-2.336c-2.855 0-5.522 1.1-7.516 3.098-1.927 1.933-2.984 4.485-2.975 7.185 0 1.979.581 3.915 1.671 5.567l.204.283-1.125 4.867 5.381-1.339s.108.036.188.067c.08.03.765.293 1.493.496.604.17 1.862.422 2.848.422 2.796 0 5.407-1.082 7.352-3.05a10.33 10.33 0 0 0 2.988-7.308c0-.708-.075-1.415-.224-2.108m-14.776 3.82a1.5 1.5 0 1 1 0-2.999 1.5 1.5 0 0 1 0 2.999m4.5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m4.5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3"/></svg>`,
KeyboardFill: `<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 26 26"><path fill-rule="evenodd" d="M23.564 20.255v-15a3 3 0 0 0-3-3h-15a3 3 0 0 0-3 3v15a3 3 0 0 0 3 3h15a3 3 0 0 0 3-3m-8-4v3h-5v-3h-4l6.5-6 6.5 6zm-9-8h13v-2h-13z" clip-rule="evenodd"/></svg>`, KeyboardFill: `<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 26 26"><path fill-rule="evenodd" d="M23.564 20.255v-15a3 3 0 0 0-3-3h-15a3 3 0 0 0-3 3v15a3 3 0 0 0 3 3h15a3 3 0 0 0 3-3m-8-4v3h-5v-3h-4l6.5-6 6.5 6zm-9-8h13v-2h-13z" clip-rule="evenodd"/></svg>`,
Shield: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="22" viewBox="0 0 20 22"><path d="m19.959 4.55-.035-.56-.552-.098c-3.862-.693-5.474-1.194-9.078-2.822L10 .937l-.294.133C6.102 2.698 4.49 3.2.628 3.892l-.552.099-.035.559c-.172 2.728.195 5.27 1.09 7.556a15.6 15.6 0 0 0 3.2 5.023c2.386 2.532 4.92 3.632 5.404 3.827l.268.108.268-.108c.483-.195 3.018-1.295 5.405-3.827a15.6 15.6 0 0 0 3.192-5.023c.896-2.286 1.263-4.828 1.09-7.556M8.668 14.193l-3.25-3.186 1-1.02 2.164 2.12L13.5 6.428l1.08.935z"/></svg>`, Shield: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="22" viewBox="0 0 20 22"><path d="m19.959 4.55-.035-.56-.552-.098c-3.862-.693-5.474-1.194-9.078-2.822L10 .937l-.294.133C6.102 2.698 4.49 3.2.628 3.892l-.552.099-.035.559c-.172 2.728.195 5.27 1.09 7.556a15.6 15.6 0 0 0 3.2 5.023c2.386 2.532 4.92 3.632 5.404 3.827l.268.108.268-.108c.483-.195 3.018-1.295 5.405-3.827a15.6 15.6 0 0 0 3.192-5.023c.896-2.286 1.263-4.828 1.09-7.556M8.668 14.193l-3.25-3.186 1-1.02 2.164 2.12L13.5 6.428l1.08.935z"/></svg>`,
AlertCircle: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 2.25c-5.376 0-9.75 4.374-9.75 9.75s4.374 9.75 9.75 9.75 9.75-4.374 9.75-9.75S17.376 2.25 12 2.25m.938 14.996h-1.876V15.37h1.876zm-.188-2.996h-1.5l-.281-7.5h2.062z"/></svg>`,
} as const) } as const)
export const SecondFactorImage = export const SecondFactorImage =

View file

@ -8,6 +8,8 @@ import { client } from "../misc/ClientDetector.js"
assertMainOrNodeBoot() assertMainOrNodeBoot()
export const WARNING_RED = "#ca0606"
/** /**
* light theme background * light theme background
*/ */

View file

@ -49,7 +49,7 @@ export class KeyManagementSettingsViewer implements UpdatableSettingsViewer {
} }
async entityEventsReceived(updates: ReadonlyArray<EntityUpdateData>): Promise<void> { async entityEventsReceived(updates: ReadonlyArray<EntityUpdateData>): Promise<void> {
m.redraw() // noop operation because key trusted entities are not stored only locally and will not trigger an entity event.
return Promise.resolve() return Promise.resolve()
} }
@ -62,7 +62,7 @@ export class KeyManagementSettingsViewer implements UpdatableSettingsViewer {
const obj: KeyManagementSettingsViewer = this const obj: KeyManagementSettingsViewer = this
const selfMailAddress = assertNotNull(this.mailAddress) const selfMailAddress = assertNotNull(this.mailAddress)
const selfFingerprint = assertNotNull(this.publicKeyHash) const selfFingerprint = this.publicKeyHash
const addressRows = Array.from(this.trustedIdentities.entries()).map(([mailAddress, publicKeyFingerprint]: [string, PublicKeyFingerprint]) => { const addressRows = Array.from(this.trustedIdentities.entries()).map(([mailAddress, publicKeyFingerprint]: [string, PublicKeyFingerprint]) => {
return m(FingerprintRow, { return m(FingerprintRow, {
@ -100,7 +100,7 @@ export class KeyManagementSettingsViewer implements UpdatableSettingsViewer {
title: lang.get("keyManagement.keyVerification_label"), title: lang.get("keyManagement.keyVerification_label"),
subTitle: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.", subTitle: "Lorem ipsum dolor sit amet, consetetur sadipscing elitr.",
}), }),
this.renderQrTextMethod(selfMailAddress, selfFingerprint), selfFingerprint ? this.renderQrTextMethod(selfMailAddress, selfFingerprint) : null,
m(".small.text-break.text-center.mb-l", lang.get("keyManagement.publicKeyFingerprintQrInfo_msg")), m(".small.text-break.text-center.mb-l", lang.get("keyManagement.publicKeyFingerprintQrInfo_msg")),
m(MenuTitle, { content: lang.get("keyManagement.verificationPool_label") }), m(MenuTitle, { content: lang.get("keyManagement.verificationPool_label") }),

View file

@ -40,17 +40,6 @@ export async function showKeyVerificationWizard(
const usageTest = usageTestController.getTest("crypto.keyVerification") const usageTest = usageTestController.getTest("crypto.keyVerification")
// const stage = usageTest.getStage(0) // const stage = usageTest.getStage(0)
// await completeStageNow(stage) // await completeStageNow(stage)
//
const data: KeyVerificationWizardData = {
keyVerificationFacade: keyVerificationFacade,
mobileSystemFacade: mobileSystemFacade,
method: KeyVerificationMethodType.text, // will be overwritten by the wizard
reloadParent: reloadParent, // will be called after a key has been pinned
mailAddress: "",
publicKeyFingerprint: null,
result: null,
usageTest: usageTest,
}
const model = new KeyVerificationModel(keyVerificationFacade, mobileSystemFacade) const model = new KeyVerificationModel(keyVerificationFacade, mobileSystemFacade)
const multiPageDialog: Dialog = new MultiPageDialog<KeyVerificationWizardPages>(KeyVerificationWizardPages.CHOOSE_METHOD) const multiPageDialog: Dialog = new MultiPageDialog<KeyVerificationWizardPages>(KeyVerificationWizardPages.CHOOSE_METHOD)
@ -67,12 +56,18 @@ export async function showKeyVerificationWizard(
case KeyVerificationWizardPages.BY_TEXT_INPUT_METHOD: // TODO: rename to EMAIL INPUT METHOD? case KeyVerificationWizardPages.BY_TEXT_INPUT_METHOD: // TODO: rename to EMAIL INPUT METHOD?
return m(VerificationByTextPage, { return m(VerificationByTextPage, {
model, model,
goToSuccessPage: () => navigateToPage(KeyVerificationWizardPages.SUCCESS), goToSuccessPage: () => {
reloadParent()
navigateToPage(KeyVerificationWizardPages.SUCCESS)
},
}) })
case KeyVerificationWizardPages.BY_QR_CODE_INPUT_METHOD: case KeyVerificationWizardPages.BY_QR_CODE_INPUT_METHOD:
return m(VerificationByQrCodePage, { return m(VerificationByQrCodePage, {
model, model,
goToSuccessPage: () => navigateToPage(KeyVerificationWizardPages.SUCCESS), goToSuccessPage: () => {
reloadParent()
navigateToPage(KeyVerificationWizardPages.SUCCESS)
},
}) })
case KeyVerificationWizardPages.SUCCESS: case KeyVerificationWizardPages.SUCCESS:
return m(VerificationResultPage, { return m(VerificationResultPage, {

View file

@ -60,8 +60,8 @@ export class VerificationByQrCodePage implements Component<VerificationByQrCodeP
".align-self-center.full-width", ".align-self-center.full-width",
m(LoginButton, { m(LoginButton, {
label: markAsVerifiedTranslationKey, label: markAsVerifiedTranslationKey,
onclick: () => { onclick: async () => {
model.trust() await model.trust()
goToSuccessPage() goToSuccessPage()
}, },
disabled: model.result !== KeyVerificationResultType.QR_OK, disabled: model.result !== KeyVerificationResultType.QR_OK,

View file

@ -81,8 +81,8 @@ export class VerificationByTextPage implements Component<VerificationByTextPageA
".align-self-center.full-width", ".align-self-center.full-width",
m(LoginButton, { m(LoginButton, {
label: markAsVerifiedTranslationKey, label: markAsVerifiedTranslationKey,
onclick: () => { onclick: async () => {
model.trust() await model.trust()
goToSuccessPage() goToSuccessPage()
}, // TODO also go to results page }, // TODO also go to results page
disabled: isFingerprintMissing(model), disabled: isFingerprintMissing(model),