From b796217cd1a9ffeb2eceeb7ad6c90411dea6cf36 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Wed, 5 Aug 2020 15:27:08 +0300 Subject: [PATCH] Implement contacts editing. --- src/Contacts/Main.tsx | 225 +++++++++++++++++++++++ src/PageNotFound.tsx | 14 ++ src/Pim/helpers.tsx | 60 ++++++ src/SyncGate.tsx | 16 +- src/components/ContactEdit.tsx | 10 +- src/components/SearchableAddressBook.tsx | 7 +- src/pim-types.ts | 11 ++ 7 files changed, 320 insertions(+), 23 deletions(-) create mode 100644 src/Contacts/Main.tsx create mode 100644 src/PageNotFound.tsx create mode 100644 src/Pim/helpers.tsx diff --git a/src/Contacts/Main.tsx b/src/Contacts/Main.tsx new file mode 100644 index 0000000..adc0e61 --- /dev/null +++ b/src/Contacts/Main.tsx @@ -0,0 +1,225 @@ +// SPDX-FileCopyrightText: © 2020 EteSync Authors +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from "react"; +import { Switch, Route, useHistory } from "react-router"; + +import * as Etebase from "etebase"; + +import { Button, useTheme } from "@material-ui/core"; +import IconEdit from "@material-ui/icons/Edit"; +import IconChangeHistory from "@material-ui/icons/ChangeHistory"; + +import { ContactType, PimType } from "../pim-types"; +import { useCredentials } from "../credentials"; +import { useItems, useCollections, getCollectionManager } from "../etebase-helpers"; +import { routeResolver } from "../App"; +import SearchableAddressBook from "../components/SearchableAddressBook"; +import Contact from "../components/Contact"; +import LoadingIndicator from "../widgets/LoadingIndicator"; +import ContactEdit from "../components/ContactEdit"; +import PageNotFound from "../PageNotFound"; + +import { CachedCollection, getItemNavigationUid, getDecryptCollectionsFunction, getDecryptItemsFunction } from "../Pim/helpers"; + +const colType = "etebase.vcard"; + +const decryptCollections = getDecryptCollectionsFunction(colType); +const decryptItems = getDecryptItemsFunction(colType, ContactType.parse); + +export default function ContactsMain() { + const [entries, setEntries] = React.useState>>(); + const [cachedCollections, setCachedCollections] = React.useState(); + const theme = useTheme(); + const history = useHistory(); + const etebase = useCredentials()!; + const collections = useCollections(etebase, colType); + const items = useItems(etebase, colType); + + React.useEffect(() => { + if (items) { + decryptItems(items) + .then((entries) => setEntries(entries)); + // FIXME: handle failure to decrypt items + } + if (collections) { + decryptCollections(collections) + .then((entries) => setCachedCollections(entries)); + // FIXME: handle failure to decrypt collections + } + }, [items, collections]); + + if (!entries || !cachedCollections) { + return ( + + ); + } + + async function onItemSave(item: PimType, collectionUid: string, originalItem?: PimType): Promise { + const itemUid = originalItem?.itemUid; + const colMgr = getCollectionManager(etebase); + const collection = collections!.find((x) => x.uid === collectionUid)!; + const itemMgr = colMgr.getItemManager(collection); + + const mtime = (new Date()).getUTCMilliseconds(); + const content = item.toIcal(); + + let eteItem; + if (itemUid) { + // Existing item + eteItem = items!.get(collectionUid)?.get(itemUid)!; + await eteItem.setContent(content); + const meta = await eteItem.getMeta(); + meta.mtime = mtime; + await eteItem.setMeta(meta); + } else { + // New + const meta: Etebase.CollectionItemMetadata = { + mtime, + name: item.uid, + }; + eteItem = await itemMgr.create(meta, content); + } + + await itemMgr.batch([eteItem]); + } + + async function onItemDelete(item: PimType, collectionUid: string) { + const itemUid = item.itemUid!; + const colMgr = getCollectionManager(etebase); + const collection = collections!.find((x) => x.uid === collectionUid)!; + const itemMgr = colMgr.getItemManager(collection); + + const eteItem = items!.get(collectionUid)?.get(itemUid)!; + const mtime = (new Date()).getUTCMilliseconds(); + const meta = await eteItem.getMeta(); + meta.mtime = mtime; + await eteItem.setMeta(meta); + await eteItem.delete(); + await itemMgr.batch([eteItem]); + + history.push(routeResolver.getRoute("pim.contacts")); + } + + function onCancel() { + history.goBack(); + } + + const flatEntries = []; + for (const col of entries.values()) { + for (const item of col.values()) { + flatEntries.push(item); + } + } + + const styles = { + button: { + marginLeft: theme.spacing(1), + }, + leftIcon: { + marginRight: theme.spacing(1), + }, + }; + + return ( + + + history.push( + routeResolver.getRoute("pim.contacts._id", { itemUid: getItemNavigationUid(item) }) + )} + /> + + + + + { + const [colUid, itemUid] = match.params.itemUid.split("|"); + const item = entries.get(colUid)?.get(itemUid); + if (!item) { + return (); + } + + /* FIXME: + const collection = collections!.find((x) => x.uid === colUid)!; + const readOnly = collection.accessLevel; + */ + const readOnly = false; + + return ( + + + + + +

