Skip to content

Commit 3befa86

Browse files
committed
Upload profile photos to IPFS
Quiet will still load the old base64-inlined profile photos, but new photos will be attached to profiles with an IPFS CID.
1 parent c7488cd commit 3befa86

File tree

25 files changed

+925
-135
lines changed

25 files changed

+925
-135
lines changed

packages/backend/src/nest/storage/userProfile/userProfile.store.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,8 @@ export class UserProfileStore extends EncryptedKeyValueIndexedValidatedStoreBase
168168
logger.error('Failed to add user profile, profile is invalid', userProfile.userId)
169169
throw new Error('Invalid user profile')
170170
}
171-
const encEntry = await this.encryptEntry(userProfile)
171+
const sanitizedProfile = UserProfileStore.sanitizeUserProfile(userProfile)
172+
const encEntry = await this.encryptEntry(sanitizedProfile)
172173
await this.getStore().put(key, encEntry)
173174
this.nicknameMaps.set(userProfile.userId, userProfile.nickname)
174175
return encEntry
@@ -179,6 +180,30 @@ export class UserProfileStore extends EncryptedKeyValueIndexedValidatedStoreBase
179180
}
180181
}
181182

183+
/**
184+
* Strips sensitive local path information from user profile metadata.
185+
* @param userProfile The user profile to sanitize.
186+
* @returns A sanitized copy of the user profile.
187+
*/
188+
public static sanitizeUserProfile(userProfile: UserProfile): UserProfile {
189+
const sanitized = { ...userProfile }
190+
if (sanitized.profilePhoto) {
191+
sanitized.profilePhoto = {
192+
...sanitized.profilePhoto,
193+
path: null,
194+
tmpPath: undefined,
195+
}
196+
}
197+
if (sanitized.fileMetadata) {
198+
sanitized.fileMetadata = {
199+
...sanitized.fileMetadata,
200+
path: null,
201+
tmpPath: undefined,
202+
}
203+
}
204+
return sanitized
205+
}
206+
182207
/**
183208
* Validates a user profile, including its photo if present.
184209
* @param userProfile The user profile to validate.

packages/desktop/src/renderer/components/ContextMenu/menus/UserProfileContextMenu.container.tsx

Lines changed: 13 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { UserProfile } from '@quiet/types'
1010
import { useContextMenu } from '../../../../hooks/useContextMenu'
1111
import { ContextMenu, ContextMenuItemList } from '../ContextMenu.component'
1212
import { MenuName } from '../../../../const/MenuNames.enum'
13-
import Jdenticon from '../../Jdenticon/Jdenticon'
13+
import ProfilePhoto from '../../ProfilePhoto/ProfilePhoto'
1414
import { createLogger } from '../../../logger'
1515

1616
const logger = createLogger('userProfileContextMenu:container')
@@ -211,21 +211,12 @@ export const UserProfileMenuProfileView: FC<UserProfileMenuProfileViewProps> = (
211211
>
212212
<Grid container direction='column'>
213213
<Grid container direction='column' className={classes.profilePhotoContainer} alignItems='center'>
214-
{userProfile?.photo ? (
215-
<img className={classes.profilePhoto} src={userProfile?.photo} alt={'User profile image'} />
216-
) : (
217-
<Jdenticon
218-
value={userId}
219-
size='96'
220-
style={{
221-
width: '96px',
222-
height: '96px',
223-
background: theme.palette.background.paper,
224-
borderRadius: '8px',
225-
marginBottom: '16px',
226-
}}
227-
/>
228-
)}
214+
<ProfilePhoto
215+
userProfile={userProfile}
216+
userId={userId}
217+
className={classes.profilePhoto}
218+
size={96}
219+
/>
229220
<Typography variant='body2' className={classes.nickname}>
230221
{username}
231222
</Typography>
@@ -415,25 +406,12 @@ export const UserProfileMenuEditView: FC<UserProfileMenuEditViewProps> = ({
415406
</Grid>
416407
)}
417408
<Grid container direction='column' className={classes.profilePhotoContainer} alignItems='center'>
418-
{userProfile?.photo ? (
419-
<img
420-
className={classes.profilePhoto}
421-
src={userProfile?.photo}
422-
alt={'Your user profile image'}
423-
/>
424-
) : (
425-
<Jdenticon
426-
value={userId}
427-
size='96'
428-
style={{
429-
width: '96px',
430-
height: '96px',
431-
background: theme.palette.background.paper,
432-
borderRadius: '8px',
433-
marginBottom: '16px',
434-
}}
435-
/>
436-
)}
409+
<ProfilePhoto
410+
userProfile={userProfile}
411+
userId={userId}
412+
className={classes.profilePhoto}
413+
size={96}
414+
/>
437415
<EditPhotoButton onChange={onChange} />
438416
</Grid>
439417
<label htmlFor='username' className={classes.editUsernameFieldLabel}>
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* `ProfilePhoto` supports both the deprecated base64-encoded photos and attachment-based photos.
3+
*/
4+
import React from 'react'
5+
import type { UserProfile } from '@quiet/types'
6+
import Jdenticon from '../Jdenticon/Jdenticon'
7+
import { useTheme } from '@mui/material/styles'
8+
9+
interface ProfilePhotoProps {
10+
userProfile?: UserProfile
11+
userId: string
12+
className?: string
13+
size?: number
14+
style?: React.CSSProperties
15+
}
16+
17+
const hasProfilePhoto = (
18+
profile: UserProfile | undefined
19+
): profile is UserProfile & { profilePhoto: { path: string } } => {
20+
return !!profile?.profilePhoto?.path
21+
}
22+
23+
const getProfilePhotoPath = (profile: UserProfile | undefined): string | undefined => {
24+
if (hasProfilePhoto(profile)) {
25+
return profile.profilePhoto.path
26+
}
27+
return undefined
28+
}
29+
30+
export const ProfilePhoto: React.FC<ProfilePhotoProps> = ({ userProfile, userId, className, size = 96, style }) => {
31+
const theme = useTheme()
32+
const profilePhotoPath = getProfilePhotoPath(userProfile)
33+
34+
const defaultStyle = {
35+
width: `${size}px`,
36+
height: `${size}px`,
37+
borderRadius: '4px',
38+
marginBottom: '16px',
39+
...style,
40+
}
41+
42+
return (
43+
<>
44+
{userProfile?.photo ? (
45+
<img className={className} src={userProfile.photo} alt={'User profile'} style={defaultStyle} />
46+
) : profilePhotoPath ? (
47+
<img className={className} src={profilePhotoPath} alt={'User profile'} style={defaultStyle} />
48+
) : (
49+
<Jdenticon
50+
value={userId}
51+
size={size.toString()}
52+
style={{
53+
background: theme.palette.background.paper,
54+
...defaultStyle,
55+
}}
56+
/>
57+
)}
58+
</>
59+
)
60+
}
61+
62+
export default ProfilePhoto

