@@ -40,7 +40,7 @@
controlsList="nodownload"
preload="metadata"
playsinline
- [src]="file.url"
+ [src]="previewUrl"
(error)="handleVideoError($event)"
>
diff --git a/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.spec.ts b/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.spec.ts
index 75d262c72b..4a55855f75 100644
--- a/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.spec.ts
+++ b/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.spec.ts
@@ -49,6 +49,29 @@ describe('ChatPreviewComponent', () => {
expect(component.file.url).toBe(TEST_URL);
});
+ describe('previewUrl', () => {
+ it('should render the immediate preview URL when it is available', () => {
+ const directUrl = 'https://uploads.example.com/chat/image.png?token=direct';
+ component.file = {
+ type: 'image/png',
+ url: 'https://cdn.example.com/chat/image.png',
+ preview: directUrl,
+ };
+
+ fixture.detectChanges();
+
+ const image = fixture.nativeElement.querySelector('img') as HTMLImageElement;
+ expect(component.previewUrl).toBe(directUrl);
+ expect(image.src).toBe(directUrl);
+ });
+
+ it('should fall back to the canonical URL for sent attachments', () => {
+ component.file = { url: TEST_URL };
+
+ expect(component.previewUrl).toBe(TEST_URL);
+ });
+ });
+
describe('download()', () => {
it('should open and download from a URL', () => {
spyOn(window, 'open');
diff --git a/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.ts b/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.ts
index 9609a2bd1e..5c3ef95fe4 100644
--- a/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.ts
+++ b/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.ts
@@ -16,6 +16,10 @@ export class ChatPreviewComponent {
public sanitizer: DomSanitizer
) {}
+ get previewUrl(): string {
+ return this.file?.preview || this.file?.url;
+ }
+
download(keyboardEvent?: KeyboardEvent) {
if (keyboardEvent && (keyboardEvent?.code === 'Space' || keyboardEvent?.code === 'Enter')) {
keyboardEvent.preventDefault();
diff --git a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts
index 6b4197c8ec..82f2d14d9a 100644
--- a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts
+++ b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts
@@ -357,6 +357,89 @@ describe('ChatRoomComponent', () => {
preview: undefined
});
});
+
+ it('should broadcast the signed attachment returned by the message API', () => {
+ const uploadedAttachment = {
+ bucket: 'chat',
+ path: '/uploads/image.png',
+ name: 'image.png',
+ url: 'https://file.example.com/files/chat/image.png',
+ extension: 'png',
+ type: 'image/png',
+ size: 1024,
+ preview: 'https://file.example.com/files/chat/image.png',
+ };
+ const signedFile = {
+ name: 'image.png',
+ type: 'image/png',
+ url: 'https://file.example.com/files/chat/image.png?Signature=signed',
+ };
+ const saveMessageRes = {
+ uuid: 'attachment-message-uuid',
+ isSender: true,
+ message: '',
+ file: signedFile,
+ created: '2026-08-12 09:30:00',
+ sentAt: '2026-08-12 09:30:00',
+ senderUuid: 'sender-uuid',
+ senderName: 'Sender',
+ senderRole: 'participant',
+ senderAvatar: null,
+ sender: {
+ uuid: 'sender-uuid',
+ name: 'Sender',
+ role: 'participant',
+ avatar: null,
+ },
+ };
+
+ component.channelUuid = 'channel-uuid';
+ component.chatChannel.pusherChannel = 'private-chat-channel';
+ component.messagePageCursor = 'existing-cursor';
+ component.selectedAttachments = [uploadedAttachment];
+ chatServiceSpy.postNewMessage.and.returnValue(of(saveMessageRes));
+ pusherSpy.triggerSendMessage.calls.reset();
+
+ component.sendMessage();
+
+ expect(pusherSpy.triggerSendMessage).toHaveBeenCalledTimes(1);
+ expect(pusherSpy.triggerSendMessage).toHaveBeenCalledWith(
+ 'private-chat-channel',
+ jasmine.objectContaining({
+ uuid: saveMessageRes.uuid,
+ file: signedFile,
+ })
+ );
+ expect(pusherSpy.triggerSendMessage.calls.mostRecent().args[1].file)
+ .not.toBe(uploadedAttachment);
+ });
+ });
+
+ describe('when testing addAttachment()', () => {
+ it('should preview the direct URL while retaining the canonical message URL', () => {
+ const upload = {
+ name: 'cyberpunk.png',
+ url: 'https://cdn.example.com/files/chat/cyberpunk.png',
+ directUrl: 'https://uploads.example.com/chat/cyberpunk.png?token=direct',
+ extension: 'png',
+ type: 'image/png',
+ size: 1024,
+ bucket: 'chat',
+ path: '/uploads/cyberpunk.png',
+ tus: {
+ uploadUrl: 'https://uploads.example.com/tus/cyberpunk.png',
+ },
+ } as any;
+
+ component.addAttachment(upload);
+
+ expect(component.selectedAttachments[0]).toEqual(
+ jasmine.objectContaining({
+ url: upload.url,
+ preview: upload.directUrl,
+ })
+ );
+ });
});
describe('when testing getAvatarClass()', () => {
diff --git a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts
index 8d2704d349..a5cb09c979 100644
--- a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts
+++ b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.ts
@@ -561,7 +561,7 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit {
.pipe(takeUntil(this.destroy$))
.subscribe(
(response) => {
- this.afterEventEmission(response, attachment);
+ this.afterEventEmission(response);
this.removeSelectAttachment(attachment);
},
(error) => {
@@ -574,8 +574,8 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit {
}
// series of after event emission actions (triggered after sending message)
- afterEventEmission(response, attachment?) {
- this.triggerPusherEvent(response, attachment);
+ afterEventEmission(response) {
+ this.triggerPusherEvent(response);
this.updateListData(response);
this.utils.broadcastEvent("chat:info-update", true);
this._scrollToBottom();
@@ -583,13 +583,13 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit {
}
// trigger pusher event with file response
- triggerPusherEvent(response, file?: FileResponse) {
+ triggerPusherEvent(response) {
const pusherData: SendMessageParam = {
channelUuid: this.channelUuid,
uuid: response.uuid,
isSender: response.isSender,
message: response.message,
- file: file || response.file,
+ file: response.file,
created: response.created,
senderUuid: response.senderUuid,
senderName: response.senderName,
@@ -1058,7 +1058,7 @@ export class ChatRoomComponent implements OnInit, OnDestroy, AfterViewInit {
// tusd custom fields
bucket: uppyRes.bucket,
path: uppyRes.path,
- preview: uppyRes.url || uppyRes.tus.uploadUrl,
+ preview: uppyRes.directUrl || uppyRes.url || uppyRes.tus.uploadUrl,
});
}
diff --git a/projects/v3/src/app/pages/settings/settings.page.spec.ts b/projects/v3/src/app/pages/settings/settings.page.spec.ts
index f79fe0b1c7..e0fbea0622 100644
--- a/projects/v3/src/app/pages/settings/settings.page.spec.ts
+++ b/projects/v3/src/app/pages/settings/settings.page.spec.ts
@@ -57,7 +57,14 @@ describe('SettingsPage', () => {
}
} as any));
authSpy.logout.and.returnValue(Promise.resolve() as any);
- authSpy.updateUserProfile.and.returnValue(of({}) as any);
+ authSpy.updateUserProfile.and.returnValue(of({
+ data: {
+ updateUserProfile: {
+ success: true,
+ message: 'User profile updated successfully',
+ }
+ }
+ }) as any);
storageSpy.getUser.and.returnValue({
email: 'user@example.com',
@@ -235,6 +242,8 @@ describe('SettingsPage', () => {
size: 10,
bucket: 'bucket',
path: '/uploads/profile',
+ url: 'https://cdn/profile.png',
+ directUrl: 'https://files/profile.png',
preview: 'https://cdn/profile.png',
};
uppyUploaderServiceSpy.open.and.returnValue(Promise.resolve({
@@ -243,12 +252,56 @@ describe('SettingsPage', () => {
await component.profileImage();
- expect(authSpy.updateUserProfile).toHaveBeenCalled();
- expect(component.profile.avatar).toBe('https://cdn/profile.png');
- expect(storageSpy.setUser).toHaveBeenCalledWith({ image: 'https://cdn/profile.png' });
+ expect(authSpy.updateUserProfile).toHaveBeenCalledWith({
+ url: 'https://files/profile.png',
+ name: 'profile.png',
+ extension: 'png',
+ type: 'image/png',
+ size: 10,
+ bucket: 'bucket',
+ path: '/uploads/profile',
+ });
+ expect(component.profile.avatar).toBe('https://files/profile.png');
+ expect(storageSpy.setUser).toHaveBeenCalledWith({
+ avatar: 'https://files/profile.png',
+ image: 'https://files/profile.png',
+ });
expect(notificationsServiceSpy.alert).toHaveBeenCalled();
});
+ it('should not update local profile when the backend rejects the file', async () => {
+ const uploaded = {
+ tus: { uploadUrl: 'https://upload' },
+ name: 'profile.png',
+ extension: 'png',
+ type: 'image/png',
+ size: 10,
+ bucket: 'bucket',
+ path: '/uploads/profile',
+ url: 'https://cdn/profile.png',
+ directUrl: 'https://files/profile.png',
+ };
+ uppyUploaderServiceSpy.open.and.returnValue(Promise.resolve({
+ onDidDismiss: () => Promise.resolve({ data: uploaded })
+ } as any));
+ authSpy.updateUserProfile.and.returnValue(of({
+ data: {
+ updateUserProfile: {
+ success: false,
+ message: 'avatar file object incorrect',
+ }
+ }
+ }) as any);
+
+ await component.profileImage();
+
+ expect(component.profile.avatar).not.toBe('https://files/profile.png');
+ expect(storageSpy.setUser).not.toHaveBeenCalled();
+ const alertArgs = notificationsServiceSpy.alert.calls.mostRecent().args[0];
+ expect(alertArgs.subHeader).toBe('avatar file object incorrect');
+ expect(component.imageUpdating).toBeFalse();
+ });
+
it('should show upload error subHeader when server returns message', async () => {
uppyUploaderServiceSpy.open.and.returnValue(Promise.resolve({
onDidDismiss: () => Promise.resolve({ data: { tus: { uploadUrl: 'u' } } })
diff --git a/projects/v3/src/app/pages/settings/settings.page.ts b/projects/v3/src/app/pages/settings/settings.page.ts
index fff0999a3f..eb89367fc5 100644
--- a/projects/v3/src/app/pages/settings/settings.page.ts
+++ b/projects/v3/src/app/pages/settings/settings.page.ts
@@ -186,8 +186,11 @@ export class SettingsPage implements OnInit, OnDestroy {
const file = res.data;
if (file) {
this.imageUpdating = true;
- await firstValueFrom(this.authService.updateUserProfile({
- url: file.tus.uploadUrl,
+ // User-profile CDN URLs are not directly readable in every environment.
+ // Match file-display and prefer the TUS direct URL when it is available.
+ const profileUrl = file.directUrl || file.url;
+ const response = await firstValueFrom(this.authService.updateUserProfile({
+ url: profileUrl,
name: file.name,
extension: file.extension,
type: file.type,
@@ -196,9 +199,16 @@ export class SettingsPage implements OnInit, OnDestroy {
path: file.path,
}));
- this.imageUpdating = false;
- this.profile.avatar = file.preview;
- this.storage.setUser({ image: file.preview });
+ const result = response?.data?.updateUserProfile;
+ if (result?.success !== true) {
+ throw new Error(result?.message || 'Profile picture could not be updated.');
+ }
+
+ this.profile.avatar = profileUrl;
+ this.storage.setUser({
+ avatar: profileUrl,
+ image: profileUrl,
+ });
return this.notificationsService.alert({
message: $localize`Profile picture successfully updated!`,
@@ -211,8 +221,6 @@ export class SettingsPage implements OnInit, OnDestroy {
});
}
} catch (error) {
- this.imageUpdating = false;
-
// eslint-disable-next-line no-console
console.error('profile image error', error);
@@ -227,10 +235,12 @@ export class SettingsPage implements OnInit, OnDestroy {
};
// Actual error message from server
- if (error?.error?.message || error?.error?.msg) {
- alertOpts.subHeader = error?.error?.message || error?.error?.msg;
+ if (error?.error?.message || error?.error?.msg || error?.message) {
+ alertOpts.subHeader = error?.error?.message || error?.error?.msg || error?.message;
}
return this.notificationsService.alert(alertOpts);
+ } finally {
+ this.imageUpdating = false;
}
}
diff --git a/projects/v3/src/app/personalised-header/personalised-header.component.scss b/projects/v3/src/app/personalised-header/personalised-header.component.scss
index 4b746820e3..05be7e77cf 100644
--- a/projects/v3/src/app/personalised-header/personalised-header.component.scss
+++ b/projects/v3/src/app/personalised-header/personalised-header.component.scss
@@ -28,7 +28,9 @@ ion-avatar {
--padding-end: 1px;
--padding-bottom: 1px;
--padding-start: 1px;
+ }
+ .notify-btn {
.hint {
background-color: red;
position: absolute;
diff --git a/projects/v3/src/app/services/auth.service.spec.ts b/projects/v3/src/app/services/auth.service.spec.ts
index a29932a99b..9a089b85ff 100644
--- a/projects/v3/src/app/services/auth.service.spec.ts
+++ b/projects/v3/src/app/services/auth.service.spec.ts
@@ -45,6 +45,7 @@ describe('AuthService', () => {
provide: ApolloService,
useValue: jasmine.createSpyObj('ApolloService', {
'graphQLFetch': of(),
+ 'graphQLMutate': of(),
'graphQLWatch': of(),
'getClient': function () {
return {
@@ -103,6 +104,27 @@ describe('AuthService', () => {
expect(service).toBeTruthy();
});
+ it('should execute updateUserProfile as a mutation with the avatar variables', () => {
+ const apolloSpy = TestBed.inject(ApolloService) as jasmine.SpyObj;
+ const avatar = {
+ bucket: 'profile-images',
+ path: '/users/profile.png',
+ name: 'profile.png',
+ url: 'https://cdn.example.com/users/profile.png',
+ extension: 'png',
+ type: 'image/png',
+ size: 10,
+ };
+
+ service.updateUserProfile(avatar).subscribe();
+
+ expect(apolloSpy.graphQLMutate).toHaveBeenCalledWith(
+ jasmine.stringMatching(/mutation updateUserProfile/),
+ { avatar }
+ );
+ expect(apolloSpy.graphQLFetch).not.toHaveBeenCalled();
+ });
+
it('when testing directLogin(), it should pass the correct data to API', () => {
const apolloSpy = TestBed.inject(ApolloService) as jasmine.SpyObj;
apolloSpy.graphQLFetch.and.returnValue(of({
diff --git a/projects/v3/src/app/services/auth.service.ts b/projects/v3/src/app/services/auth.service.ts
index 5a132ca8fb..cb871222a4 100644
--- a/projects/v3/src/app/services/auth.service.ts
+++ b/projects/v3/src/app/services/auth.service.ts
@@ -45,6 +45,12 @@ interface ProfileAvatar {
size: number;
}
+interface UpdateUserProfileResponse {
+ data?: {
+ updateUserProfile?: Response;
+ };
+}
+
interface RegisterData {
password?: string;
user_id: number;
@@ -669,8 +675,8 @@ export class AuthService {
*
* @return {} [return description]
*/
- updateUserProfile(avatar: ProfileAvatar): Observable {
- return this.apolloService.graphQLFetch(`
+ updateUserProfile(avatar: ProfileAvatar): Observable {
+ return this.apolloService.graphQLMutate(`
mutation updateUserProfile($avatar: FileInput) {
updateUserProfile(avatar: $avatar) {
success
@@ -678,9 +684,7 @@ export class AuthService {
}
}
`, {
- variables: {
- avatar
- }
+ avatar
});
}
}
diff --git a/projects/v3/src/app/services/pusher.service.ts b/projects/v3/src/app/services/pusher.service.ts
index d8657a0eaa..fa74068ea8 100644
--- a/projects/v3/src/app/services/pusher.service.ts
+++ b/projects/v3/src/app/services/pusher.service.ts
@@ -18,7 +18,11 @@ export interface SendMessageParam {
channelUuid: string;
uuid: string;
message: string;
- file: string;
+ file: {
+ name: string;
+ type: string;
+ url: string;
+ } | string | null;
isSender: boolean;
created: string;
senderUuid: string;
diff --git a/projects/v3/src/index.html b/projects/v3/src/index.html
index 9cc5fe6018..e660258fc8 100644
--- a/projects/v3/src/index.html
+++ b/projects/v3/src/index.html
@@ -19,6 +19,57 @@
-
+