tutanota/src/common/settings/UserListView.ts
map 2d24bab6f9 MailSet support (static mail listIds)
In order to allow importing of mails we replace legacy MailFolders
(non-static mail listIds) with new MailSets (static mail listIds).
From now on, mails have static mail listIds and static mail elementIds.
To move mails between new MailSets we introduce MailSetEntries
("entries" property on a MailSet), which are index entries sorted by
the received date of the referenced mails (customId). This commit adds
support for new MailSets, while still supporting legacy MailFolders
(mail lists) to support migrating gradually.

* TutanotaModelV74 adds:
  * MailSet support
  * and defaultAlarmList on GroupSettings

* SystemModelV107 adds model changes for counter (unread mails) updates

* Adapt mail list to show MailSet and legacy mails
  The list model is now largely unaware about listIds since it can
  display mails from multiple MailBags. MailBags are static mailLists
  from which a mail is only removed from when the mail is permanently
  deleted.

* Adapt offline storage for mail sets
  Offline storage gained the ability to provide cached entities
  from a list of ids.
2024-08-20 16:19:58 +02:00

357 lines
11 KiB
TypeScript

import m, { Children } from "mithril"
import { NotFoundError } from "../api/common/error/RestError.js"
import { size } from "../gui/size.js"
import type { GroupInfo, User } from "../api/entities/sys/TypeRefs.js"
import { GroupInfoTypeRef, GroupMemberTypeRef } from "../api/entities/sys/TypeRefs.js"
import { contains, LazyLoaded, memoized, noOp } from "@tutao/tutanota-utils"
import { UserViewer } from "./UserViewer.js"
import { FeatureType, GroupType } from "../api/common/TutanotaConstants.js"
import { Icon } from "../gui/base/Icon.js"
import { Icons } from "../gui/base/icons/Icons.js"
import { BootIcons } from "../gui/base/icons/BootIcons.js"
import { compareGroupInfos } from "../api/common/utils/GroupUtils.js"
import { elementIdPart, GENERATED_MAX_ID } from "../api/common/utils/EntityUtils.js"
import { ListColumnWrapper } from "../gui/ListColumnWrapper.js"
import { assertMainOrNode } from "../api/common/Env.js"
import { locator } from "../api/main/CommonLocator.js"
import Stream from "mithril/stream"
import * as AddUserDialog from "./AddUserDialog.js"
import { SelectableRowContainer, SelectableRowSelectedSetter, setVisibility } from "../gui/SelectableRowContainer.js"
import { ListModel } from "../misc/ListModel.js"
import { List, ListAttrs, MultiselectMode, RenderConfig } from "../gui/base/List.js"
import { listSelectionKeyboardShortcuts, VirtualRow } from "../gui/base/ListUtils.js"
import ColumnEmptyMessageBox from "../gui/base/ColumnEmptyMessageBox.js"
import { theme } from "../gui/theme.js"
import { BaseSearchBar, BaseSearchBarAttrs } from "../gui/base/BaseSearchBar.js"
import { IconButton } from "../gui/base/IconButton.js"
import { attachDropdown } from "../gui/base/Dropdown.js"
import { lang } from "../misc/LanguageViewModel.js"
import { keyManager } from "../misc/KeyManager.js"
import { EntityUpdateData, isUpdateFor, isUpdateForTypeRef } from "../api/common/utils/EntityUpdateUtils.js"
import { ListAutoSelectBehavior } from "../misc/DeviceConfig.js"
import { UpdatableSettingsViewer } from "./Interfaces.js"
assertMainOrNode()
/**
* Displays a list with users that are available to manage by the current user.
* Global admins see all users.
* Local admins see only their assigned users.
*/
export class UserListView implements UpdatableSettingsViewer {
private searchQuery: string = ""
private listModel: ListModel<GroupInfo>
private readonly renderConfig: RenderConfig<GroupInfo, UserRow> = {
createElement: (dom) => {
const row = new UserRow((groupInfo) => this.isAdmin(groupInfo))
m.render(dom, row.render())
return row
},
itemHeight: size.list_row_height,
swipe: null,
multiselectionAllowed: MultiselectMode.Disabled,
}
private readonly listId: LazyLoaded<Id>
private adminUserGroupInfoIds: Id[] = []
private listStateSubscription: Stream<unknown> | null = null
private listSelectionSubscription: Stream<unknown> | null = null
constructor(
private readonly updateDetailsViewer: (viewer: UserViewer | null) => unknown,
private readonly focusDetailsViewer: () => unknown,
private readonly canImportUsers: () => boolean,
private readonly onImportUsers: () => unknown,
private readonly onExportUsers: () => unknown,
) {
// doing it after "onSelectionChanged" is initialized
this.listModel = this.makeListModel()
this.listId = new LazyLoaded(async () => {
const customer = await locator.logins.getUserController().loadCustomer()
return customer.userGroups
})
this.listModel.loadInitial()
this.oncreate = this.oncreate.bind(this)
this.onremove = this.onremove.bind(this)
this.view = this.view.bind(this)
}
private readonly shortcuts = listSelectionKeyboardShortcuts(MultiselectMode.Disabled, () => this.listModel)
oncreate() {
keyManager.registerShortcuts(this.shortcuts)
}
view(): Children {
if (locator.logins.isEnabled(FeatureType.WhitelabelChild)) {
return null
}
return m(
ListColumnWrapper,
{
headerContent: m(
".flex.flex-space-between.center-vertically.plr-l",
m(BaseSearchBar, {
text: this.searchQuery,
onInput: (text) => this.updateQuery(text),
busy: false,
onKeyDown: (e) => e.stopPropagation(),
onClear: () => {
this.searchQuery = ""
this.listModel.reapplyFilter()
},
placeholder: lang.get("searchUsers_placeholder"),
} satisfies BaseSearchBarAttrs),
m(
".mr-negative-s",
m(IconButton, {
title: "addUsers_action",
icon: Icons.Add,
click: () => this.addButtonClicked(),
}),
this.renderImportButton(),
),
),
},
this.listModel.isEmptyAndDone()
? m(ColumnEmptyMessageBox, {
color: theme.list_message_bg,
icon: BootIcons.Contacts,
message: "noEntries_msg",
})
: m(List, {
renderConfig: this.renderConfig,
state: this.listModel.state,
onLoadMore: () => this.listModel.loadMore(),
onRetryLoading: () => this.listModel.retryLoading(),
onStopLoading: () => this.listModel.stopLoading(),
onSingleSelection: (item: GroupInfo) => {
this.listModel.onSingleSelection(item)
this.focusDetailsViewer()
},
onSingleTogglingMultiselection: noOp,
onRangeSelectionTowards: noOp,
} satisfies ListAttrs<GroupInfo, UserRow>),
)
}
private renderImportButton() {
if (this.canImportUsers()) {
return m(
IconButton,
attachDropdown({
mainButtonAttrs: {
title: "more_label",
icon: Icons.More,
},
childAttrs: () => [
{
label: "importUsers_action",
click: () => {
this.onImportUsers()
},
},
{
label: "exportUsers_action",
click: () => {
this.onExportUsers()
},
},
],
}),
)
} else {
return null
}
}
onremove() {
keyManager.unregisterShortcuts(this.shortcuts)
this.listStateSubscription?.end(true)
this.listSelectionSubscription?.end(true)
}
private async loadAdmins(): Promise<void> {
const adminGroupMembership = locator.logins.getUserController().user.memberships.find((gm) => gm.groupType === GroupType.Admin)
if (adminGroupMembership == null) {
return
}
const members = await locator.entityClient.loadAll(GroupMemberTypeRef, adminGroupMembership.groupMember[0])
this.adminUserGroupInfoIds = members.map((adminGroupMember) => elementIdPart(adminGroupMember.userGroupInfo))
}
private isAdmin(userGroupInfo: GroupInfo): boolean {
return contains(this.adminUserGroupInfoIds, userGroupInfo._id[1])
}
private addButtonClicked() {
AddUserDialog.show()
}
async entityEventsReceived<T>(updates: ReadonlyArray<EntityUpdateData>): Promise<void> {
for (const update of updates) {
const { instanceListId, instanceId, operation } = update
if (isUpdateForTypeRef(GroupInfoTypeRef, update) && this.listId.getSync() === instanceListId) {
await this.listModel.entityEventReceived(instanceListId, instanceId, operation)
} else if (isUpdateFor(locator.logins.getUserController().user, update)) {
await this.loadAdmins()
this.listModel.reapplyFilter()
}
m.redraw()
}
}
private readonly localAdminGroups = memoized((user: User) => {
return locator.logins
.getUserController()
.getLocalAdminGroupMemberships()
.map((gm) => gm.group)
})
private makeListModel(): ListModel<GroupInfo> {
const listModel = new ListModel<GroupInfo>({
topId: GENERATED_MAX_ID,
sortCompare: compareGroupInfos,
fetch: async (startId) => {
if (startId !== GENERATED_MAX_ID) {
throw new Error("fetch user group infos called for specific start id")
}
await this.loadAdmins()
const listId = await this.listId.getAsync()
const allUserGroupInfos = await locator.entityClient.loadAll(GroupInfoTypeRef, listId)
return { items: allUserGroupInfos, complete: true }
},
loadSingle: async (_listId: Id, elementId: Id) => {
const listId = await this.listId.getAsync()
try {
return await locator.entityClient.load<GroupInfo>(GroupInfoTypeRef, [listId, elementId])
} catch (e) {
if (e instanceof NotFoundError) {
// we return null if the GroupInfo does not exist
return null
} else {
throw e
}
}
},
autoSelectBehavior: () => ListAutoSelectBehavior.OLDER,
})
listModel.setFilter((gi) => this.groupFilter(gi) && this.queryFilter(gi))
this.listStateSubscription?.end(true)
this.listStateSubscription = listModel.stateStream.map((state) => {
m.redraw()
})
this.listSelectionSubscription?.end(true)
this.listSelectionSubscription = listModel.differentItemsSelected.map((newSelection) => {
let detailsViewer: UserViewer | null
if (newSelection.size === 0) {
detailsViewer = null
} else {
const item = newSelection.values().next().value
detailsViewer = new UserViewer(item, this.isAdmin(item))
}
this.updateDetailsViewer(detailsViewer)
m.redraw()
})
return listModel
}
private queryFilter(gi: GroupInfo) {
const lowercaseSearch = this.searchQuery.toLowerCase()
return (
gi.name.toLowerCase().includes(lowercaseSearch) ||
(!!gi.mailAddress && gi.mailAddress?.toLowerCase().includes(lowercaseSearch)) ||
gi.mailAddressAliases.some((mai) => mai.mailAddress.toLowerCase().includes(lowercaseSearch))
)
}
private groupFilter = (gi: GroupInfo) => {
if (locator.logins.getUserController().isGlobalAdmin()) {
return true
} else {
return !!gi.localAdmin && this.localAdminGroups(locator.logins.getUserController().user).includes(gi.localAdmin)
}
}
private updateQuery(query: string) {
this.searchQuery = query
this.listModel.reapplyFilter()
}
}
export class UserRow implements VirtualRow<GroupInfo> {
top: number = 0
domElement: HTMLElement | null = null // set from List
entity: GroupInfo | null = null
private nameDom!: HTMLElement
private addressDom!: HTMLElement
private adminIconDom!: HTMLElement
private deletedIconDom!: HTMLElement
private selectionUpdater!: SelectableRowSelectedSetter
constructor(private readonly isAdmin: (groupInfo: GroupInfo) => boolean) {}
update(groupInfo: GroupInfo, selected: boolean): void {
this.entity = groupInfo
this.selectionUpdater(selected, false)
this.nameDom.textContent = groupInfo.name
this.addressDom.textContent = groupInfo.mailAddress ? groupInfo.mailAddress : ""
setVisibility(this.adminIconDom, this.isAdmin(groupInfo))
setVisibility(this.deletedIconDom, groupInfo.deleted != null)
}
/**
* Only the structure is managed by mithril. We set all contents on our own (see update) in order to avoid the vdom overhead (not negligible on mobiles)
*/
render(): Children {
return m(
SelectableRowContainer,
{
onSelectedChangeRef: (updater) => (this.selectionUpdater = updater),
},
m(".flex.col.flex-grow", [
m(".badge-line-height", [
m("", {
oncreate: (vnode) => (this.nameDom = vnode.dom as HTMLElement),
}),
]),
m(".flex-space-between.mt-xxs", [
m(".smaller", {
oncreate: (vnode) => (this.addressDom = vnode.dom as HTMLElement),
}),
m(".icons.flex", [
m(Icon, {
icon: BootIcons.Settings,
oncreate: (vnode) => (this.adminIconDom = vnode.dom as HTMLElement),
class: "svg-list-accent-fg",
style: {
display: "none",
},
}),
m(Icon, {
icon: Icons.Trash,
oncreate: (vnode) => (this.deletedIconDom = vnode.dom as HTMLElement),
class: "svg-list-accent-fg",
style: {
display: "none",
},
}),
]),
]),
]),
)
}
}