├── src ├── assets │ ├── .gitkeep │ ├── nsfw.png │ └── default.png ├── favicon.ico ├── app │ ├── shared │ │ ├── interfaces │ │ │ ├── settings.ts │ │ │ ├── reddit-pagination.ts │ │ │ ├── index.ts │ │ │ ├── gif.ts │ │ │ ├── reddit-response.ts │ │ │ └── reddit-post.ts │ │ ├── utils │ │ │ └── injection-tokens.ts │ │ └── data-access │ │ │ ├── reddit.service.spec.ts │ │ │ └── reddit.service.ts │ ├── app.routes.ts │ ├── app.component.ts │ ├── app.config.ts │ ├── home │ │ ├── ui │ │ │ ├── search-bar.component.ts │ │ │ ├── gif-list.component.ts │ │ │ ├── search-bar.component.spec.ts │ │ │ ├── gif-list.component.spec.ts │ │ │ ├── gif-player.component.ts │ │ │ └── gif-player.component.spec.ts │ │ ├── home.component.ts │ │ └── home.component.spec.ts │ └── app.component.spec.ts ├── main.ts ├── styles.scss └── index.html ├── README.md ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── tsconfig.spec.json ├── tsconfig.app.json ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── package.json └── angular.json /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuamorony/angularstart-giflist/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Giflist 2 | 3 | Check out the live demo of this app at [giflist.app](https://giflist.app) 4 | -------------------------------------------------------------------------------- /src/assets/nsfw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuamorony/angularstart-giflist/HEAD/src/assets/nsfw.png -------------------------------------------------------------------------------- /src/assets/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuamorony/angularstart-giflist/HEAD/src/assets/default.png -------------------------------------------------------------------------------- /src/app/shared/interfaces/settings.ts: -------------------------------------------------------------------------------- 1 | export interface Settings { 2 | perPage: number; 3 | sort: 'hot' | 'new'; 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /src/app/shared/interfaces/reddit-pagination.ts: -------------------------------------------------------------------------------- 1 | export interface RedditPagination { 2 | after: string | null; 3 | totalFound: number; 4 | retries: number; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/shared/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './gif'; 2 | export * from './reddit-pagination'; 3 | export * from './reddit-post'; 4 | export * from './reddit-response'; 5 | export * from './settings'; 6 | -------------------------------------------------------------------------------- /src/app/shared/utils/injection-tokens.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core'; 2 | 3 | export const WINDOW = new InjectionToken('The window object', { 4 | factory: () => window, 5 | }); 6 | -------------------------------------------------------------------------------- /src/app/shared/interfaces/gif.ts: -------------------------------------------------------------------------------- 1 | export interface Gif { 2 | src: string; 3 | author: string; 4 | name: string; 5 | permalink: string; 6 | title: string; 7 | thumbnail: string; 8 | comments: number; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | 3 | export const routes: Routes = [ 4 | { 5 | path: '', 6 | loadComponent: () => import('./home/home.component'), 7 | pathMatch: 'full', 8 | }, 9 | ]; 10 | -------------------------------------------------------------------------------- /src/app/shared/interfaces/reddit-response.ts: -------------------------------------------------------------------------------- 1 | import { RedditPost } from './reddit-post'; 2 | 3 | export interface RedditResponse { 4 | data: RedditResponseData; 5 | } 6 | 7 | interface RedditResponseData { 8 | children: RedditPost[]; 9 | } 10 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { appConfig } from './app/app.config'; 3 | import { AppComponent } from './app/app.component'; 4 | 5 | bootstrapApplication(AppComponent, appConfig) 6 | .catch((err) => console.error(err)); 7 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": ["jest"] 7 | }, 8 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | 3 | html, 4 | body { 5 | height: 100%; 6 | } 7 | body { 8 | margin: 0; 9 | font-family: Roboto, "Helvetica Neue", sans-serif; 10 | } 11 | 12 | .toolbar-spacer { 13 | flex: 1 1 auto; 14 | } 15 | 16 | .grid-container { 17 | display: grid; 18 | align-items: center; 19 | grid-template-columns: 1; 20 | } 21 | 22 | @media (min-width: 600px) { 23 | .grid-container { 24 | grid-template-columns: repeat(auto-fit, minmax(600px, 1fr)); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/app/shared/interfaces/reddit-post.ts: -------------------------------------------------------------------------------- 1 | export interface RedditPost { 2 | data: RedditPostData; 3 | } 4 | 5 | interface RedditPostData { 6 | author: string; 7 | name: string; 8 | permalink: string; 9 | preview: RedditPreview; 10 | secure_media: RedditMedia; 11 | title: string; 12 | media: RedditMedia; 13 | url: string; 14 | thumbnail: string; 15 | num_comments: number; 16 | } 17 | 18 | interface RedditPreview { 19 | reddit_video_preview: RedditVideoPreview; 20 | } 21 | 22 | interface RedditVideoPreview { 23 | is_gif: boolean; 24 | fallback_url: string; 25 | } 26 | 27 | interface RedditMedia { 28 | reddit_video: RedditVideoPreview; 29 | } 30 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Giflist 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, effect, inject } from '@angular/core'; 2 | import { RouterOutlet } from '@angular/router'; 3 | import { MatSnackBar } from '@angular/material/snack-bar'; 4 | import { RedditService } from './shared/data-access/reddit.service'; 5 | 6 | @Component({ 7 | selector: 'app-root', 8 | standalone: true, 9 | imports: [RouterOutlet], 10 | template: ` `, 11 | styles: [], 12 | }) 13 | export class AppComponent { 14 | redditService = inject(RedditService); 15 | snackBar = inject(MatSnackBar); 16 | 17 | constructor() { 18 | effect(() => { 19 | const error = this.redditService.gifsLoaded.error(); 20 | 21 | // if (error !== null) { 22 | // this.snackBar.open(error, 'Dismiss', { duration: 2000 }); 23 | // } 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationConfig, 3 | importProvidersFrom, 4 | provideBrowserGlobalErrorListeners, 5 | provideCheckNoChangesConfig, 6 | provideZonelessChangeDetection, 7 | } from '@angular/core'; 8 | import { provideRouter } from '@angular/router'; 9 | 10 | import { routes } from './app.routes'; 11 | import { provideHttpClient } from '@angular/common/http'; 12 | import { MatSnackBarModule } from '@angular/material/snack-bar'; 13 | import { provideAnimations } from '@angular/platform-browser/animations'; 14 | 15 | export const appConfig: ApplicationConfig = { 16 | providers: [ 17 | provideRouter(routes), 18 | provideZonelessChangeDetection(), 19 | provideBrowserGlobalErrorListeners(), 20 | provideCheckNoChangesConfig({ 21 | exhaustive: true, 22 | interval: 500, 23 | }), 24 | provideHttpClient(), 25 | importProvidersFrom(MatSnackBarModule), 26 | provideAnimations(), 27 | ], 28 | }; 29 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "esModuleInterop": true, 9 | "strict": true, 10 | "noImplicitOverride": true, 11 | "noPropertyAccessFromIndexSignature": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "sourceMap": true, 15 | "declaration": false, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "bundler", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "ES2022", 21 | "useDefineForClassFields": false, 22 | "lib": [ 23 | "ES2022", 24 | "dom" 25 | ] 26 | }, 27 | "angularCompilerOptions": { 28 | "enableI18nLegacyMessageIdFormat": false, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true, 32 | "_enabledBlockTypes": [ 33 | "if", 34 | "for", 35 | "switch", 36 | "defer" 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/home/ui/search-bar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, input } from '@angular/core'; 2 | import { FormControl, ReactiveFormsModule } from '@angular/forms'; 3 | import { MatIconModule } from '@angular/material/icon'; 4 | import { MatToolbarModule } from '@angular/material/toolbar'; 5 | import { MatFormFieldModule } from '@angular/material/form-field'; 6 | import { MatInputModule } from '@angular/material/input'; 7 | 8 | @Component({ 9 | standalone: true, 10 | selector: 'app-search-bar', 11 | template: ` 12 | 13 | 14 | 20 | search 21 | 22 | 23 | `, 24 | imports: [ 25 | ReactiveFormsModule, 26 | MatToolbarModule, 27 | MatIconModule, 28 | MatFormFieldModule, 29 | MatInputModule, 30 | ], 31 | styles: [ 32 | ` 33 | mat-toolbar { 34 | height: 80px; 35 | } 36 | 37 | mat-form-field { 38 | width: 100%; 39 | padding-top: 20px; 40 | } 41 | `, 42 | ], 43 | }) 44 | export class SearchBarComponent { 45 | subredditFormControl = input.required(); 46 | } 47 | -------------------------------------------------------------------------------- /src/app/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject } from '@angular/core'; 2 | import { GifListComponent } from './ui/gif-list.component'; 3 | import { RedditService } from '../shared/data-access/reddit.service'; 4 | import { SearchBarComponent } from './ui/search-bar.component'; 5 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 6 | import { InfiniteScrollModule } from 'ngx-infinite-scroll'; 7 | 8 | @Component({ 9 | standalone: true, 10 | selector: 'app-home', 11 | template: ` 12 | 15 | 16 | 26 | 27 | @if (redditService.gifsLoaded.isLoading()) { 28 | 29 | } 30 | `, 31 | imports: [ 32 | GifListComponent, 33 | SearchBarComponent, 34 | MatProgressSpinnerModule, 35 | InfiniteScrollModule, 36 | ], 37 | styles: [ 38 | ` 39 | mat-progress-spinner { 40 | margin: 2rem auto; 41 | } 42 | `, 43 | ], 44 | }) 45 | export default class HomeComponent { 46 | redditService = inject(RedditService); 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angularstart-giflist", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test" 10 | }, 11 | "private": true, 12 | "dependencies": { 13 | "@angular/animations": "^20.0.0-rc.1", 14 | "@angular/cdk": "^17.0.0-next.5", 15 | "@angular/common": "^20.0.0-rc.1", 16 | "@angular/compiler": "^20.0.0-rc.1", 17 | "@angular/core": "^20.0.0-rc.1", 18 | "@angular/forms": "^20.0.0-rc.1", 19 | "@angular/material": "^17.0.0-next.5", 20 | "@angular/platform-browser": "^20.0.0-rc.1", 21 | "@angular/platform-browser-dynamic": "^20.0.0-rc.1", 22 | "@angular/router": "^20.0.0-rc.1", 23 | "ngx-infinite-scroll": "^16.0.0", 24 | "rxjs": "~7.8.0", 25 | "tslib": "^2.3.0", 26 | "zone.js": "~0.15.0" 27 | }, 28 | "devDependencies": { 29 | "@angular-devkit/build-angular": "^20.0.0-rc.2", 30 | "@angular/cli": "~20.0.0-rc.2", 31 | "@angular/compiler-cli": "^20.0.0-rc.1", 32 | "@hirez_io/observer-spy": "^2.2.0", 33 | "@types/jasmine": "~4.3.0", 34 | "@types/jest": "^29.5.4", 35 | "jasmine-core": "~5.1.0", 36 | "jest": "^29.6.4", 37 | "jest-environment-jsdom": "^29.6.4", 38 | "karma": "~6.4.0", 39 | "karma-chrome-launcher": "~3.2.0", 40 | "karma-coverage": "~2.2.0", 41 | "karma-jasmine": "~5.1.0", 42 | "karma-jasmine-html-reporter": "~2.1.0", 43 | "typescript": "~5.8.3" 44 | } 45 | } -------------------------------------------------------------------------------- /src/app/home/ui/gif-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject, input } from '@angular/core'; 2 | import { MatToolbarModule } from '@angular/material/toolbar'; 3 | import { MatIconModule } from '@angular/material/icon'; 4 | import { MatButtonModule } from '@angular/material/button'; 5 | import { GifPlayerComponent } from './gif-player.component'; 6 | import { Gif } from 'src/app/shared/interfaces'; 7 | import { WINDOW } from 'src/app/shared/utils/injection-tokens'; 8 | 9 | @Component({ 10 | standalone: true, 11 | selector: 'app-gif-list', 12 | template: ` 13 | @for (gif of gifs(); track gif.permalink) { 14 |
15 | 20 | 21 | {{ gif.title }} 22 | 23 | 29 | 30 |
31 | } @empty { 32 |

Can't find any gifs 🤷

33 | } 34 | `, 35 | imports: [ 36 | GifPlayerComponent, 37 | MatToolbarModule, 38 | MatIconModule, 39 | MatButtonModule, 40 | ], 41 | styles: [ 42 | ` 43 | div { 44 | margin: 1rem; 45 | filter: drop-shadow(0px 0px 6px #0e0c1ba8); 46 | } 47 | 48 | mat-toolbar { 49 | white-space: break-spaces; 50 | } 51 | 52 | p { 53 | font-size: 2em; 54 | width: 100%; 55 | text-align: center; 56 | margin-top: 4rem; 57 | } 58 | `, 59 | ], 60 | }) 61 | export class GifListComponent { 62 | gifs = input.required(); 63 | window = inject(WINDOW); 64 | } 65 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { AppComponent } from './app.component'; 3 | import { MatSnackBar } from '@angular/material/snack-bar'; 4 | import { RedditService } from './shared/data-access/reddit.service'; 5 | import { signal } from '@angular/core'; 6 | 7 | describe('AppComponent', () => { 8 | let fixture: ComponentFixture; 9 | const mockErrorSignal = signal(null); 10 | let snackBar: MatSnackBar; 11 | 12 | beforeEach(() => { 13 | TestBed.configureTestingModule({ 14 | imports: [AppComponent], 15 | providers: [ 16 | { 17 | provide: RedditService, 18 | useValue: { 19 | error: mockErrorSignal, 20 | }, 21 | }, 22 | { 23 | provide: MatSnackBar, 24 | useValue: { 25 | open: jest.fn(), 26 | }, 27 | }, 28 | ], 29 | }); 30 | 31 | snackBar = TestBed.inject(MatSnackBar); 32 | fixture = TestBed.createComponent(AppComponent); 33 | }); 34 | 35 | it('should create the app', () => { 36 | const app = fixture.componentInstance; 37 | expect(app).toBeTruthy(); 38 | }); 39 | 40 | describe('effects', () => { 41 | it('should open snack bar with error state when error changes', () => { 42 | const testError = 'some error'; 43 | mockErrorSignal.set(testError); 44 | 45 | fixture.detectChanges(); 46 | 47 | expect(snackBar.open).toHaveBeenCalledWith(testError, 'Dismiss', { 48 | duration: 2000, 49 | }); 50 | }); 51 | it('should not open snack bar for null error messages', () => { 52 | const testError = 'some error'; 53 | mockErrorSignal.set(testError); 54 | fixture.detectChanges(); 55 | mockErrorSignal.set(null); 56 | fixture.detectChanges(); 57 | 58 | expect(snackBar.open).toHaveBeenCalledTimes(1); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/app/home/ui/search-bar.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { SearchBarComponent } from './search-bar.component'; 3 | import { By } from '@angular/platform-browser'; 4 | import { FormControl } from '@angular/forms'; 5 | 6 | import { Component, Input } from '@angular/core'; 7 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 8 | 9 | @Component({ 10 | standalone: true, 11 | selector: 'app-search-bar', 12 | template: `

Hello world

`, 13 | }) 14 | export class MockSearchBarComponent { 15 | @Input({ required: true }) subredditFormControl: any; 16 | } 17 | 18 | describe('SearchBarComponent', () => { 19 | let component: SearchBarComponent; 20 | let fixture: ComponentFixture; 21 | 22 | beforeEach(() => { 23 | TestBed.configureTestingModule({ 24 | imports: [SearchBarComponent, NoopAnimationsModule], 25 | }) 26 | .overrideComponent(SearchBarComponent, { 27 | remove: { imports: [] }, 28 | add: { imports: [] }, 29 | }) 30 | .compileComponents(); 31 | 32 | fixture = TestBed.createComponent(SearchBarComponent); 33 | component = fixture.componentInstance; 34 | component.subredditFormControl = new FormControl(); 35 | fixture.detectChanges(); 36 | }); 37 | 38 | it('should create', () => { 39 | expect(component).toBeTruthy(); 40 | }); 41 | 42 | describe('input: subredditFormControl', () => { 43 | it('form control should update when input supplied', () => { 44 | const testInput = 'hello'; 45 | 46 | const input = fixture.debugElement.query( 47 | By.css('[data-testid="subreddit-bar"] input') 48 | ); 49 | 50 | input.nativeElement.value = testInput; 51 | input.nativeElement.dispatchEvent(new Event('input')); 52 | 53 | fixture.detectChanges(); 54 | 55 | expect(component.subredditFormControl.value).toEqual(testInput); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/app/home/ui/gif-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { GifListComponent } from './gif-list.component'; 3 | import { By } from '@angular/platform-browser'; 4 | import { GifPlayerComponent } from './gif-player.component'; 5 | import { MockGifPlayerComponent } from './gif-player.component.spec'; 6 | 7 | import { Component, Input } from '@angular/core'; 8 | import { Gif } from 'src/app/shared/interfaces'; 9 | import { WINDOW } from 'src/app/shared/utils/injection-tokens'; 10 | 11 | @Component({ 12 | standalone: true, 13 | selector: 'app-gif-list', 14 | template: `

Hello world

`, 15 | }) 16 | export class MockGifListComponent { 17 | @Input({ required: true }) gifs!: Gif[]; 18 | } 19 | 20 | describe('GifListComponent', () => { 21 | let component: GifListComponent; 22 | let fixture: ComponentFixture; 23 | 24 | beforeEach(() => { 25 | TestBed.configureTestingModule({ 26 | imports: [GifListComponent], 27 | providers: [ 28 | { 29 | provide: WINDOW, 30 | useValue: { 31 | open: jest.fn(), 32 | }, 33 | }, 34 | ], 35 | }) 36 | .overrideComponent(GifListComponent, { 37 | remove: { imports: [GifPlayerComponent] }, 38 | add: { imports: [MockGifPlayerComponent] }, 39 | }) 40 | .compileComponents(); 41 | 42 | fixture = TestBed.createComponent(GifListComponent); 43 | component = fixture.componentInstance; 44 | fixture.detectChanges(); 45 | }); 46 | 47 | it('should create', () => { 48 | expect(component).toBeTruthy(); 49 | }); 50 | 51 | describe('input: gifs', () => { 52 | it('should render an app-gif-player for each element', () => { 53 | const testData = [{}, {}, {}] as any; 54 | component.gifs = testData; 55 | 56 | fixture.detectChanges(); 57 | 58 | const items = fixture.debugElement.queryAll(By.css('app-gif-player')); 59 | 60 | expect(items.length).toEqual(testData.length); 61 | }); 62 | }); 63 | 64 | describe('app-gif-player', () => { 65 | it('should use the src of the gif as the src input', () => { 66 | const testSrc = 'http://test.com/test.mp4'; 67 | const testData = [{ src: testSrc }] as any; 68 | component.gifs = testData; 69 | 70 | fixture.detectChanges(); 71 | 72 | const player = fixture.debugElement.query(By.css('app-gif-player')); 73 | 74 | expect(player.componentInstance.src).toEqual(testSrc); 75 | }); 76 | 77 | it('should use the thumbnail of the gif as the thumbnail input', () => { 78 | const testThumb = 'test.png'; 79 | const testData = [{ thumbnail: testThumb }] as any; 80 | component.gifs = testData; 81 | 82 | fixture.detectChanges(); 83 | 84 | const player = fixture.debugElement.query(By.css('app-gif-player')); 85 | 86 | expect(player.componentInstance.thumbnail).toEqual(testThumb); 87 | }); 88 | }); 89 | 90 | describe('title bar', () => { 91 | it('should launch the permalink when comments clicked', () => { 92 | const testLink = 'abc'; 93 | const testData = [{ permalink: testLink }] as any; 94 | const window = TestBed.inject(WINDOW); 95 | 96 | component.gifs = testData; 97 | 98 | fixture.detectChanges(); 99 | 100 | const link = fixture.debugElement.query(By.css('button')); 101 | 102 | link.nativeElement.click(); 103 | 104 | expect(window.open).toHaveBeenCalledWith( 105 | 'https://reddit.com/' + testLink 106 | ); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "angularstart-giflist": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "inlineTemplate": true, 11 | "inlineStyle": true, 12 | "style": "scss", 13 | "standalone": true 14 | }, 15 | "@schematics/angular:directive": { 16 | "standalone": true 17 | }, 18 | "@schematics/angular:pipe": { 19 | "standalone": true 20 | } 21 | }, 22 | "root": "", 23 | "sourceRoot": "src", 24 | "prefix": "app", 25 | "architect": { 26 | "build": { 27 | "builder": "@angular-devkit/build-angular:application", 28 | "options": { 29 | "outputPath": { 30 | "base": "dist/angularstart-giflist" 31 | }, 32 | "index": "src/index.html", 33 | "polyfills": ["zone.js"], 34 | "tsConfig": "tsconfig.app.json", 35 | "inlineStyleLanguage": "scss", 36 | "assets": ["src/favicon.ico", "src/assets"], 37 | "styles": [ 38 | "@angular/material/prebuilt-themes/pink-bluegrey.css", 39 | "src/styles.scss" 40 | ], 41 | "scripts": [], 42 | "browser": "src/main.ts" 43 | }, 44 | "configurations": { 45 | "production": { 46 | "budgets": [ 47 | { 48 | "type": "initial", 49 | "maximumWarning": "500kb", 50 | "maximumError": "1mb" 51 | }, 52 | { 53 | "type": "anyComponentStyle", 54 | "maximumWarning": "2kb", 55 | "maximumError": "4kb" 56 | } 57 | ], 58 | "outputHashing": "all" 59 | }, 60 | "development": { 61 | "optimization": false, 62 | "extractLicenses": false, 63 | "sourceMap": true, 64 | "namedChunks": true 65 | } 66 | }, 67 | "defaultConfiguration": "production" 68 | }, 69 | "serve": { 70 | "builder": "@angular-devkit/build-angular:dev-server", 71 | "configurations": { 72 | "production": { 73 | "buildTarget": "angularstart-giflist:build:production" 74 | }, 75 | "development": { 76 | "buildTarget": "angularstart-giflist:build:development" 77 | } 78 | }, 79 | "defaultConfiguration": "development" 80 | }, 81 | "extract-i18n": { 82 | "builder": "@angular-devkit/build-angular:extract-i18n", 83 | "options": { 84 | "buildTarget": "angularstart-giflist:build" 85 | } 86 | }, 87 | "test": { 88 | "builder": "@angular-devkit/build-angular:jest", 89 | "options": { 90 | "tsConfig": "tsconfig.spec.json", 91 | "polyfills": ["zone.js", "zone.js/testing"] 92 | } 93 | } 94 | } 95 | } 96 | }, 97 | "schematics": { 98 | "@schematics/angular:component": { 99 | "type": "component" 100 | }, 101 | "@schematics/angular:directive": { 102 | "type": "directive" 103 | }, 104 | "@schematics/angular:service": { 105 | "type": "service" 106 | }, 107 | "@schematics/angular:guard": { 108 | "typeSeparator": "." 109 | }, 110 | "@schematics/angular:interceptor": { 111 | "typeSeparator": "." 112 | }, 113 | "@schematics/angular:module": { 114 | "typeSeparator": "." 115 | }, 116 | "@schematics/angular:pipe": { 117 | "typeSeparator": "." 118 | }, 119 | "@schematics/angular:resolver": { 120 | "typeSeparator": "." 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/app/home/ui/gif-player.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | ElementRef, 4 | computed, 5 | effect, 6 | input, 7 | signal, 8 | viewChild, 9 | } from '@angular/core'; 10 | import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; 11 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 12 | import { Subject, fromEvent, switchMap } from 'rxjs'; 13 | 14 | interface GifPlayerState { 15 | playing: boolean; 16 | status: 'initial' | 'loading' | 'loaded'; 17 | } 18 | 19 | @Component({ 20 | standalone: true, 21 | selector: 'app-gif-player', 22 | template: ` 23 | @if (status() === 'loading') { 24 | 25 | } 26 |
34 | 43 |
44 | `, 45 | styles: [ 46 | ` 47 | :host { 48 | display: block; 49 | position: relative; 50 | overflow: hidden; 51 | max-height: 80vh; 52 | } 53 | 54 | .preload-background { 55 | width: 100%; 56 | height: auto; 57 | } 58 | 59 | .blur { 60 | filter: blur(10px) brightness(0.6); 61 | transform: scale(1.1); 62 | } 63 | 64 | video { 65 | width: 100%; 66 | max-height: 80vh; 67 | height: auto; 68 | margin: auto; 69 | background: transparent; 70 | } 71 | 72 | mat-progress-spinner { 73 | position: absolute; 74 | top: 2em; 75 | right: 2em; 76 | z-index: 1; 77 | } 78 | `, 79 | ], 80 | imports: [MatProgressSpinnerModule], 81 | }) 82 | export class GifPlayerComponent { 83 | src = input.required(); 84 | thumbnail = input.required(); 85 | 86 | videoElement = viewChild.required>('gifPlayer'); 87 | videoElement$ = toObservable(this.videoElement); 88 | 89 | state = signal({ 90 | playing: false, 91 | status: 'initial', 92 | }); 93 | 94 | //selectors 95 | playing = computed(() => this.state().playing); 96 | status = computed(() => this.state().status); 97 | 98 | // sources 99 | togglePlay$ = new Subject(); 100 | 101 | // note: unfortunately, we need to check if a play has been triggered here as 102 | // subscribing to the 'loadstart' event will actually trigger a load, which we 103 | // don't want unless it is supposed to be playing 104 | videoLoadStart$ = this.togglePlay$.pipe( 105 | switchMap(() => this.videoElement$), 106 | switchMap(({ nativeElement }) => fromEvent(nativeElement, 'loadstart')), 107 | ); 108 | 109 | videoLoadComplete$ = this.videoElement$.pipe( 110 | switchMap(({ nativeElement }) => fromEvent(nativeElement, 'loadeddata')), 111 | ); 112 | 113 | constructor() { 114 | //reducers 115 | this.videoLoadStart$ 116 | .pipe(takeUntilDestroyed()) 117 | .subscribe(() => 118 | this.state.update((state) => ({ ...state, status: 'loading' })), 119 | ); 120 | 121 | this.videoLoadComplete$ 122 | .pipe(takeUntilDestroyed()) 123 | .subscribe(() => 124 | this.state.update((state) => ({ ...state, status: 'loaded' })), 125 | ); 126 | 127 | this.togglePlay$ 128 | .pipe(takeUntilDestroyed()) 129 | .subscribe(() => 130 | this.state.update((state) => ({ ...state, playing: !state.playing })), 131 | ); 132 | 133 | // effects 134 | effect(() => { 135 | const { nativeElement: video } = this.videoElement(); 136 | const playing = this.playing(); 137 | const status = this.status(); 138 | 139 | if (!video) return; 140 | 141 | if (playing && status === 'initial') { 142 | video.load(); 143 | } 144 | 145 | if (status === 'loaded') { 146 | playing ? video.play() : video.pause(); 147 | } 148 | }); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/app/home/home.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import HomeComponent from './home.component'; 3 | import { RedditService } from '../shared/data-access/reddit.service'; 4 | import { DebugElement, signal } from '@angular/core'; 5 | import { By } from '@angular/platform-browser'; 6 | import { GifListComponent } from './ui/gif-list.component'; 7 | import { MockGifListComponent } from './ui/gif-list.component.spec'; 8 | import { SearchBarComponent } from './ui/search-bar.component'; 9 | import { MockSearchBarComponent } from './ui/search-bar.component.spec'; 10 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 11 | 12 | describe('HomeComponent', () => { 13 | let component: HomeComponent; 14 | let fixture: ComponentFixture; 15 | let redditService: RedditService; 16 | 17 | const testGifs = [{}, {}, {}]; 18 | const testControl = {}; 19 | 20 | const mockGifsSignal = signal([{}, {}, {}]); 21 | const mockLoadingSignal = signal(true); 22 | 23 | beforeEach(() => { 24 | TestBed.configureTestingModule({ 25 | imports: [HomeComponent], 26 | providers: [ 27 | { 28 | provide: RedditService, 29 | useValue: { 30 | gifs: mockGifsSignal, 31 | loading: mockLoadingSignal, 32 | subredditFormControl: testControl, 33 | pagination$: { 34 | next: jest.fn(), 35 | }, 36 | }, 37 | }, 38 | ], 39 | }) 40 | .overrideComponent(HomeComponent, { 41 | remove: { imports: [GifListComponent, SearchBarComponent] }, 42 | add: { imports: [MockGifListComponent, MockSearchBarComponent] }, 43 | }) 44 | .compileComponents(); 45 | 46 | fixture = TestBed.createComponent(HomeComponent); 47 | component = fixture.componentInstance; 48 | redditService = TestBed.inject(RedditService); 49 | mockLoadingSignal.set(true); 50 | mockGifsSignal.set([{}, {}, {}]); 51 | fixture.detectChanges(); 52 | }); 53 | 54 | it('should create', () => { 55 | expect(component).toBeTruthy(); 56 | }); 57 | 58 | describe('app-search-bar', () => { 59 | let searchBar: DebugElement; 60 | 61 | beforeEach(() => { 62 | searchBar = fixture.debugElement.query(By.css('app-search-bar')); 63 | }); 64 | 65 | describe('input: subredditFormControl', () => { 66 | it('should supply the subredditFormControl from reddit service', () => { 67 | expect(searchBar.componentInstance.subredditFormControl).toEqual( 68 | testControl 69 | ); 70 | }); 71 | }); 72 | }); 73 | 74 | describe('no-gifs', () => { 75 | it('should display no gifs message if not loading and no gifs', () => { 76 | mockGifsSignal.set([]); 77 | mockLoadingSignal.set(false); 78 | fixture.detectChanges(); 79 | 80 | const result = fixture.debugElement.query( 81 | By.css('[data-testid="no-gifs"]') 82 | ); 83 | 84 | expect(result).toBeTruthy(); 85 | }); 86 | }); 87 | 88 | describe('app-gif-list', () => { 89 | let gifList: DebugElement; 90 | 91 | beforeEach(() => { 92 | mockLoadingSignal.set(false); 93 | fixture.detectChanges(); 94 | gifList = fixture.debugElement.query(By.css('app-gif-list')); 95 | }); 96 | 97 | describe('output: scrolled', () => { 98 | it('should next the pagination$ source', () => { 99 | gifList.triggerEventHandler('scrolled', null); 100 | expect(redditService.pagination$.next).toHaveBeenCalled(); 101 | }); 102 | }); 103 | 104 | describe('input: gifs', () => { 105 | it('should supply the gifs selector from the reddit service', () => { 106 | expect(gifList.componentInstance.gifs).toEqual(testGifs); 107 | }); 108 | 109 | it('should display spinner instead of app-gif-list if loading state is true', () => { 110 | mockLoadingSignal.set(true); 111 | fixture.detectChanges(); 112 | 113 | const spinnerBefore = fixture.debugElement.query( 114 | By.css('mat-progress-spinner') 115 | ); 116 | const gifListBefore = fixture.debugElement.query( 117 | By.css('app-gif-list') 118 | ); 119 | 120 | expect(gifListBefore).toBeFalsy(); 121 | expect(spinnerBefore).toBeTruthy(); 122 | 123 | mockLoadingSignal.set(false); 124 | fixture.detectChanges(); 125 | 126 | const spinnerAfter = fixture.debugElement.query( 127 | By.css('mat-progress-spinner') 128 | ); 129 | const gifListAfter = fixture.debugElement.query(By.css('app-gif-list')); 130 | 131 | expect(gifListAfter).toBeTruthy(); 132 | expect(spinnerAfter).toBeFalsy(); 133 | }); 134 | }); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /src/app/shared/data-access/reddit.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, fakeAsync, tick } from '@angular/core/testing'; 2 | import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; 3 | import { RedditService } from './reddit.service'; 4 | import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; 5 | 6 | describe('RedditService', () => { 7 | let service: RedditService; 8 | let httpMock: HttpTestingController; 9 | 10 | const apiUrl = 'https://www.reddit.com/r/gifs/hot/.json?limit=100'; 11 | 12 | const mockPost = { 13 | data: { 14 | url: 'test.mp4', 15 | author: 'josh', 16 | name: 'whatever', 17 | permalink: 'link', 18 | title: 'title', 19 | thumbnail: 'thumb', 20 | num_comments: 5, 21 | }, 22 | }; 23 | 24 | const mockPostWithInvalidSrc = { 25 | ...mockPost, 26 | data: { ...mockPost.data, url: '' }, 27 | }; 28 | 29 | const mockData = { 30 | data: { 31 | children: [mockPost, mockPost, mockPost, mockPostWithInvalidSrc], 32 | }, 33 | }; 34 | 35 | const parsedPost = { 36 | src: mockPost.data.url, 37 | author: mockPost.data.author, 38 | name: mockPost.data.name, 39 | permalink: mockPost.data.permalink, 40 | title: mockPost.data.title, 41 | thumbnail: mockPost.data.thumbnail, 42 | comments: mockPost.data.num_comments, 43 | }; 44 | 45 | const expectedResults = [parsedPost, parsedPost, parsedPost] as any; 46 | 47 | beforeEach(() => { 48 | TestBed.configureTestingModule({ 49 | imports: [], 50 | providers: [RedditService, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()] 51 | }); 52 | 53 | service = TestBed.inject(RedditService); 54 | httpMock = TestBed.inject(HttpTestingController); 55 | }); 56 | 57 | it('should create', () => { 58 | expect(service).toBeTruthy(); 59 | }); 60 | 61 | describe('source: pagination$', () => { 62 | it('should trigger a request using the last gifs name as the after value', () => { 63 | const request = httpMock.expectOne(apiUrl); 64 | request.flush(mockData); 65 | 66 | const gifs = service.gifs(); 67 | const expectedAfter = gifs[gifs.length - 1].name; 68 | 69 | service.pagination$.next(); 70 | 71 | const requestTwo = httpMock.expectOne(apiUrl + '&after=' + expectedAfter); 72 | requestTwo.flush(mockData); 73 | }); 74 | }); 75 | 76 | describe('source: subredditChanged$', () => { 77 | it('should set loading state to true until complete', () => { 78 | expect(service.loading()).toEqual(true); 79 | 80 | const request = httpMock.expectOne(apiUrl); 81 | request.flush(mockData); 82 | 83 | expect(service.loading()).toEqual(false); 84 | }); 85 | 86 | it('should load data from specified subreddit when form control changes', fakeAsync(() => { 87 | // initial load 88 | const requestOne = httpMock.expectOne(apiUrl); 89 | requestOne.flush(mockData); 90 | 91 | const altMockData = { 92 | data: { 93 | children: [mockPost, mockPost], 94 | }, 95 | }; 96 | 97 | const expectedResults = [parsedPost, parsedPost] as any; 98 | 99 | const testValue = 'funny'; 100 | service.subredditFormControl.setValue(testValue); 101 | 102 | // wait for debounce time 103 | tick(300); 104 | 105 | const requestTwo = httpMock.expectOne(apiUrl.replace('gifs', testValue)); 106 | requestTwo.flush(altMockData); 107 | tick(); 108 | 109 | expect(service.gifs()).toEqual(expectedResults); 110 | })); 111 | 112 | it('should continue to allow subreddit switching after an error', fakeAsync(() => { 113 | // initial load 114 | const requestOne = httpMock.expectOne(apiUrl); 115 | requestOne.flush('', { status: 404, statusText: 'Not Found' }); 116 | 117 | const altMockData = { 118 | data: { 119 | children: [mockPost, mockPost], 120 | }, 121 | }; 122 | 123 | const expectedResults = [parsedPost, parsedPost] as any; 124 | 125 | const testValue = 'funny'; 126 | service.subredditFormControl.setValue(testValue); 127 | 128 | // wait for debounce time 129 | tick(300); 130 | 131 | const requestTwo = httpMock.expectOne(apiUrl.replace('gifs', testValue)); 132 | requestTwo.flush(altMockData); 133 | tick(); 134 | 135 | expect(service.gifs()).toEqual(expectedResults); 136 | })); 137 | 138 | it('should set error state if the fetch errors', () => { 139 | const requestOne = httpMock.expectOne(apiUrl); 140 | requestOne.flush('', { status: 404, statusText: 'Not Found' }); 141 | expect(service.error()).toEqual('Failed to load gifs for /r/gifs'); 142 | }); 143 | }); 144 | 145 | describe('source: gifsLoaded$', () => { 146 | it('should set gifs on initial load from gifs subreddit', () => { 147 | const request = httpMock.expectOne(apiUrl); 148 | request.flush(mockData); 149 | 150 | expect(service.gifs()).toEqual(expectedResults); 151 | }); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /src/app/home/ui/gif-player.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { GifPlayerComponent } from './gif-player.component'; 3 | import { By } from '@angular/platform-browser'; 4 | import { DebugElement, Input } from '@angular/core'; 5 | 6 | import { Component } from '@angular/core'; 7 | 8 | @Component({ 9 | standalone: true, 10 | selector: 'app-gif-player', 11 | template: `

Hello world

`, 12 | }) 13 | export class MockGifPlayerComponent { 14 | @Input({ required: true }) src!: string; 15 | @Input({ required: true }) thumbnail!: string; 16 | } 17 | 18 | describe('GifPlayerComponent', () => { 19 | let component: GifPlayerComponent; 20 | let fixture: ComponentFixture; 21 | 22 | beforeEach(() => { 23 | TestBed.configureTestingModule({ 24 | imports: [GifPlayerComponent], 25 | }) 26 | .overrideComponent(GifPlayerComponent, { 27 | remove: { imports: [] }, 28 | add: { imports: [] }, 29 | }) 30 | .compileComponents(); 31 | 32 | fixture = TestBed.createComponent(GifPlayerComponent); 33 | component = fixture.componentInstance; 34 | component.src = 'http://test.com/test.mp4'; 35 | component.thumbnail = 'test.png'; 36 | 37 | fixture.detectChanges(); 38 | }); 39 | 40 | it('should create', () => { 41 | expect(component).toBeTruthy(); 42 | }); 43 | 44 | describe('input: src', () => { 45 | it('should set the video src', () => { 46 | const testSrc = 'http://test.com/test.mp4'; 47 | component.src = testSrc; 48 | 49 | fixture.detectChanges(); 50 | 51 | const video = fixture.debugElement.query(By.css('video')); 52 | 53 | expect(video.nativeElement.src).toEqual(testSrc); 54 | }); 55 | }); 56 | 57 | describe('input: thumbnail', () => { 58 | xit('should use the supplied thumbnail for the preload background element', () => { 59 | const testThumb = 'test.png'; 60 | component.thumbnail = testThumb; 61 | 62 | fixture.detectChanges(); 63 | 64 | const result = fixture.debugElement.query(By.css('.preload-background')); 65 | 66 | const computedStyle = getComputedStyle(result.nativeElement); 67 | const backgroundStyle = computedStyle.background; 68 | 69 | console.log(backgroundStyle); 70 | 71 | expect(backgroundStyle).toContain(testThumb); 72 | }); 73 | }); 74 | 75 | describe('video', () => { 76 | let video: DebugElement; 77 | 78 | beforeEach(() => { 79 | video = fixture.debugElement.query(By.css('video')); 80 | video.nativeElement.pause = jest.fn(); 81 | video.nativeElement.play = jest.fn(); 82 | video.nativeElement.load = jest.fn().mockImplementation(() => { 83 | return new Promise((resolve) => { 84 | resolve(null); 85 | setTimeout(() => { 86 | video.nativeElement.dispatchEvent(new Event('loadeddata')); 87 | }, 0); 88 | }); 89 | }); 90 | }); 91 | 92 | it('should display spinner when loading', () => { 93 | const spinnerBeforeLoading = fixture.debugElement.query( 94 | By.css('mat-progress-spinner') 95 | ); 96 | expect(spinnerBeforeLoading).toBeFalsy(); 97 | 98 | video.nativeElement.click(); 99 | fixture.detectChanges(); 100 | 101 | const spinnerWhileLoading = fixture.debugElement.query( 102 | By.css('mat-progress-spinner') 103 | ); 104 | expect(spinnerWhileLoading).toBeTruthy(); 105 | 106 | fixture.whenStable().then(() => { 107 | const spinnerAfterLoading = fixture.debugElement.query( 108 | By.css('mat-progress-spinner') 109 | ); 110 | expect(spinnerAfterLoading).toBeFalsy(); 111 | }); 112 | }); 113 | 114 | describe('ready when clicked', () => { 115 | beforeEach(() => { 116 | component.videoLoadComplete$.next(); 117 | }); 118 | 119 | it('should play if paused', () => { 120 | video.nativeElement.click(); 121 | fixture.detectChanges(); 122 | 123 | expect(video.nativeElement.play).toHaveBeenCalled(); 124 | }); 125 | 126 | it('should pause if playing', () => { 127 | component.togglePlay$.next(); 128 | fixture.detectChanges(); 129 | 130 | video.nativeElement.click(); 131 | fixture.detectChanges(); 132 | 133 | expect(video.nativeElement.pause).toHaveBeenCalled(); 134 | }); 135 | }); 136 | 137 | describe('not ready when clicked', () => { 138 | it('should trigger loading of video', () => { 139 | video.nativeElement.click(); 140 | fixture.detectChanges(); 141 | 142 | expect(component.status()).toEqual('loading'); 143 | 144 | fixture.whenStable().then(() => { 145 | expect(component.status()).toEqual('loaded'); 146 | }); 147 | }); 148 | 149 | it('should play video after loaded if playing is true', () => { 150 | video.nativeElement.click(); 151 | fixture.detectChanges(); 152 | 153 | fixture.whenStable().then(() => { 154 | expect(video.nativeElement.play).toHaveBeenCalled(); 155 | }); 156 | }); 157 | 158 | it('should NOT play video after loaded if playing is false', () => { 159 | video.nativeElement.click(); 160 | video.nativeElement.click(); 161 | fixture.detectChanges(); 162 | 163 | fixture.whenStable().then(() => { 164 | expect(video.nativeElement.play).toHaveBeenCalled(); 165 | }); 166 | }); 167 | }); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /src/app/shared/data-access/reddit.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, inject, linkedSignal } from '@angular/core'; 2 | import { rxResource, toSignal } from '@angular/core/rxjs-interop'; 3 | import { HttpClient } from '@angular/common/http'; 4 | import { Gif, RedditPost, RedditResponse } from '../interfaces'; 5 | import { FormControl } from '@angular/forms'; 6 | import { EMPTY } from 'rxjs'; 7 | import { 8 | reduce, 9 | debounceTime, 10 | distinctUntilChanged, 11 | expand, 12 | map, 13 | startWith, 14 | } from 'rxjs/operators'; 15 | 16 | @Injectable({ providedIn: 'root' }) 17 | export class RedditService { 18 | private http = inject(HttpClient); 19 | private gifsPerPage = 5; 20 | 21 | subredditFormControl = new FormControl(); 22 | 23 | //sources 24 | private subredditChanged$ = this.subredditFormControl.valueChanges.pipe( 25 | debounceTime(300), 26 | distinctUntilChanged(), 27 | startWith('gifs'), 28 | map((subreddit) => (subreddit.length ? subreddit : 'gifs')), 29 | ); 30 | subreddit = toSignal(this.subredditChanged$); 31 | 32 | paginateAfter = linkedSignal({ 33 | source: this.subreddit, 34 | computation: () => null as string | null, 35 | }); 36 | 37 | gifsLoaded = rxResource({ 38 | params: () => ({ 39 | subreddit: this.subreddit(), 40 | paginateAfter: this.paginateAfter(), 41 | }), 42 | stream: ({ params }) => 43 | this.fetchRecursivelyFromReddit(params.subreddit, params.paginateAfter), 44 | }); 45 | 46 | gifs = linkedSignal, Gif[]>({ 47 | source: this.gifsLoaded.value, 48 | computation: (source, prev) => { 49 | // initial and page loads 50 | if (typeof source === 'undefined') return prev?.value ?? []; 51 | 52 | // clear on subreddit change 53 | if ( 54 | !prev || 55 | !prev.value[0]?.permalink.startsWith(`/r/${source.subreddit}`) 56 | ) 57 | return source.gifs; 58 | 59 | // accumulate values on paginate 60 | return [...prev.value, ...source.gifs]; 61 | }, 62 | }); 63 | 64 | private fetchFromReddit( 65 | subreddit: string, 66 | after: string | null, 67 | gifsRequired: number, 68 | ) { 69 | return this.http 70 | .get( 71 | `https://www.reddit.com/r/${subreddit}/hot/.json?limit=100` + 72 | (after ? `&after=${after}` : ''), 73 | ) 74 | .pipe( 75 | map((response) => { 76 | const posts = response.data.children; 77 | let gifs = this.convertRedditPostsToGifs(posts); 78 | let paginateAfter = posts.length 79 | ? posts[posts.length - 1].data.name 80 | : null; 81 | 82 | return { 83 | gifs, 84 | gifsRequired, 85 | paginateAfter, 86 | subreddit, 87 | }; 88 | }), 89 | ); 90 | } 91 | 92 | private fetchRecursivelyFromReddit( 93 | subreddit: string, 94 | paginateAfter: string | null, 95 | ) { 96 | return this.fetchFromReddit( 97 | subreddit, 98 | paginateAfter, 99 | this.gifsPerPage, 100 | ).pipe( 101 | // A single request might not give us enough valid gifs for a 102 | // full page, as not every post is a valid gif 103 | // Keep fetching more data until we do have enough for a page 104 | expand((response, index) => { 105 | const { gifs, gifsRequired, paginateAfter } = response; 106 | const remainingGifsToFetch = gifsRequired - gifs.length; 107 | const maxAttempts = 5; 108 | 109 | const shouldKeepTrying = 110 | remainingGifsToFetch > 0 && 111 | index < maxAttempts && 112 | paginateAfter !== null; 113 | 114 | return shouldKeepTrying 115 | ? this.fetchFromReddit(subreddit, paginateAfter, remainingGifsToFetch) 116 | : EMPTY; 117 | }), 118 | map((response) => { 119 | const { gifs, gifsRequired } = response; 120 | const remainingGifsToFetch = gifsRequired - gifs.length; 121 | 122 | if (remainingGifsToFetch < 0) { 123 | // trim to page size 124 | const trimmedGifs = response.gifs.slice(0, remainingGifsToFetch); 125 | return { 126 | ...response, 127 | gifs: trimmedGifs, 128 | paginateAfter: trimmedGifs[trimmedGifs.length - 1].name, 129 | }; 130 | } 131 | 132 | return response; 133 | }), 134 | reduce( 135 | (acc, curr) => ({ 136 | ...curr, 137 | gifs: [...acc.gifs, ...curr.gifs], 138 | }), 139 | { 140 | gifs: [] as Gif[], 141 | paginateAfter: null as string | null, 142 | gifsRequired: this.gifsPerPage, 143 | subreddit: 'gifs', 144 | }, 145 | ), 146 | ); 147 | } 148 | 149 | private convertRedditPostsToGifs(posts: RedditPost[]) { 150 | const defaultThumbnails = ['default', 'none', 'nsfw']; 151 | 152 | return posts 153 | .map((post) => { 154 | const thumbnail = post.data.thumbnail; 155 | const modifiedThumbnail = defaultThumbnails.includes(thumbnail) 156 | ? `/assets/${thumbnail}.png` 157 | : thumbnail; 158 | 159 | const validThumbnail = 160 | modifiedThumbnail.endsWith('.jpg') || 161 | modifiedThumbnail.endsWith('.png'); 162 | 163 | return { 164 | src: this.getBestSrcForGif(post), 165 | author: post.data.author, 166 | name: post.data.name, 167 | permalink: post.data.permalink, 168 | title: post.data.title, 169 | thumbnail: validThumbnail ? modifiedThumbnail : `/assets/default.png`, 170 | comments: post.data.num_comments, 171 | }; 172 | }) 173 | .filter((post): post is Gif => post.src !== null); 174 | } 175 | 176 | private getBestSrcForGif(post: RedditPost) { 177 | // If the source is in .mp4 format, leave unchanged 178 | if (post.data.url.indexOf('.mp4') > -1) { 179 | return post.data.url; 180 | } 181 | 182 | // If the source is in .gifv or .webm formats, convert to .mp4 and return 183 | if (post.data.url.indexOf('.gifv') > -1) { 184 | return post.data.url.replace('.gifv', '.mp4'); 185 | } 186 | 187 | if (post.data.url.indexOf('.webm') > -1) { 188 | return post.data.url.replace('.webm', '.mp4'); 189 | } 190 | 191 | // If the URL is not .gifv or .webm, check if media or secure media is available 192 | if (post.data.secure_media?.reddit_video) { 193 | return post.data.secure_media.reddit_video.fallback_url; 194 | } 195 | 196 | if (post.data.media?.reddit_video) { 197 | return post.data.media.reddit_video.fallback_url; 198 | } 199 | 200 | // If media objects are not available, check if a preview is available 201 | if (post.data.preview?.reddit_video_preview) { 202 | return post.data.preview.reddit_video_preview.fallback_url; 203 | } 204 | 205 | // No useable formats available 206 | return null; 207 | } 208 | } 209 | --------------------------------------------------------------------------------