All files / app/identity/user-photos user-photos.service.ts

94.28% Statements 33/35
50% Branches 2/4
100% Functions 7/7
94.11% Lines 32/34

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159                                                                                            1x   1x   1x   1x           1x                         2x         2x 2x   2x       1x       1x                   2x   2x       3x 3x 3x       3x 6x 6x       3x 6x 6x   6x     6x               8x   3x                           1x       1x 1x 1x 1x 1x        
import { inject, Injectable } from '@angular/core';
import {
  getDownloadURL,
  getMetadata,
  listAll,
  percentage,
  ref,
  Storage,
  uploadBytesResumable,
} from '@angular/fire/storage';
import type {
  FullMetadata,
  ListResult,
  UploadTask,
  UploadTaskSnapshot,
} from '@angular/fire/storage';
import {
  BehaviorSubject,
  endWith,
  finalize,
  Subject,
  switchMap,
} from 'rxjs';
import type { Observable } from 'rxjs';
 
/**
 * Firebase storage file
 */
export interface Photo {
  /** Need the file metadata to sort by time. */
  readonly metadata: FullMetadata;
  /** Special download URL for file based on storage.rules. */
  readonly url: string;
}
 
/**
 * Upload progress to Firebase Storage
 */
export interface Progress {
  /** Upload precentage between 0% and 100% */
  readonly progress: number;
  /** Not used, but part of the rxfire interface */
  readonly snapshot: UploadTaskSnapshot;
}
 
/** Total allowed uploaded profile photos. */
export const MAXIMUM_PHOTOS = 6;
/** Directory below UID directory for files. */
const PHOTO_DIR = 'profile-photos';
/** Represent random number using letters and numbers. */
const RADIX = 36;
/** Remove the whole number and decimal point from Math.random */
const SKIP_WHOLE_NUM = 2;
 
/**
 * Specific handling for Firebase storage of user profile photos.
 */
@Injectable({ providedIn: 'root' })
export class UserPhotosService {
  /**
   * Track the percentage uploaded of `uploadPhoto`.
   * Idea here is that while falsy (before first emit, when emitting undefined) then the template
   * will show an `@else` branch for file picker UI. But while uploading it will emit progress for
   * use in a progress meter.
   * To automatically refresh the uploaded profile photos from `getProfilePhotos` this must be
   * subscribed to.
   */
  public readonly uploadPercentage$: Observable<Progress | undefined>;
 
  /** Emits whenever a new profile photo is uploaded to re-fetch the list of all photos for the User. */
  private readonly _refreshFilesSubject$: BehaviorSubject<void>;
  private readonly _storage: Storage = inject(Storage);
  /** Triggers `uploadPercentage$` to track a new upload task. */
  private readonly _taskSubject$: Subject<UploadTask>;
 
  constructor() {
    this._refreshFilesSubject$ = new BehaviorSubject<void>(undefined);
    this._taskSubject$ = new Subject<UploadTask>();
 
    this.uploadPercentage$ = this._taskSubject$.pipe(
      switchMap((task: UploadTask): Observable<Progress | undefined> =>
        // First emit the upload progress as a percentage.
        // https://github.com/FirebaseExtended/rxfire/blob/b91f358e2c13c6bf33fb4a540e3963c3902a62b1/storage/index.ts#L115
        percentage(task).pipe(
          // Then when complete (100% progress), emit undefined to reset the Observable to falsy.
          endWith(undefined),
          // Inform `getProfilePhotos` that there are new files to fetch since storage doesn't stream StorageReferences.
          finalize((): void => { this._refreshFilesSubject$.next(); }),
        )),
    );
  }
 
  /**
   * Gets a list of profile photos from Firebase Storage for the UID. Sorted by most recently uploaded.
   * So long as something is subscribed to `uploadPercentage$` then this will be refreshed on each upload.
   */
  public getProfilePhotos(uid: string): Observable<Photo[]> {
    const profilePhotosRef = ref(this._storage, `${uid}/${PHOTO_DIR}`);
 
    return this._refreshFilesSubject$.pipe(
      // Using an inner Observable here to allow us to refresh the files list after each upload.
      // But this will only work if someone is subscribed to `uploadPercentage$`!
      switchMap(async (): Promise<Photo[]> => {
        const profilePhotos: Photo[] = [];
        const profilePhotosList: ListResult = await listAll(profilePhotosRef);
        const promises: Array<Promise<[PromiseSettledResult<string>, PromiseSettledResult<FullMetadata>]>> = [];
 
        // Need the metadata for sorting and the download URL for displaying & form values.
        // Note this does not handle nested folders, but we aren't using those.
        for (const item of profilePhotosList.items) {
          const promise = Promise.allSettled([ getDownloadURL(item), getMetadata(item) ]);
          promises.push(promise);
        }
 
        // Build the data structure for the Photos.
        for (const result of await Promise.all(promises)) {
          const [ settledUrl, settledMetadata ] = result;
          Iif (settledUrl.status === 'rejected') {
            console.error(settledUrl.reason);
          } else Iif (settledMetadata.status === 'rejected') {
            console.error(settledMetadata.reason);
          } else {
            profilePhotos.push({
              metadata: settledMetadata.value,
              url: settledUrl.value,
            });
          }
        }
 
        // Organize the photos by most recently uploaded.
        profilePhotos.sort((a: Photo, b: Photo): number => Number(new Date(b.metadata.updated)) - Number(new Date(a.metadata.updated)));
 
        return profilePhotos;
      }),
    );
  }
 
  /**
   * Uploads files to the user's profile photos directory. `uploadPercentage$` will track progress.
   *
   * Note that `files` is expected to be a list of one File. However the code is nominally designed
   * to handle multiple uploads. In that case the `uploadPercentage$` will only be for the final file
   * uploaded, which if the previous files are still going might be wrong. Unclear how to handle that
   * case at this time.
   */
  public uploadPhoto(files: FileList, uid: string): void {
    for (const file of files) {
      // Unlike AWS S3, Firebase storage knows what folders are, and requires you to traverse into them.
      // So for more straightforward access prefix the filename with a random value to avoid collisions
      // instead of using a folder.
      const prefix = Math.random().toString(RADIX).slice(SKIP_WHOLE_NUM);
      const path = `${uid}/${PHOTO_DIR}/${prefix}-${file.name}`;
      const storageRef = ref(this._storage, path);
      const task = uploadBytesResumable(storageRef, file);
      this._taskSubject$.next(task);
    }
  }
}