Skip to content

Commit

Permalink
feat: voice recording
Browse files Browse the repository at this point in the history
  • Loading branch information
szuperaz committed Sep 26, 2024
1 parent 20daf91 commit de07c2b
Show file tree
Hide file tree
Showing 49 changed files with 1,481 additions and 217 deletions.
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
"dayjs": "^1.11.10",
"dotenv": "^16.4.5",
"emoji-regex": "^10.3.0",
"fix-webm-duration": "^1.0.6",
"ngx-float-ui": "^15.0.0",
"pretty-bytes": "^6.1.1",
"rxjs": "~7.4.0",
Expand Down
16 changes: 14 additions & 2 deletions projects/sample-app/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,22 @@
</stream-channel-header>
<stream-message-list></stream-message-list>
<stream-notification-list></stream-notification-list>
<stream-message-input></stream-message-input>
<stream-message-input [displayVoiceRecordingButton]="true">
<ng-template voice-recorder let-service="service">
<stream-voice-recorder
[voiceRecorderService]="service"
></stream-voice-recorder>
</ng-template>
</stream-message-input>
<stream-thread class="thread" name="thread">
<stream-message-list mode="thread"></stream-message-list>
<stream-message-input mode="thread"></stream-message-input>
<stream-message-input mode="thread" [displayVoiceRecordingButton]="true">
<ng-template voice-recorder let-service="service">
<stream-voice-recorder
[voiceRecorderService]="service"
></stream-voice-recorder>
</ng-template>
</stream-message-input>
</stream-thread>
</stream-channel>
</div>
Expand Down
6 changes: 5 additions & 1 deletion projects/sample-app/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
TemplateRef,
ViewChild,
} from '@angular/core';
import { Observable } from 'rxjs';
import { Observable, Subject } from 'rxjs';
import { map } from 'rxjs/operators';
import {
ChatClientService,
Expand All @@ -14,6 +14,7 @@ import {
CustomTemplatesService,
ThemeService,
AvatarContext,
MessageService,
} from 'stream-chat-angular';
import { environment } from '../environments/environment';
import names from 'starwars-names';
Expand All @@ -32,16 +33,19 @@ export class AppComponent implements AfterViewInit {
@ViewChild('avatar') avatarTemplate!: TemplateRef<AvatarContext>;
theme$: Observable<string>;
counter = 0;
sendMessageOutsideTrigger$ = new Subject<void>();

constructor(
private chatService: ChatClientService,
private channelService: ChannelService,
private streamI18nService: StreamI18nService,
private customTemplateService: CustomTemplatesService,
private messageService: MessageService,
themeService: ThemeService
) {
const isDynamicUser = environment.userId === '<dynamic user>';
const userId = isDynamicUser ? uuidv4() : environment.userId;
// this.messageService.displayAs = 'html';
void this.chatService.init(
environment.apiKey,
isDynamicUser ? { id: userId, name: names.random() } : userId,
Expand Down
2 changes: 2 additions & 0 deletions projects/sample-app/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CustomMessageComponent } from './custom-message/custom-message.componen
import {
StreamAutocompleteTextareaModule,
StreamChatModule,
VoiceRecorderModule,
} from 'stream-chat-angular';
import { EmojiPickerComponent } from './emoji-picker/emoji-picker.component';
import { PickerModule } from '@ctrl/ngx-emoji-mart';
Expand All @@ -18,6 +19,7 @@ import { PickerModule } from '@ctrl/ngx-emoji-mart';
TranslateModule.forRoot(),
StreamChatModule,
PickerModule,
VoiceRecorderModule,
StreamAutocompleteTextareaModule,
],
bootstrap: [AppComponent],
Expand Down
1 change: 1 addition & 0 deletions projects/stream-chat-angular/ng-package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
},
"allowedNonPeerDependencies": [
"dayjs",
"fix-webm-duration",
"@stream-io/transliterate",
"uuid",
"pretty-bytes",
Expand Down
1 change: 1 addition & 0 deletions projects/stream-chat-angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"angular-mentions": "^1.4.0",
"dayjs": "^1.11.10",
"emoji-regex": "^10.3.0",
"fix-webm-duration": "^1.0.6",
"ngx-float-ui": "^15.0.0|| ^16.0.0 || ^17.0.0 || ^18.0.0 || ^18.0.1-rc.0",
"pretty-bytes": "^6.1.1",
"tslib": "^2.3.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,13 @@
</div>
<div
*ngIf="
attachmentUpload.type === 'file' || attachmentUpload.type === 'video'
attachmentUpload.type === 'file' ||
attachmentUpload.type === 'video' ||
attachmentUpload.type === 'voiceRecording'
"
class="str-chat__attachment-preview-file"
class="str-chat__attachment-preview-file str-chat__attachment-preview-type-{{
attachmentUpload.type
}}"
data-testclass="attachment-file-preview"
>
<stream-icon-placeholder
Expand All @@ -59,7 +63,10 @@
></stream-icon-placeholder>