Not currently implemented.

+
+ +
+ + + + +
+ +
+
+ ); + }} + /> +
+ ); +} + diff --git a/src/PageNotFound.tsx b/src/PageNotFound.tsx new file mode 100644 index 0000000..2e684de --- /dev/null +++ b/src/PageNotFound.tsx @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: © 2020 EteSync Authors +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from "react"; + +import Container from "./widgets/Container"; + +export default function PageNotFound() { + return ( + +

404 Page Not Found

+
+ ); +} diff --git a/src/Pim/helpers.tsx b/src/Pim/helpers.tsx new file mode 100644 index 0000000..3ba0ed1 --- /dev/null +++ b/src/Pim/helpers.tsx @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: © 2020 EteSync Authors +// SPDX-License-Identifier: AGPL-3.0-only + +import memoize from "memoizee"; + +import * as Etebase from "etebase"; + +import { PimType } from "../pim-types"; + +export interface CachedCollection { + collection: Etebase.Collection; + metadata: Etebase.CollectionMetadata; +} + +export function getItemNavigationUid(item: PimType) { + // Both collectionUid and itemUid are url safe + return `${item.collectionUid}|${item.itemUid}`; +} + +export function getDecryptCollectionsFunction(_colType: string) { + return memoize( + async function (collections: Etebase.Collection[]) { + const entries: CachedCollection[] = []; + if (collections) { + for (const collection of collections) { + entries.push({ + collection, + metadata: await collection.getMeta(), + }); + } + } + + return entries; + }, + { max: 1 } + ); +} + +export function getDecryptItemsFunction(_colType: string, parseFunc: (str: string) => T) { + return memoize( + async function (items: Map>) { + const entries: Map> = new Map(); + if (items) { + for (const [colUid, col] of items.entries()) { + const cur = new Map(); + entries.set(colUid, cur); + for (const item of col.values()) { + const contact = parseFunc(await item.getContent(Etebase.OutputFormat.String)); + contact.collectionUid = colUid; + contact.itemUid = item.uid; + cur.set(item.uid, contact); + } + } + } + + return entries; + }, + { max: 1 } + ); +} diff --git a/src/SyncGate.tsx b/src/SyncGate.tsx index e6a20e7..bb2c960 100644 --- a/src/SyncGate.tsx +++ b/src/SyncGate.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { useSelector, useDispatch } from "react-redux"; -import { Route, Switch, Redirect, withRouter, useHistory } from "react-router"; +import { Route, Switch, Redirect, useHistory } from "react-router"; import moment from "moment"; import "moment/locale/en-gb"; @@ -14,12 +14,11 @@ import { routeResolver } from "./App"; import AppBarOverride from "./widgets/AppBarOverride"; import LoadingIndicator from "./widgets/LoadingIndicator"; -import SearchableAddressBook from "./components/SearchableAddressBook"; +import ContactsMain from "./Contacts/Main"; import Journals from "./Journals"; import Settings from "./Settings"; import Debug from "./Debug"; -import Pim from "./Pim"; import * as EteSync from "etesync"; @@ -38,8 +37,6 @@ export interface SyncInfoJournal { export type SyncInfo = Map; -const PimRouter = withRouter(Pim); - export default function SyncGate() { const etebase = useCredentials(); const settings = useSelector((state: StoreState) => state.settings); @@ -68,6 +65,7 @@ export default function SyncGate() { // FIXME: Shouldn't be here moment.locale(settings.locale); + // FIXME: remove const etesync = etebase as any; return ( @@ -83,12 +81,6 @@ export default function SyncGate() { path={routeResolver.getRoute("pim")} > - - 1} /> + { }; interface PropsType { - collections: EteSync.CollectionInfo[]; + collections: CachedCollection[]; initialCollection?: string; item?: ContactType; onSave: (contact: ContactType, journalUid: string, originalContact?: ContactType) => Promise; @@ -229,7 +229,7 @@ class ContactEdit extends React.PureComponent { if (props.initialCollection) { this.state.journalUid = props.initialCollection; } else if (props.collections[0]) { - this.state.journalUid = props.collections[0].uid; + this.state.journalUid = props.collections[0].collection.uid; } this.onSubmit = this.onSubmit.bind(this); this.handleChange = this.handleChange.bind(this); @@ -403,7 +403,7 @@ class ContactEdit extends React.PureComponent { onChange={this.handleInputChange} > {this.props.collections.map((x) => ( - {x.displayName} + {x.metadata.name} ))} diff --git a/src/components/SearchableAddressBook.tsx b/src/components/SearchableAddressBook.tsx index f77f7a2..7886c47 100644 --- a/src/components/SearchableAddressBook.tsx +++ b/src/components/SearchableAddressBook.tsx @@ -21,11 +21,6 @@ interface PropsType { export default function SearchableAddressBook(props: PropsType) { const [searchQuery, setSearchQuery] = React.useState(""); - const { - entries, - ...rest - } = props; - const reg = new RegExp(searchQuery, "i"); return ( @@ -43,7 +38,7 @@ export default function SearchableAddressBook(props: PropsType) { } - ent.fn?.match(reg)} {...rest} /> + ent.fn?.match(reg)} {...props} /> ); } diff --git a/src/pim-types.ts b/src/pim-types.ts index 5bcb29e..77fb03d 100644 --- a/src/pim-types.ts +++ b/src/pim-types.ts @@ -10,6 +10,8 @@ export const PRODID = "-//iCal.js EteSync iOS"; export interface PimType { uid: string; + collectionUid?: string; + itemUid?: string; toIcal(): string; clone(): PimType; } @@ -54,6 +56,9 @@ export function parseString(content: string) { } export class EventType extends ICAL.Event implements PimType { + public collectionUid?: string; + public itemUid?: string; + public static isEvent(comp: ICAL.Component) { return !!comp.getFirstSubcomponent("vevent"); } @@ -155,6 +160,9 @@ export enum TaskPriorityType { export const TaskTags = ["Work", "Home"]; export class TaskType extends EventType { + public collectionUid?: string; + public itemUid?: string; + public static fromVCalendar(comp: ICAL.Component) { const task = new TaskType(comp.getFirstSubcomponent("vtodo")); // FIXME: we need to clone it so it loads the correct timezone and applies it @@ -331,6 +339,9 @@ export class TaskType extends EventType { export class ContactType implements PimType { public comp: ICAL.Component; + public collectionUid?: string; + public itemUid?: string; + public static parse(content: string) { return new ContactType(parseString(content));