Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
deleteContact as deleteContactAction,
parseContactName,
renameContact as renameContactAction,
selectContactById,
} from "@domain/entity-contact";
import { useMemo } from "react";
import type { ContactDetailActionsPorts } from "@features/flow-contacts";
import { useDispatch, useStore } from "LLD/hooks/redux";

export function useContactsEditDeletePorts(): ContactDetailActionsPorts {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[ASK] "Ports" is for what? :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ports are dependency-injection interfaces that keep @features/flow-contacts platform-agnostic.
The shared flow defines what it needs (ContactEditPort.renameContact, ContactDeletionPort.deleteContact) without knowing how it’s persisted.
useContactsEditDeletePorts is the desktop wiring layer.

const dispatch = useDispatch();
const store = useStore();

return useMemo<ContactDetailActionsPorts>(
() => ({
edit: {
renameContact: async ({ contactId, name }) => {
dispatch(renameContactAction({ contactId, name: parseContactName(name) }));
const updatedContact = selectContactById(store.getState(), contactId);

if (updatedContact === undefined) {
throw new Error("Contact not found");
}

return updatedContact;
},
},
deletion: {
deleteContact: async contactIdToDelete => {
dispatch(deleteContactAction(contactIdToDelete));
},
},
}),
[dispatch, store],
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import React from "react";
import {
ContactAddressDetailDialog,
ContactsAddContactDialog,
ContactsDeleteContactDialog,
ContactsEditSignerDialog,
ContactsListView,
ContactsRenameContactDialog,
type ContactAddressDetailDialogProps,
type ContactsAddContactDialogProps,
type ContactsListViewProps,
Expand All @@ -11,18 +14,21 @@ import {
ContactsAddAddressFlowDialog,
type ContactsAddAddressFlowDialogProps,
} from "./components/ContactsAddAddressFlowDialog";
import type { ContactDetailEditDeleteDialogProps } from "./useContactDetailEditDeleteAdapter";

export type ContactsViewProps = ContactsListViewProps &
Readonly<{
addContactDialog: ContactsAddContactDialogProps;
addAddressFlowDialog: ContactsAddAddressFlowDialogProps;
addressDetailDialog: ContactAddressDetailDialogProps;
editDeleteDialogs: ContactDetailEditDeleteDialogProps;
}>;

export function ContactsView({
addContactDialog,
addAddressFlowDialog,
addressDetailDialog,
editDeleteDialogs,
...pageProps
}: Readonly<ContactsViewProps>) {
return (
Expand All @@ -31,6 +37,9 @@ export function ContactsView({
<ContactsAddContactDialog {...addContactDialog} />
<ContactAddressDetailDialog {...addressDetailDialog} />
<ContactsAddAddressFlowDialog {...addAddressFlowDialog} />
<ContactsRenameContactDialog {...editDeleteDialogs.renameDialog} />
<ContactsDeleteContactDialog {...editDeleteDialogs.deleteDialog} />
<ContactsEditSignerDialog {...editDeleteDialogs.signerDialog} />
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {
ContactIdSchema,
INVALID_CONTACT_NAME_ERROR_NAME,
type ContactId,
} from "@domain/entity-contact";
import {
type ContactsDeleteContactDialogProps,
type ContactsEditSignerDialogProps,
type ContactsRenameContactDialogProps,
type ContactDetailActionsLabels,
useContactDetailEditDeleteFlowViewModel,
useRenameContactDialogViewModel,
} from "@features/flow-contacts";
import { useTranslation } from "react-i18next";
import { useContactsEditDeletePorts } from "../../hooks/useContactsEditDeletePorts";

export type ContactDetailEditDeleteDialogProps = Readonly<{
detailActions?: Readonly<{
canDelete: boolean;
labels: ContactDetailActionsLabels;
onEdit: () => void;
onDelete: () => void;
}>;
renameDialog: ContactsRenameContactDialogProps;
deleteDialog: ContactsDeleteContactDialogProps;
signerDialog: ContactsEditSignerDialogProps;
}>;

export function useContactDetailEditDeleteAdapter(
contactId: ContactId | undefined,
onDeleteSuccess: () => void,
): ContactDetailEditDeleteDialogProps {
const { t } = useTranslation();
const ports = useContactsEditDeletePorts();
const resolvedContactId = contactId ?? ContactIdSchema.parse("contact-me");
const flow = useContactDetailEditDeleteFlowViewModel({
contactId: resolvedContactId,
ports,
onDeleteSuccess,
});
const renameDialogViewModel = useRenameContactDialogViewModel({
contactId: resolvedContactId,
currentName: flow.contactName,
editPort: ports.edit,
isRequestedOpen: flow.editUiState === "edit-open",
onCloseRequest: flow.onEditClose,
onSaveSuccess: () => undefined,
});
const actionLabels: ContactDetailActionsLabels = {
editContact: t("contacts.detailActions.editContact"),
deleteContact: t("contacts.detailActions.deleteContact"),
};
const renameLabels = {
title: t("contacts.editContact.title"),
namePlaceholder: t("contacts.editContact.namePlaceholder"),
namingDisclaimer: t("contacts.editContact.namingDisclaimer"),
applyChanges: t("contacts.editContact.applyChanges"),
nameValidationErrors: {
[INVALID_CONTACT_NAME_ERROR_NAME]: t("contacts.editContact.invalidNameError"),
},
};
const deleteLabels = {
title: t("contacts.deleteContact.title"),
description: t("contacts.deleteContact.description"),
confirm: t("contacts.deleteContact.confirm"),
cancel: t("contacts.deleteContact.cancel"),
};
const signerLabels = {
title: t("contacts.editSigner.title"),
description: t("contacts.editSigner.description"),
confirm: t("contacts.editSigner.confirm"),
cancel: t("contacts.editSigner.cancel"),
};

return {
detailActions: contactId
? {
canDelete: flow.canDelete,
labels: actionLabels,
onEdit: flow.onEditPress,
onDelete: flow.onDeletePress,
}
: undefined,
renameDialog: {
...renameDialogViewModel,
labels: renameLabels,
},
deleteDialog: {
isOpen: flow.deleteLifecycle.status === "open",
isDeleting: flow.isDeleting,
labels: deleteLabels,
onConfirm: flow.confirmDelete,
onCancel: flow.cancelDelete,
},
signerDialog: {
isOpen: flow.editUiState === "signer-open",
labels: signerLabels,
onConfirm: flow.onSignerConfirm,
onCancel: flow.onSignerCancel,
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,25 @@ import {
} from "@features/flow-contacts";
import { MY_WALLET_AVATAR_USER_URL } from "LLD/features/MyWallet/components/UserAvatar/constants";
import { useContactsAddressCurrencyAdapter } from "../../hooks/useContactsAddressCurrencyAdapter";
import { useContactDetailEditDeleteAdapter } from "./useContactDetailEditDeleteAdapter";

export function useContactDetailPaneAdapter(
onAddAddress: (contact: AddAddressContact) => void,
): Readonly<{
detail: ContactDetailViewProps | undefined;
addressDetailDialog: ContactAddressDetailDialogProps;
editDeleteDialogs: ReturnType<typeof useContactDetailEditDeleteAdapter>;
onOpenMe: ContactsListViewProps["onOpenMe"];
onOpenContact: ContactsListViewProps["onOpenContact"];
}> {
const { t } = useTranslation();
const meContact = useContactsMeContact();
const currencyPort = useContactsAddressCurrencyAdapter();
const [detailContactId, setDetailContactId] = useState<ContactId | undefined>(meContact.id);
const onDeleteSuccess = useCallback(() => {
setDetailContactId(meContact.id);
}, [meContact.id]);
const editDeleteDialogs = useContactDetailEditDeleteAdapter(detailContactId, onDeleteSuccess);
const emptyContact = useEmptyContactDetail(detailContactId);
const populatedContactDetail = usePopulatedContactDetail(detailContactId, currencyPort);
const {
Expand Down Expand Up @@ -82,6 +88,7 @@ export function useContactDetailPaneAdapter(
onAddAddress: () => onAddAddress(populatedContactDetail.contact),
addressGroups: populatedContactDetail.addressGroups,
onAddressRowPress,
detailActions: editDeleteDialogs.detailActions,
};
}

Expand All @@ -93,8 +100,16 @@ export function useContactDetailPaneAdapter(
...baseDetail,
contact: emptyContact,
onAddAddress: () => onAddAddress(emptyContact),
detailActions: editDeleteDialogs.detailActions,
};
}, [emptyContact, labels, onAddAddress, onAddressRowPress, populatedContactDetail]);
}, [
emptyContact,
editDeleteDialogs.detailActions,
labels,
onAddAddress,
onAddressRowPress,
populatedContactDetail,
]);
const addressDetailDialog = useMemo<ContactAddressDetailDialogProps>(
() => ({
isOpen,
Expand All @@ -118,6 +133,7 @@ export function useContactDetailPaneAdapter(
return {
detail,
addressDetailDialog,
editDeleteDialogs,
onOpenMe: openContact,
onOpenContact: openContact,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ import { useContactsFeatureIntroductionPreference } from "../../hooks/useContact
import { useContactsCurrencySelectionAdapter } from "../../hooks/useContactsCurrencySelectionAdapter";
import { useContactsAddressValidationAdapter } from "../../hooks/useContactsAddressValidationAdapter";
import { useContactDetailPaneAdapter } from "./useContactDetailPaneAdapter";
import { useContactDetailEditDeleteAdapter } from "./useContactDetailEditDeleteAdapter";
import type { ContactsAddAddressFlowDialogProps } from "./components/ContactsAddAddressFlowDialog";

export type ContactsPageViewModel = Omit<ContactsListViewProps, "onAddContact"> &
Readonly<{
addAddressFlowState: AddAddressFlowState;
addAddressFlowDialog: ContactsAddAddressFlowDialogProps;
addressDetailDialog: ContactAddressDetailDialogProps;
editDeleteDialogs: ReturnType<typeof useContactDetailEditDeleteAdapter>;
onClearSearch: () => void;
}>;

Expand Down Expand Up @@ -122,7 +124,7 @@ export function useContactsViewModel(): ContactsPageViewModel {
updateAddress,
],
);
const { detail, addressDetailDialog, onOpenMe, onOpenContact } =
const { detail, addressDetailDialog, editDeleteDialogs, onOpenMe, onOpenContact } =
useContactDetailPaneAdapter(onAddAddress);
const [isLedgerSyncIntroductionDismissed, setIsLedgerSyncIntroductionDismissed] = useState(false);
const [ledgerSyncStatus] = useState<ContactsLedgerSyncStatus>("ready");
Expand Down Expand Up @@ -188,6 +190,7 @@ export function useContactsViewModel(): ContactsPageViewModel {
addAddressFlowState,
addAddressFlowDialog,
addressDetailDialog,
editDeleteDialogs,
viewModel,
labels,
searchQuery,
Expand Down
23 changes: 23 additions & 0 deletions apps/ledger-live-desktop/static/i18n/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -9335,6 +9335,29 @@
"delete": "Delete",
"networkTag": "{{name}} Network"
},
"detailActions": {
"editContact": "Edit contact",
"deleteContact": "Delete contact"
},
"editContact": {
"title": "Edit contact",
"namePlaceholder": "Contact name",
"namingDisclaimer": "For your privacy, avoid full names and surnames. Use a nickname or just a first name + initial, e.g. 'John S.'",
"applyChanges": "Apply changes",
"invalidNameError": "Special characters are not allowed."
},
"deleteContact": {
"title": "Delete contact?",
"description": "Deleting this contact will erase all associated addresses.",
"confirm": "Delete",
"cancel": "Cancel"
},
"editSigner": {
"title": "Confirm on your device",
"description": "Connect and unlock your Ledger device to confirm this change.",
"confirm": "Continue",
"cancel": "Cancel"
},
"ledgerSyncIntroduction": {
"description": "Your contacts are end-to-end encrypted with your Ledger and synced across your devices, only you can unlock them.",
"dismiss": "Got it"
Expand Down
2 changes: 2 additions & 0 deletions features/flow/contacts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export * from "./steps/List";
export * from "./steps/List/web";
export * from "./steps/AddContact";
export * from "./steps/AddContact/web";
export * from "./steps/EditContact";
export * from "./steps/EditContact/web";
export * from "./steps/AddAddress";
export * from "./steps/AddAddress/web";
export * from "./steps/Introduction";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function ContactNameInput({
maxCount={CONTACT_NAME_MAX_LENGTH}
helperText={errorMessage}
status={errorMessage ? "error" : undefined}
className="mt-2"
/>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";
import type { ContactDetailViewProps } from "./types";
import { ContactDetailActions } from "./components/ContactDetailActions/ContactDetailActions.web";
import { ContactDetailAddressList } from "./components/ContactDetailAddressList/ContactDetailAddressList.web";
import { ContactDetailEmptyState } from "./components/ContactDetailEmptyState.web";
import { ContactDetailHeader } from "./components/ContactDetailHeader.web";
Expand All @@ -11,11 +12,16 @@ export function ContactDetailView({
onAddAddress,
addressGroups,
onAddressRowPress,
detailActions,
}: ContactDetailViewProps): React.ReactNode {
const hasPopulatedAddresses = addressGroups !== undefined && onAddressRowPress !== undefined;

return (
<div className="flex h-full flex-col gap-32 px-16 py-32" data-testid="contacts-detail-screen">
<div
className="relative flex h-full flex-col gap-32 px-16 py-32"
data-testid="contacts-detail-screen"
>
{detailActions ? <ContactDetailActions {...detailActions} /> : null}
<ContactDetailHeader
contact={contact}
labels={labels}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from "react";
import { IconButton } from "@ledgerhq/lumen-ui-react";
import { PenEdit, Trash } from "@ledgerhq/lumen-ui-react/symbols";
import type { ContactDetailActionsLabels } from "../../types";

export type ContactDetailActionsProps = Readonly<{
canDelete: boolean;
labels: ContactDetailActionsLabels;
onEdit: () => void;
onDelete: () => void;
}>;

export function ContactDetailActions({
canDelete,
labels,
onEdit,
onDelete,
}: ContactDetailActionsProps): React.ReactNode {
return (
<div className="absolute right-16 top-16 flex gap-8" data-testid="contacts-detail-actions">
<IconButton
appearance="transparent"
size="sm"
icon={PenEdit}
aria-label={labels.editContact}
onClick={onEdit}
data-testid="contacts-detail-edit-action"
/>
{canDelete ? (
<IconButton
appearance="transparent"
size="sm"
icon={Trash}
aria-label={labels.deleteContact}
onClick={onDelete}
data-testid="contacts-detail-delete-action"
/>
) : null}
</div>
);
}
Loading
Loading