mirror of
https://github.com/lux-tel/session-desktop.git
synced 2026-10-30 20:33:24 +00:00
374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
import { useCallback, useState } from 'react';
|
|
import type { AnyAction, Dispatch } from 'redux';
|
|
import styled from 'styled-components';
|
|
import { getAppDispatch } from '../../state/dispatch';
|
|
import { ToastUtils, UserUtils } from '../../session/utils';
|
|
import {
|
|
userSettingsModal,
|
|
updateEditProfilePictureModal,
|
|
updateConversationDetailsModal,
|
|
updateSessionCTA,
|
|
} from '../../state/ducks/modalDialog';
|
|
import type { EditProfilePictureModalProps } from '../../types/ReduxTypes';
|
|
import { pickFileForAvatar } from '../../types/attachments/VisualAttachment';
|
|
import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
|
|
import { SessionSpinner } from '../loading';
|
|
import { ProfileAvatar } from './user-settings/components';
|
|
import { useAvatarPath, useIsMe, useIsPublic } from '../../hooks/useParamSelector';
|
|
import { tr } from '../../localization/localeTools';
|
|
import { OpenGroupUtils } from '../../session/apis/open_group_api/utils';
|
|
import { PubKey } from '../../session/types';
|
|
import { groupInfoActions } from '../../state/ducks/metaGroups';
|
|
import { useGroupAvatarChangeFromUIPending } from '../../state/selectors/groups';
|
|
import { userActions } from '../../state/ducks/user';
|
|
import { ReduxSogsRoomInfos } from '../../state/ducks/sogsRoomInfo';
|
|
import { useOurAvatarIsUploading, useOurAvatarUploadFailed } from '../../state/selectors/user';
|
|
import { useAvatarOfRoomIsUploading } from '../../state/selectors/sogsRoomInfo';
|
|
import {
|
|
ModalActionsContainer,
|
|
ModalBasicHeader,
|
|
SessionWrapperModal,
|
|
} from '../SessionWrapperModal';
|
|
import { getIsProAvailableMemo } from '../../hooks/useIsProAvailable';
|
|
import { SpacerLG, SpacerSM } from '../basic/Text';
|
|
import { AvatarSize } from '../avatar/Avatar';
|
|
import { ProIconButton } from '../buttons/ProButton';
|
|
import { useProBadgeOnClickCb } from '../menuAndSettingsHooks/useProBadgeOnClickCb';
|
|
import { Localizer } from '../basic/Localizer';
|
|
import { UploadFirstImageButton } from '../buttons/avatar/UploadFirstImageButton';
|
|
import { Flex } from '../basic/Flex';
|
|
import { LucideIcon } from '../icon/LucideIcon';
|
|
import { LUCIDE_ICONS_UNICODE } from '../icon/lucide';
|
|
import {
|
|
useEditProfilePictureModal,
|
|
useUpdateConversationDetailsModal,
|
|
} from '../../state/selectors/modal';
|
|
import { CTAVariant } from './cta/types';
|
|
import { useCurrentUserHasPro } from '../../hooks/useHasPro';
|
|
|
|
const StyledAvatarContainer = styled.div`
|
|
cursor: pointer;
|
|
position: relative;
|
|
`;
|
|
|
|
const StyledCTADescription = styled.span<{ $reverseDirection: boolean }>`
|
|
text-align: center;
|
|
cursor: pointer;
|
|
font-size: var(--font-size-lg);
|
|
color: var(--text-secondary-color);
|
|
line-height: normal;
|
|
display: inline-flex;
|
|
flex-direction: ${props => (props.$reverseDirection ? 'row-reverse' : 'row')};
|
|
align-items: center;
|
|
gap: var(--margins-xs);
|
|
padding: 3px;
|
|
`;
|
|
|
|
const triggerUploadProfileAvatar = async (
|
|
scaledAvatarUrl: string | null,
|
|
conversationId: string,
|
|
dispatch: Dispatch<AnyAction>
|
|
) => {
|
|
if (scaledAvatarUrl?.length) {
|
|
try {
|
|
const fetched = await fetch(scaledAvatarUrl);
|
|
const blobContent = await fetched.blob();
|
|
if (!blobContent || !blobContent.size) {
|
|
throw new Error('Failed to fetch blob content from scaled avatar');
|
|
}
|
|
|
|
if (conversationId === UserUtils.getOurPubKeyStrFromCache()) {
|
|
const newAvatarDecrypted = await blobContent.arrayBuffer();
|
|
dispatch(userActions.updateOurAvatar({ mainAvatarDecrypted: newAvatarDecrypted }) as any);
|
|
} else if (OpenGroupUtils.isOpenGroupV2(conversationId)) {
|
|
dispatch(
|
|
ReduxSogsRoomInfos.roomAvatarChange({
|
|
conversationId,
|
|
avatarObjectUrl: scaledAvatarUrl,
|
|
}) as any
|
|
);
|
|
} else if (PubKey.is03Pubkey(conversationId)) {
|
|
dispatch(
|
|
groupInfoActions.currentDeviceGroupAvatarChange({
|
|
objectUrl: scaledAvatarUrl,
|
|
groupPk: conversationId,
|
|
}) as any
|
|
);
|
|
} else {
|
|
throw new Error('uploadProfileAvatar: unsupported case');
|
|
}
|
|
} catch (error) {
|
|
if (error.message && error.message.length) {
|
|
ToastUtils.pushToastError('edit-profile', error.message);
|
|
}
|
|
window.log.error(
|
|
'triggerUploadProfileAvatar Error ensuring that image is properly sized:',
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
const triggerRemovalProfileAvatar = async (conversationId: string) => {
|
|
try {
|
|
if (OpenGroupUtils.isOpenGroupV2(conversationId)) {
|
|
throw new Error('triggerRemovalProfileAvatar: not supported for communities');
|
|
}
|
|
|
|
if (conversationId === UserUtils.getOurPubKeyStrFromCache()) {
|
|
window.inboxStore?.dispatch(userActions.clearOurAvatar() as any);
|
|
} else if (PubKey.is03Pubkey(conversationId)) {
|
|
window.inboxStore?.dispatch(
|
|
groupInfoActions.currentDeviceGroupAvatarRemoval({
|
|
groupPk: conversationId,
|
|
}) as any
|
|
);
|
|
} else {
|
|
throw new Error('triggerRemovalProfileAvatar: unsupported case');
|
|
}
|
|
} catch (error) {
|
|
if (error.message && error.message.length) {
|
|
ToastUtils.pushToastError('edit-profile', error.message);
|
|
}
|
|
}
|
|
};
|
|
|
|
export const EditProfilePictureModal = ({ conversationId }: EditProfilePictureModalProps) => {
|
|
const dispatch = getAppDispatch();
|
|
|
|
const isMe = useIsMe(conversationId);
|
|
const isCommunity = useIsPublic(conversationId);
|
|
const weHavePro = useCurrentUserHasPro() && isMe;
|
|
const isProAvailable = getIsProAvailableMemo();
|
|
|
|
const avatarPath = useAvatarPath(conversationId) || '';
|
|
|
|
const groupAvatarChangePending = useGroupAvatarChangeFromUIPending();
|
|
const ourAvatarIsUploading = useOurAvatarIsUploading();
|
|
const ourAvatarUploadFailed = useOurAvatarUploadFailed();
|
|
const sogsAvatarIsUploading = useAvatarOfRoomIsUploading(conversationId);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
|
|
const [newAvatarObjectUrl, setNewAvatarObjectUrl] = useState<string | null>(avatarPath);
|
|
const [isNewAvatarAnimated, setIsNewAvatarAnimated] = useState<boolean>(false);
|
|
|
|
const avatarChanged = newAvatarObjectUrl !== avatarPath;
|
|
|
|
const resetState = () => {
|
|
setNewAvatarObjectUrl(null);
|
|
setIsNewAvatarAnimated(false);
|
|
};
|
|
|
|
const editProfilePictureModal = useEditProfilePictureModal();
|
|
const conversationDetailsModal = useUpdateConversationDetailsModal();
|
|
|
|
const afterActionButtonCallback = useCallback(() => {
|
|
dispatch(updateEditProfilePictureModal(null));
|
|
dispatch(updateConversationDetailsModal(null));
|
|
}, [dispatch]);
|
|
|
|
const actionButtonNextModalAfterCloseCallback = useCallback(() => {
|
|
dispatch(userSettingsModal({ userSettingsPage: 'default' }));
|
|
dispatch(updateConversationDetailsModal(conversationDetailsModal));
|
|
dispatch(updateEditProfilePictureModal(editProfilePictureModal));
|
|
}, [dispatch, conversationDetailsModal, editProfilePictureModal]);
|
|
|
|
const proBadgeCb = useProBadgeOnClickCb({
|
|
context: 'edit-profile-pic',
|
|
args: {
|
|
cta: {
|
|
variant: weHavePro
|
|
? CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE_ACTIVATED
|
|
: CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE,
|
|
afterActionButtonCallback,
|
|
actionButtonNextModalAfterCloseCallback,
|
|
},
|
|
},
|
|
});
|
|
|
|
const closeDialog = useCallback(() => {
|
|
dispatch(updateEditProfilePictureModal(null));
|
|
if (isMe) {
|
|
dispatch(userSettingsModal({ userSettingsPage: 'default' }));
|
|
}
|
|
}, [dispatch, isMe]);
|
|
|
|
const isPublic = useIsPublic(conversationId);
|
|
|
|
const handleAvatarClick = async () => {
|
|
const res = await pickFileForAvatar(setIsProcessing);
|
|
|
|
if (!res) {
|
|
window.log.error('Failed to pick avatar');
|
|
resetState();
|
|
|
|
return;
|
|
}
|
|
|
|
if (res) {
|
|
setIsNewAvatarAnimated(res.mainAvatarDetails.isAnimated);
|
|
const blob = new Blob([res.mainAvatarDetails.outputBuffer], {
|
|
type: res.mainAvatarDetails.format,
|
|
});
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
setNewAvatarObjectUrl(blobUrl);
|
|
}
|
|
};
|
|
|
|
const handleUpload = async () => {
|
|
if (!avatarChanged) {
|
|
window.log.debug('Avatar Object URL has not changed!');
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* Can upload animated profile picture if:
|
|
* A. Pro user uploading their own profile picture
|
|
* B. Group admin uploading a group profile picture and at least 1 admin is Pro.
|
|
* C. Community admin uploading a community profile picture
|
|
* All of those are taken care of as part of the `isProUser` check in the conversation model
|
|
*/
|
|
if (isProAvailable && !weHavePro && isNewAvatarAnimated && !isCommunity) {
|
|
dispatch(
|
|
updateSessionCTA({
|
|
variant: CTAVariant.PRO_ANIMATED_DISPLAY_PICTURE,
|
|
afterActionButtonCallback,
|
|
actionButtonNextModalAfterCloseCallback,
|
|
})
|
|
);
|
|
window.log.debug('Attempted to upload an animated profile picture without pro!');
|
|
return;
|
|
}
|
|
|
|
await triggerUploadProfileAvatar(newAvatarObjectUrl, conversationId, dispatch);
|
|
};
|
|
|
|
const loading =
|
|
ourAvatarIsUploading || groupAvatarChangePending || sogsAvatarIsUploading || isProcessing;
|
|
|
|
const newAvatarLoaded = newAvatarObjectUrl !== avatarPath;
|
|
|
|
const handleRemove = async () => {
|
|
if (isCommunity) {
|
|
throw new Error('community do not support removing avatars, only changing them');
|
|
}
|
|
await triggerRemovalProfileAvatar(conversationId);
|
|
resetState();
|
|
};
|
|
|
|
const handleClear = async () => {
|
|
if (loading || !newAvatarLoaded) {
|
|
return;
|
|
}
|
|
resetState();
|
|
if (isPublic) {
|
|
closeDialog();
|
|
}
|
|
};
|
|
|
|
const handleClick = () => {
|
|
void handleAvatarClick();
|
|
};
|
|
|
|
return (
|
|
<SessionWrapperModal
|
|
modalId="editProfilePictureModal"
|
|
onClose={closeDialog}
|
|
headerChildren={
|
|
<ModalBasicHeader
|
|
title={tr(isCommunity ? 'setCommunityDisplayPicture' : 'profileDisplayPictureSet')}
|
|
showExitIcon={!loading}
|
|
/>
|
|
}
|
|
buttonChildren={
|
|
<ModalActionsContainer buttonType={SessionButtonType.Outline}>
|
|
<SessionButton
|
|
text={tr('save')}
|
|
buttonType={SessionButtonType.Simple}
|
|
onClick={handleUpload}
|
|
disabled={!newAvatarObjectUrl || !avatarChanged || loading}
|
|
dataTestId="save-button-profile-update"
|
|
/>
|
|
{/* we cannot remove avatars from communities, only change them */}
|
|
{newAvatarObjectUrl && newAvatarLoaded ? (
|
|
<SessionButton
|
|
text={tr('clear')}
|
|
buttonColor={SessionButtonColor.Danger}
|
|
buttonType={SessionButtonType.Simple}
|
|
onClick={handleClear}
|
|
disabled={!newAvatarObjectUrl || loading}
|
|
/>
|
|
) : !isCommunity ? (
|
|
<SessionButton
|
|
text={tr('remove')}
|
|
buttonColor={SessionButtonColor.Danger}
|
|
buttonType={SessionButtonType.Simple}
|
|
onClick={handleRemove}
|
|
disabled={!avatarPath || loading}
|
|
/>
|
|
) : null}
|
|
</ModalActionsContainer>
|
|
}
|
|
$flexGap="var(--margins-sm)"
|
|
>
|
|
{isMe && proBadgeCb.cb ? (
|
|
<StyledCTADescription $reverseDirection={weHavePro} onClick={proBadgeCb.cb}>
|
|
{tr(
|
|
weHavePro
|
|
? 'proAnimatedDisplayPictureModalDescription'
|
|
: 'proAnimatedDisplayPicturesNonProModalDescription'
|
|
)}
|
|
<ProIconButton
|
|
iconSize={'medium'}
|
|
dataTestId="pro-badge-edit-profile-picture"
|
|
disabled={loading}
|
|
onClick={proBadgeCb.cb}
|
|
/>
|
|
</StyledCTADescription>
|
|
) : null}
|
|
<div role="button" data-testid={'image-upload-click'}>
|
|
<SpacerLG />
|
|
<StyledAvatarContainer>
|
|
{newAvatarObjectUrl || avatarPath ? (
|
|
<ProfileAvatar
|
|
newAvatarObjectUrl={newAvatarObjectUrl}
|
|
avatarPath={avatarPath}
|
|
conversationId={conversationId}
|
|
onPlusAvatarClick={handleClick}
|
|
onAvatarClick={handleClick}
|
|
avatarSize={AvatarSize.XL}
|
|
dataTestId={'avatar-edit-profile-picture-dialog'}
|
|
/>
|
|
) : (
|
|
<UploadFirstImageButton onClick={handleClick} />
|
|
)}
|
|
</StyledAvatarContainer>
|
|
</div>
|
|
{ourAvatarUploadFailed && isMe ? (
|
|
<Flex
|
|
$container={true}
|
|
$justifyContent="center"
|
|
$alignItems="center"
|
|
$flexGap="var(--margins-xs)"
|
|
style={{
|
|
marginTop: 'var(--margins-lg)',
|
|
color: 'var(--danger-color)',
|
|
}}
|
|
>
|
|
<LucideIcon unicode={LUCIDE_ICONS_UNICODE.TRIANGLE_ALERT} iconSize="small" />
|
|
<Localizer token="profileErrorUpdate" />
|
|
</Flex>
|
|
) : null}
|
|
{loading ? (
|
|
<>
|
|
<SpacerSM />
|
|
{isMe && !isProcessing ? <Localizer token="updating" /> : null}
|
|
<SessionSpinner $loading={loading} $height="30px" />
|
|
</>
|
|
) : (
|
|
<SpacerLG />
|
|
)}
|
|
</SessionWrapperModal>
|
|
);
|
|
};
|