Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type FC, useMemo } from 'react';
import type { FieldProps } from 'formik';

import { useMemoSelector } from '@proton/pass/hooks/useMemoSelector';
import { isCustomIconFile } from '@proton/pass/lib/file-attachments/custom-icon';
import { selectItemFilesForRevision } from '@proton/pass/store/selectors/files';
import type { FileAttachmentValues, FileID, SelectedRevision } from '@proton/pass/types';
import { prop } from '@proton/pass/utils/fp/lens';
Expand All @@ -18,11 +19,17 @@ export const FileAttachmentsFieldEdit: FC<Props> = (props) => {
const { shareId, itemId, revision, form } = props;

const filesForRevision = useMemoSelector(selectItemFilesForRevision, [shareId, itemId, revision]);
const filesCount = filesForRevision.length;

/** Filter out custom icon files — they are managed by CustomIconField */
const regularFiles = useMemo(
() => filesForRevision.filter((f) => !isCustomIconFile(f.name)),
[filesForRevision]
);
const filesCount = regularFiles.length;

const files = useMemo(
() => filesForRevision.filter(pipe(prop('fileID'), notIn(form.values.files.toRemove))),
[filesForRevision, form.values.files.toRemove]
() => regularFiles.filter(pipe(prop('fileID'), notIn(form.values.files.toRemove))),
[regularFiles, form.values.files.toRemove]
);

const handleFileDelete = (fileID: FileID) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { FC, PropsWithChildren } from 'react';
import { type FC, type PropsWithChildren, useMemo } from 'react';
import { useSelector } from 'react-redux';

import { FieldsetCluster } from '@proton/pass/components/Form/Field/Layout/FieldsetCluster';
import { useMemoSelector } from '@proton/pass/hooks/useMemoSelector';
import { isCustomIconFile } from '@proton/pass/lib/file-attachments/custom-icon';
import { hasAttachments } from '@proton/pass/lib/items/item.predicates';
import { filesResolve } from '@proton/pass/store/actions';
import { selectRequestInFlight } from '@proton/pass/store/selectors';
Expand Down Expand Up @@ -30,8 +31,11 @@ export const FileAttachmentsContentView: FC<{ revision: ItemRevision<any> & Part
revision,
}) => {
const { shareId, itemId, optimistic, failed } = revision;
const files = useMemoSelector(selectItemFilesForRevision, [shareId, itemId, revision.revision]);
const allFiles = useMemoSelector(selectItemFilesForRevision, [shareId, itemId, revision.revision]);
const loading = useSelector(selectRequestInFlight(filesResolve.requestID(revision))) || (optimistic && !failed);

/** Filter out custom icon files — they are displayed as item icons */
const files = useMemo(() => allFiles.filter((f) => !isCustomIconFile(f.name)), [allFiles]);
const filesCount = files.length;

return (
Expand Down
177 changes: 177 additions & 0 deletions packages/pass/components/Form/Field/CustomIconField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { type FC, useCallback, useRef, useState } from 'react';

import type { FieldProps } from 'formik';
import { c } from 'ttag';

import { Button } from '@proton/atoms/Button/Button';
import Icon from '@proton/components/components/icon/Icon';
import useNotifications from '@proton/components/hooks/useNotifications';
import type { IconName } from '@proton/icons/types';
import {
CUSTOM_ICON_ACCEPTED_TYPES,
CUSTOM_ICON_MAX_SIZE,
processIconImage,
} from '@proton/pass/lib/file-attachments/custom-icon';
import type { FileAttachmentValues, FileID, Maybe, ShareId } from '@proton/pass/types';
import { partialMerge } from '@proton/pass/utils/object/merge';
import { uniqueId } from '@proton/pass/utils/string/unique-id';
import humanSize from '@proton/shared/lib/helpers/humanSize';

import { resolveMimeTypeForFile, useFileUpload } from '../../../hooks/files/useFileUpload';
import { IconBox, getIconSizePx } from '../../Layout/Icon/IconBox';

type Props = FieldProps<{}, FileAttachmentValues> & {
icon: IconName;
shareId: ShareId;
/** Object URL of an existing custom icon to display */
existingIconSrc?: string;
};

export const CustomIconField: FC<Props> = ({ form, icon, shareId, existingIconSrc }) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const fileUpload = useFileUpload();
const { createNotification } = useNotifications();
const [previewSrc, setPreviewSrc] = useState<Maybe<string>>();
const [uploading, setUploading] = useState(false);
const [uploadedFileId, setUploadedFileId] = useState<Maybe<FileID>>();

const displaySrc = previewSrc ?? existingIconSrc;

const handleFileSelect = useCallback(
async (file: File) => {
if (!CUSTOM_ICON_ACCEPTED_TYPES.includes(file.type)) {
createNotification({
type: 'error',
text: c('Error').t`Please select a PNG, JPEG, WebP, or SVG image.`,
});
return;
}

if (file.size > CUSTOM_ICON_MAX_SIZE) {
const maxSize = humanSize({ bytes: CUSTOM_ICON_MAX_SIZE, unit: 'KB', fraction: 0 });
createNotification({
type: 'error',
text: c('Error').t`Image is too large. Maximum size is ${maxSize}.`,
});
return;
}

setUploading(true);

try {
const processed = await processIconImage(file);
const preview = URL.createObjectURL(processed);
setPreviewSrc(preview);

const mimeType = await resolveMimeTypeForFile(processed);
const uploadID = uniqueId();
const fileID = await fileUpload.start(processed, processed.name, mimeType, shareId, uploadID);

/** If there was a previously uploaded icon in this session, remove it */
if (uploadedFileId) {
await form.setValues((values) => {
const toAdd = values.files.toAdd.filter((id) => id !== uploadedFileId);
return partialMerge(values, { files: { toAdd } });
});
}

setUploadedFileId(fileID);
await form.setValues((values) => {
const toAdd = values.files.toAdd.concat([fileID]);
return partialMerge(values, { files: { toAdd } });
});
} catch {
createNotification({
type: 'error',
text: c('Error').t`Failed to upload custom icon.`,
});
setPreviewSrc(undefined);
} finally {
setUploading(false);
}
},
[shareId, uploadedFileId]
);

const handleRemove = useCallback(async () => {
if (uploadedFileId) {
await form.setValues((values) => {
const toAdd = values.files.toAdd.filter((id) => id !== uploadedFileId);
return partialMerge(values, { files: { toAdd } });
});
setUploadedFileId(undefined);
}

if (previewSrc) URL.revokeObjectURL(previewSrc);
setPreviewSrc(undefined);
}, [uploadedFileId, previewSrc]);

const size = 5;

return (
<div className="relative shrink-0 ml-3 my-2">
<input
ref={fileInputRef}
type="file"
accept={CUSTOM_ICON_ACCEPTED_TYPES.join(',')}
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
e.target.value = '';
}}
/>

<button
type="button"
className="interactive-pseudo-inset rounded-xl"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
title={c('Action').t`Set custom icon`}
>
<IconBox mode={displaySrc ? 'image' : 'icon'} size={size} pill>
{displaySrc ? (
<img
src={displaySrc}
alt=""
className="w-custom h-custom absolute inset-center object-cover"
style={{
'--w-custom': `${getIconSizePx(size)}px`,
'--h-custom': `${getIconSizePx(size)}px`,
}}
/>
) : (
<Icon
className="absolute inset-center"
color="var(--interaction-norm)"
name={icon}
size={size}
/>
)}

{uploading && (
<div className="absolute inset-0 flex items-center justify-center bg-norm rounded-xl opacity-60">
<Icon name="arrow-up-line" size={3} className="anime-spin" />
</div>
)}
</IconBox>
</button>

{displaySrc && (
<Button
icon
pill
size="small"
shape="solid"
color="danger"
className="absolute top-custom right-custom"
style={{ '--top-custom': '-4px', '--right-custom': '-4px' }}
onClick={handleRemove}
title={c('Action').t`Remove custom icon`}
>
<Icon name="cross-small" size={3} />
</Button>
)}
</div>
);
};
26 changes: 19 additions & 7 deletions packages/pass/components/Item/Login/Login.edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { IcCross } from '@proton/icons/icons/IcCross';
import { IcPlus } from '@proton/icons/icons/IcPlus';
import { FileAttachmentsFieldEdit } from '@proton/pass/components/FileAttachments/FileAttachmentsFieldEdit';
import { ValueControl } from '@proton/pass/components/Form/Field/Control/ValueControl';
import { CustomIconField } from '@proton/pass/components/Form/Field/CustomIconField';
import { ExtraFieldGroup } from '@proton/pass/components/Form/Field/ExtraFieldGroup/ExtraFieldGroup';
import { Field } from '@proton/pass/components/Form/Field/Field';
import { FieldsetCluster } from '@proton/pass/components/Form/Field/Layout/FieldsetCluster';
Expand All @@ -21,6 +22,7 @@ import { ItemEditPanel } from '@proton/pass/components/Layout/Panel/ItemEditPane
import { UpgradeButton } from '@proton/pass/components/Upsell/UpgradeButton';
import type { ItemEditViewProps } from '@proton/pass/components/Views/types';
import { MAX_ITEM_NAME_LENGTH, MAX_ITEM_NOTE_LENGTH, UpsellRef } from '@proton/pass/constants';
import { useCustomIcon } from '@proton/pass/hooks/files/useCustomIcon';
import { useAliasForLogin } from '@proton/pass/hooks/useAliasForLogin';
import { useDeobfuscatedItem } from '@proton/pass/hooks/useDeobfuscatedItem';
import { useItemDraft } from '@proton/pass/hooks/useItemDraft';
Expand Down Expand Up @@ -53,6 +55,7 @@ export const LoginEdit: FC<ItemEditViewProps<'login'>> = ({ revision, url, share
const domain = url ? resolveSubdomain(url) : null;
const { shareId } = share;
const { data: item, itemId, revision: lastRevision } = revision;
const { iconSrc: existingIconSrc } = useCustomIcon({ shareId, itemId, revision: lastRevision });
const { metadata, content, extraFields, ...uneditable } = useDeobfuscatedItem(item);

/** On initial mount: expand username field by default IIF:
Expand Down Expand Up @@ -206,13 +209,22 @@ export const LoginEdit: FC<ItemEditViewProps<'login'>> = ({ revision, url, share
<FormikProvider value={form}>
<Form id={FORM_ID}>
<FieldsetCluster>
<Field
lengthLimiters
name="name"
label={c('Label').t`Title`}
component={TitleField}
maxLength={MAX_ITEM_NAME_LENGTH}
/>
<div className="flex items-center">
<Field
name="files"
component={CustomIconField}
icon="user"
shareId={shareId}
existingIconSrc={existingIconSrc}
/>
<Field
lengthLimiters
name="name"
label={c('Label').t`Title`}
component={TitleField}
maxLength={MAX_ITEM_NAME_LENGTH}
/>
</div>
</FieldsetCluster>

{form.values.passkeys.map((passkey, idx, passkeys) => (
Expand Down
27 changes: 18 additions & 9 deletions packages/pass/components/Item/Login/Login.new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { c } from 'ttag';

import { FileAttachmentsField } from '@proton/pass/components/FileAttachments/FileAttachmentsField';
import { ValueControl } from '@proton/pass/components/Form/Field/Control/ValueControl';
import { CustomIconField } from '@proton/pass/components/Form/Field/CustomIconField';
import { ExtraFieldGroup } from '@proton/pass/components/Form/Field/ExtraFieldGroup/ExtraFieldGroup';
import { Field } from '@proton/pass/components/Form/Field/Field';
import { FieldsetCluster } from '@proton/pass/components/Form/Field/Layout/FieldsetCluster';
Expand Down Expand Up @@ -194,15 +195,23 @@ export const LoginNew: FC<ItemNewViewProps<'login'>> = ({ shareId, url: currentU
{vaultTotalCount > 1 &&
openPortal(<Field component={VaultPickerField} name="shareId" dense />)}

<Field
name="name"
label={c('Label').t`Title`}
placeholder={c('Placeholder').t`Untitled`}
component={TitleField}
autoFocus={!draft && didEnter}
key={`login-name-${didEnter}`}
maxLength={MAX_ITEM_NAME_LENGTH}
/>
<div className="flex items-center">
<Field
name="files"
component={CustomIconField}
icon="user"
shareId={form.values.shareId}
/>
<Field
name="name"
label={c('Label').t`Title`}
placeholder={c('Placeholder').t`Untitled`}
component={TitleField}
autoFocus={!draft && didEnter}
key={`login-name-${didEnter}`}
maxLength={MAX_ITEM_NAME_LENGTH}
/>
</div>
</FieldsetCluster>

<FieldsetCluster>
Expand Down
17 changes: 15 additions & 2 deletions packages/pass/components/Layout/Icon/ItemIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { useSelector } from 'react-redux';
import { CircleLoader } from '@proton/atoms/CircleLoader/CircleLoader';
import Icon from '@proton/components/components/icon/Icon';
import type { IconName, IconSize } from '@proton/icons/types';
import { useCustomIcon } from '@proton/pass/hooks/files/useCustomIcon';
import { isDisabledAliasItem } from '@proton/pass/lib/items/item.predicates';
import { selectCanLoadDomainImages } from '@proton/pass/store/selectors';
import type { ItemMap, ItemRevision, Maybe, MaybeNull } from '@proton/pass/types';
import { ItemFlag } from '@proton/pass/types';
import { CardType } from '@proton/pass/types/protobuf/item-v1.static';
import amex from '@proton/styles/assets/img/credit-card-icons/cc-american-express.svg';
import masterCard from '@proton/styles/assets/img/credit-card-icons/cc-mastercard.svg';
Expand Down Expand Up @@ -139,15 +141,26 @@ export const SafeItemIcon: FC<ItemIconProps> = ({ className, iconClassName, item
const loadDomainImages = useSelector(selectCanLoadDomainImages);
const domainURL = data.type === 'login' ? data.content.urls?.[0] : null;

const customIcon = data.type === 'creditCard' ? getCreditCardIcon(data.content.cardType) : undefined;
const hasAttachments = Boolean(item.flags & ItemFlag.HasAttachments);
const { iconSrc: customIconSrc } = useCustomIcon(
hasAttachments
? { shareId: item.shareId, itemId: item.itemId, revision: item.revision }
: { shareId: '', itemId: '', revision: 0 }
);

const customIcon = customIconSrc ? (
<img src={customIconSrc} alt="" className="w-full h-full object-cover" />
) : data.type === 'creditCard' ? (
getCreditCardIcon(data.content.cardType)
) : undefined;

return (
<ItemIcon
alt={data.type}
className={className}
icon={presentItemIcon(item)}
iconClassName={iconClassName}
loadImage={loadDomainImages}
loadImage={loadDomainImages && !customIconSrc}
pill={pill}
renderIndicators={(size) => renderIndicators?.(size)}
size={size}
Expand Down
Loading