<div class="str-chat__attachment-preview-file-end">
<div class="str-chat__attachment-preview-file-name">
<div
class="str-chat__attachment-preview-file-name"
title="{{ attachmentUpload.file.name }}"
>
{{ attachmentUpload.file.name }}
</div>
<a
Expand Down
65 changes: 55 additions & 10 deletions projects/stream-chat-angular/src/lib/attachment.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { Injectable } from '@angular/core';
import { isImageFile } from './is-image-file';
import { createUriFromBlob, isImageFile } from './file-utils';
import { BehaviorSubject, Observable } from 'rxjs';
import { AppSettings, Attachment } from 'stream-chat';
import { ChannelService } from './channel.service';
import { isImageAttachment } from './is-image-attachment';
import { NotificationService } from './notification.service';
import { AttachmentUpload, DefaultStreamChatGenerics } from './types';
import {
AttachmentUpload,
AudioRecording,
DefaultStreamChatGenerics,
} from './types';
import { ChatClientService } from './chat-client.service';

/**
Expand Down Expand Up @@ -54,6 +58,39 @@ export class AttachmentService<
this.attachmentUploadsSubject.next([]);
}

/**
* Upload a voice recording
* @param audioRecording
* @returns A promise with true or false. If false is returned the upload was canceled because of a client side error. The error is emitted via the `NotificationService`.
*/
async uploadVoiceRecording(audioRecording: AudioRecording) {
if (
!(await this.areAttachmentsHaveValidExtension([audioRecording.recording]))
) {
return false;
}
if (!(await this.areAttachmentsHaveValidSize([audioRecording.recording]))) {
return false;
}

const upload = {
file: audioRecording.recording,
previewUri: audioRecording.asset_url,
extraData: {
duration: audioRecording.duration,
waveform_data: audioRecording.waveform_data,
},
state: 'uploading' as const,
type: 'voiceRecording' as const,
};
this.attachmentUploadsSubject.next([
...this.attachmentUploadsSubject.getValue(),
upload,
]);
await this.uploadAttachments([upload]);
return true;
}