packages/desktop/src/renderer/components/Sidebar/DirectMessagesPanel/UserProfileListItem.tsx

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import { Typography, ListItemButton, Avatar } from '@mui/material'
55
import Badge from '@mui/material/Badge'
66
import ListItemText from '@mui/material/ListItemText'
77
import { UserProfile } from '@quiet/types'
8-
import Jdenticon from '../../Jdenticon/Jdenticon'
98
import { useContextMenu } from '../../../../hooks/useContextMenu'
109
import { MenuName } from '../../../../const/MenuNames.enum'
1110
import { users } from '@quiet/state-manager'
1211
import { useSelector } from 'react-redux'
12+
import ProfilePhoto from '../../ProfilePhoto/ProfilePhoto'
1313

1414
const PREFIX = 'UserProfileListItem'
1515

@@ -30,7 +30,9 @@ const StyledBadge = styled(Badge)(({ theme }) => ({
3030
minWidth: theme.componentSizes.statusIndicator.size,
3131
minHeight: theme.componentSizes.statusIndicator.size,
3232
borderRadius: '50%',
33-
border: `${theme.componentSizes.statusIndicator.borderWidth}px solid ${theme.palette.colors?.sidebarBackground || theme.palette.background.default}`,
33+
border: `${theme.componentSizes.statusIndicator.borderWidth}px solid ${
34+
theme.palette.colors?.sidebarBackground || theme.palette.background.default
35+
}`,
3436
boxSizing: 'border-box',
3537
right: theme.componentSizes.statusIndicator.position.right,
3638
bottom: theme.componentSizes.statusIndicator.position.bottom,
@@ -106,17 +108,13 @@ export const UserProfileListItem: React.FC<UserProfileListItemProps> = ({
106108
variant='dot'
107109
invisible={!connected}
108110
>
109-
{userProfile.photo ? (
110-
<Avatar className={classes.avatar} src={userProfile.photo} alt={userProfile.nickname} />
111-
) : (
112-
<span className={classes.avatar}>
113-
<Jdenticon
114-
value={userProfile.userId}
115-
size={theme.componentSizes.avatar.small.toString()}
116-
style={{ borderRadius: 4 }}
117-
/>
118-
</span>
119-
)}
111+
<span className={classes.avatar}>
112+
<ProfilePhoto
113+
userProfile={userProfile}
114+
userId={userProfile.userId}
115+
size={theme.componentSizes.avatar.small}
116+
/>
117+
</span>
120118
</StyledBadge>
121119
<ListItemText
122120
primary={

packages/desktop/src/renderer/components/Sidebar/UserProfilePanel/UserProfilePanel.tsx

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import Typography from '@mui/material/Typography'
77
import { Identity, UserProfile } from '@quiet/types'
88

99
import { useContextMenu } from '../../../../hooks/useContextMenu'
10-
import Jdenticon from '../../Jdenticon/Jdenticon'
10+
import ProfilePhoto from '../../ProfilePhoto/ProfilePhoto'
1111

1212
const PREFIX = 'UserProfilePanel-'
1313

@@ -88,21 +88,15 @@ export const UserProfilePanel: React.FC<UserProfilePanelProps> = ({
8888
classes={{ root: classes.button }}
8989
data-testid={'user-profile-menu-button'}
9090
>
91-
{userProfile?.photo ? (
92-
<img className={classes.profilePhoto} src={userProfile?.photo} alt={'Your user profile image'} />
93-
) : (
94-
<Jdenticon
95-
value={userID}
96-
size='24'
97-
style={{
98-
width: '24px',
99-
height: '24px',
100-
background: theme.palette.background.paper,
101-
borderRadius: '4px',
102-
marginRight: '8px',
103-
}}
104-
/>
105-
)}
91+
<ProfilePhoto
92+
userProfile={userProfile}
93+
userId={userID}
94+
className={classes.profilePhoto}
95+
size={24}
96+
style={{
97+
marginRight: '8px',
98+
}}
99+
/>
106100
<Typography variant='body2' className={classes.nickname} data-testid='user-profile-nickname'>
107101
{username}
108102
</Typography>

packages/desktop/src/renderer/components/widgets/channels/BasicMessage.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import ListItemText from '@mui/material/ListItemText'
99

1010
import red from '@mui/material/colors/red'
1111

12-
import Jdenticon from '../../Jdenticon/Jdenticon'
12+
import ProfilePhoto from '../../ProfilePhoto/ProfilePhoto'
1313

1414
import type { DisplayableMessage, DownloadStatus, MessageSendingStatus } from '@quiet/types'
1515

@@ -145,16 +145,16 @@ const formatMessageTime = (timestamp: number | string) => {
145145
}
146146

147147
const MessageProfilePhoto: React.FC<{ message: DisplayableMessage }> = ({ message }) => {
148-
const imgStyle = {
149-
width: '36px',
150-
height: '36px',
151-
borderRadius: '4px',
152-
marginRight: '8px',
153-
}
154-
return message.photo ? (
155-
<img style={imgStyle} src={message.photo} alt={"Message author's profile image"} />
156-
) : (
157-
<Jdenticon value={message.userId} size='36' />
148+
return (
149+
<ProfilePhoto
150+
userProfile={message}
151+
userId={message.userId}
152+
size={36}
153+
style={{
154+
borderRadius: '4px',
155+
marginRight: '8px',
156+
}}
157+
/>
158158
)
159159
}
160160

packages/mobile/src/components/Message/Message.component.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ const MessageProfilePhoto: React.FC<{ message: DisplayableMessage }> = ({ messag
2424
}
2525
return message.photo ? (
2626
<Image style={imgStyle} source={{ uri: message.photo }} alt={"Message author's profile image"} />
27+
) : message.profilePhoto?.path ? (
28+
<Image
29+
style={imgStyle}
30+
source={{ uri: `file://${message.profilePhoto.path}` }}
31+
alt={"Message author's profile image"}
32+
/>
2733
) : (
2834
<Jdenticon value={message.userId} size={37} />
2935
)

packages/state-manager/src/sagas/files/broadcastHostedFile/broadcastHostedFile.saga.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { type PayloadAction } from '@reduxjs/toolkit'
22
import { applyEmitParams, type Socket } from '../../../types'
33

4-
import { select, apply } from 'typed-redux-saga'
4+
import { select, apply, put } from 'typed-redux-saga'
55
import { identitySelectors } from '../../identity/identity.selectors'
66
import { messagesSelectors } from '../../messages/messages.selectors'
7-
import { type filesActions } from '../files.slice'
8-
import { ChannelMessage, instanceOfChannelMessage, SocketActions } from '@quiet/types'
7+
import { filesActions } from '../files.slice'
8+
import { ChannelMessage, instanceOfChannelMessage, SocketActions, PROFILE_PHOTO_CHANNEL_ID } from '@quiet/types'
99
import { createLogger } from '../../../utils/logger'
1010

1111
const logger = createLogger('broadcastHostedFileSaga')
@@ -15,6 +15,17 @@ export function* broadcastHostedFileSaga(
1515
action: PayloadAction<ReturnType<typeof filesActions.broadcastHostedFile>['payload']>
1616
): Generator {
1717
const payload = action.payload
18+
if (payload.message.channelId === PROFILE_PHOTO_CHANNEL_ID) {
19+
logger.info('Skipping message broadcast for profile photo attachment')
20+
yield* put(
21+
filesActions.setProfilePhotoMetadata({
22+
messageId: payload.message.id,
23+
fileMetadata: payload,
24+
})
25+
)
26+
return
27+
}
28+
1829
logger.info('Broadcasting hosted file', payload)
1930
const identity = yield* select(identitySelectors.currentIdentity)
2031
if (!identity) return

packages/state-manager/src/sagas/files/files.selectors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export const downloadStatuses = createSelector(filesSlice, state =>
1111
downloadStatusAdapter.getSelectors().selectEntities(state.downloadStatus)
1212
)
1313

14+
export const profilePhotos = createSelector(filesSlice, state => state.profilePhotos)
15+
1416
export const filesSelectors = {
1517
downloadStatuses,
18+
profilePhotos,
1619
}

packages/state-manager/src/sagas/files/files.slice.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313

1414
export class FilesState {
1515
public downloadStatus: EntityState<DownloadStatus> = downloadStatusAdapter.getInitialState()
16+
public profilePhotos: Record<string, FileMetadata> = {}
1617
}
1718

1819
export const filesSlice = createSlice({
@@ -31,6 +32,12 @@ export const filesSlice = createSlice({
3132
broadcastHostedFile: (state, _action: PayloadAction<FileMetadata>) => state,
3233
downloadFile: (state, _action: PayloadAction<FileMetadata>) => state,
3334
updateMessageMedia: (state, _action: PayloadAction<FileMetadata>) => state,
35+
setProfilePhotoMetadata: (state, action: PayloadAction<{ messageId: string; fileMetadata: FileMetadata }>) => {
36+
if (!state.profilePhotos) {
37+
state.profilePhotos = {}
38+
}
39+
state.profilePhotos[action.payload.messageId] = action.payload.fileMetadata
40+
},
3441
checkForMissingFiles: (state, _action: PayloadAction<CommunityId>) => state,
3542
deleteFilesFromChannel: (state, _action: PayloadAction<DeleteFilesFromChannelPayload>) => state,
3643
},

0 commit comments

Comments
 (0)