/**
* Uploads the selected files, and creates preview for image files. The result is propagated throught the `attachmentUploads$` stream.
* @param fileList The files selected by the user, if you have Blobs instead of Files, you can convert them with this method: https://developer.mozilla.org/en-US/docs/Web/API/File/File
Expand Down Expand Up @@ -85,7 +122,7 @@ export class AttachmentService<
dataFiles.push(file);
}
});
imageFiles.forEach((f) => this.createPreview(f));
imageFiles.forEach((f) => void this.createPreview(f));
const newUploads = [
...imageFiles.map((file) => ({
file,
Expand Down Expand Up @@ -177,7 +214,7 @@ export class AttachmentService<
return attachmentUploads
.filter((r) => r.state === 'success')
.map((r) => {
const attachment: Attachment = {
let attachment: Attachment = {
type: r.type,
};
if (r.fromAttachment) {
Expand All @@ -193,6 +230,9 @@ export class AttachmentService<
attachment.file_size = r.file?.size;
attachment.thumb_url = r.thumb_url;
}
if (r.extraData) {
attachment = { ...attachment, ...r.extraData };
}
}

return attachment;
Expand Down Expand Up @@ -243,18 +283,23 @@ export class AttachmentService<
}
}

private createPreview(file: File | Blob) {
const reader = new FileReader();
reader.onload = (event) => {
private async createPreview(file: File | Blob) {
try {
const uri = await createUriFromBlob(file);
const attachmentUploads = this.attachmentUploadsSubject.getValue();
const upload = attachmentUploads.find((upload) => upload.file === file);
if (!upload) {
return;
}
upload.previewUri = event.target?.result || undefined;
upload.previewUri = uri;
this.attachmentUploadsSubject.next([...attachmentUploads]);
};
reader.readAsDataURL(file as Blob);
} catch (e: unknown) {
this.chatClientService?.chatClient?.logger(
'error',
e instanceof Error ? e.message : `Can't create image preview`,
{ error: e, tag: ['AttachmentService'] }
);
}
}

private async uploadAttachments(uploads: AttachmentUpload[]) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ import {
import { ChannelPreviewComponent } from './channel-preview.component';
import { Observable, Subject, of } from 'rxjs';
import { DefaultStreamChatGenerics } from '../types';
import { IconPlaceholderComponent } from '../icon-placeholder/icon-placeholder.component';
import { DateParserService } from '../date-parser.service';
import { IconModule } from '../icon/icon.module';
import { IconPlaceholderComponent } from '../icon/icon-placeholder/icon-placeholder.component';

describe('ChannelPreviewComponent', () => {
let fixture: ComponentFixture<ChannelPreviewComponent>;
Expand All @@ -42,12 +43,11 @@ describe('ChannelPreviewComponent', () => {
user$: of({ id: 'currentUser' }),
};
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
imports: [TranslateModule.forRoot(), IconModule],
declarations: [
ChannelPreviewComponent,
AvatarComponent,
AvatarPlaceholderComponent,
IconPlaceholderComponent,
],
providers: [
{ provide: ChannelService, useValue: channelServiceMock },
Expand Down
49 changes: 49 additions & 0 deletions projects/stream-chat-angular/src/lib/file-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
export const isImageFile = (file: File) => {
// photoshop files begin with 'image/'
return file.type.startsWith('image/') && !file.type.endsWith('.photoshop');
};

export const readBlobAsArrayBuffer = (blob: Blob): Promise<ArrayBuffer> =>
new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = () => {
resolve(fileReader.result as ArrayBuffer);
};

fileReader.onerror = () => {
reject(fileReader.error);
};

fileReader.readAsArrayBuffer(blob);
});

export const createFileFromBlobs = ({
blobsArray,
fileName,
mimeType,
}: {
blobsArray: Blob[];
fileName: string;
mimeType: string;
}) => {
const concatenatedBlob = new Blob(blobsArray, { type: mimeType });
return new File([concatenatedBlob], fileName, {
type: concatenatedBlob.type,
});
};

export const getExtensionFromMimeType = (mimeType: string) => {
const match = mimeType.match(/\/([^/;]+)/);
return match && match[1];
};

export const createUriFromBlob = (blob: Blob) => {
return new Promise<string | ArrayBuffer | undefined>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
resolve(event.target?.result ?? undefined);
};
reader.onerror = (e) => reject(e);
reader.readAsDataURL(blob);
});
};
18 changes: 18 additions & 0 deletions projects/stream-chat-angular/src/lib/format-duration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const formatDuration = (durationInSeconds?: number) => {
if (durationInSeconds === undefined || durationInSeconds <= 0) return '00:00';

const [hours, hoursLeftover] = divMod(durationInSeconds, 3600);
const [minutes, seconds] = divMod(hoursLeftover, 60);
const roundedSeconds = Math.ceil(seconds);

const prependHrsZero = hours.toString().length === 1 ? '0' : '';
const prependMinZero = minutes.toString().length === 1 ? '0' : '';
const prependSecZero = roundedSeconds.toString().length === 1 ? '0' : '';
const minSec = `${prependMinZero}${minutes}:${prependSecZero}${roundedSeconds}`;

return hours ? `${prependHrsZero}${hours}:` + minSec : minSec;
};

const divMod = (num: number, divisor: number) => {
return [Math.floor(num / divisor), num % divisor];
};
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { IconComponent } from '../icon/icon.component';

import { IconPlaceholderComponent } from './icon-placeholder.component';
import { IconComponent } from '../icon.component';

describe('IconPlaceholderComponent', () => {
let component: IconPlaceholderComponent;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Component, Input, OnChanges } from '@angular/core';
import { CustomTemplatesService } from '../custom-templates.service';
import { Icon } from '../icon/icon.component';
import { IconContext } from '../types';
import { Icon } from '../icon.component';
import { IconContext } from '../../types';
import { CustomTemplatesService } from '../../custom-templates.service';

/**
* The `IconPlaceholder` component displays the [default icons](./IconComponent.mdx) unless a [custom template](../services/CustomTemplatesService.mdx) is provided. This component is used by the SDK internally, you likely won't need to use it.
Expand Down
Loading

0 comments on commit de07c2b

Please sign in to comment.