Multicolumn Bluesky client powered by Angular

first version

Changed files
+4888 -360
src
app
components
core
models
services
shared
views
+3
.gitignore
··· 40 40 # System files 41 41 .DS_Store 42 42 Thumbs.db 43 + 44 + /docs 45 + .package-lock.json
+5
.postcssrc.json
··· 1 + { 2 + "plugins": { 3 + "@tailwindcss/postcss": {} 4 + } 5 + }
+10 -5
angular.json
··· 13 13 "build": { 14 14 "builder": "@angular-devkit/build-angular:application", 15 15 "options": { 16 - "outputPath": "dist/consolesky", 16 + "outputPath": { 17 + "base": "docs", 18 + "browser": "" 19 + }, 17 20 "index": "src/index.html", 18 21 "browser": "src/main.ts", 19 22 "polyfills": [ ··· 27 30 } 28 31 ], 29 32 "styles": [ 30 - "src/styles.css" 33 + "src/styles.css", 34 + "https://unpkg.com/video.js@8.22.0/dist/video-js.min.css" 31 35 ], 32 36 "scripts": [] 33 37 }, ··· 36 40 "budgets": [ 37 41 { 38 42 "type": "initial", 39 - "maximumWarning": "500kB", 40 - "maximumError": "1MB" 43 + "maximumWarning": "2MB", 44 + "maximumError": "5MB" 41 45 }, 42 46 { 43 47 "type": "anyComponentStyle", ··· 85 89 } 86 90 ], 87 91 "styles": [ 88 - "src/styles.css" 92 + "src/styles.css", 93 + "https://unpkg.com/video.js@8.22.0/dist/video-js.min.css" 89 94 ], 90 95 "scripts": [] 91 96 }
+39
main.js
··· 1 + const {app, BrowserWindow} = require('electron') 2 + const url = require("url"); 3 + const path = require("path"); 4 + 5 + let mainWindow 6 + 7 + function createWindow () { 8 + mainWindow = new BrowserWindow({ 9 + width: 800, 10 + height: 600, 11 + webPreferences: { 12 + nodeIntegration: true 13 + } 14 + }) 15 + 16 + mainWindow.loadURL( 17 + url.format({ 18 + pathname: path.join(__dirname, `docs/index.html`), 19 + protocol: "file:", 20 + slashes: true 21 + }) 22 + ); 23 + // Open the DevTools. 24 + mainWindow.webContents.openDevTools() 25 + 26 + mainWindow.on('closed', function () { 27 + mainWindow = null 28 + }) 29 + } 30 + 31 + app.on('ready', createWindow) 32 + 33 + app.on('window-all-closed', function () { 34 + if (process.platform !== 'darwin') app.quit() 35 + }) 36 + 37 + app.on('activate', function () { 38 + if (mainWindow === null) createWindow() 39 + })
+16 -2
package.json
··· 1 1 { 2 2 "name": "consolesky", 3 3 "version": "0.0.0", 4 + "main": "main.js", 4 5 "scripts": { 5 6 "ng": "ng", 6 7 "start": "ng serve", 7 8 "build": "ng build", 8 9 "watch": "ng build --watch --configuration development", 9 - "test": "ng test" 10 + "test": "ng test", 11 + "electron": "ng build --base-href ./ && electron ." 10 12 }, 11 13 "private": true, 12 14 "dependencies": { 15 + "@angular/cdk": "^19.2.8", 13 16 "@angular/common": "^19.2.0", 14 17 "@angular/compiler": "^19.2.0", 15 18 "@angular/core": "^19.2.0", ··· 17 20 "@angular/platform-browser": "^19.2.0", 18 21 "@angular/platform-browser-dynamic": "^19.2.0", 19 22 "@angular/router": "^19.2.0", 23 + "@angular/youtube-player": "^19.2.8", 24 + "@atproto/api": "^0.14.16", 25 + "@tailwindcss/postcss": "^4.0.17", 26 + "angular-mentions": "^1.5.0", 27 + "date-fns": "^4.1.0", 28 + "ngx-image-compress": "^18.1.5", 29 + "postcss": "^8.5.3", 20 30 "rxjs": "~7.8.0", 31 + "tailwindcss": "^4.0.17", 21 32 "tslib": "^2.3.0", 33 + "uuid": "^11.1.0", 22 34 "zone.js": "~0.15.0" 23 35 }, 24 36 "devDependencies": { ··· 26 38 "@angular/cli": "^19.2.5", 27 39 "@angular/compiler-cli": "^19.2.0", 28 40 "@types/jasmine": "~5.1.0", 41 + "electron": "^35.1.2", 29 42 "jasmine-core": "~5.6.0", 30 43 "karma": "~6.4.0", 31 44 "karma-chrome-launcher": "~3.2.0", 32 45 "karma-coverage": "~2.2.0", 33 46 "karma-jasmine": "~5.1.0", 34 47 "karma-jasmine-html-reporter": "~2.1.0", 35 - "typescript": "~5.7.2" 48 + "typescript": "~5.7.2", 49 + "video.js": "^8.22.0" 36 50 } 37 51 }
+3 -336
src/app/app.component.html
··· 1 - <!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> 2 - <!-- * * * * * * * * * * * The content below * * * * * * * * * * * --> 3 - <!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * --> 4 - <!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * --> 5 - <!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> 6 - <!-- * * * * * * * * * Delete the template below * * * * * * * * * --> 7 - <!-- * * * * * * * to get started with your project! * * * * * * * --> 8 - <!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> 9 - 10 - <style> 11 - :host { 12 - --bright-blue: oklch(51.01% 0.274 263.83); 13 - --electric-violet: oklch(53.18% 0.28 296.97); 14 - --french-violet: oklch(47.66% 0.246 305.88); 15 - --vivid-pink: oklch(69.02% 0.277 332.77); 16 - --hot-red: oklch(61.42% 0.238 15.34); 17 - --orange-red: oklch(63.32% 0.24 31.68); 18 - 19 - --gray-900: oklch(19.37% 0.006 300.98); 20 - --gray-700: oklch(36.98% 0.014 302.71); 21 - --gray-400: oklch(70.9% 0.015 304.04); 22 - 23 - --red-to-pink-to-purple-vertical-gradient: linear-gradient( 24 - 180deg, 25 - var(--orange-red) 0%, 26 - var(--vivid-pink) 50%, 27 - var(--electric-violet) 100% 28 - ); 29 - 30 - --red-to-pink-to-purple-horizontal-gradient: linear-gradient( 31 - 90deg, 32 - var(--orange-red) 0%, 33 - var(--vivid-pink) 50%, 34 - var(--electric-violet) 100% 35 - ); 36 - 37 - --pill-accent: var(--bright-blue); 38 - 39 - font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 40 - Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", 41 - "Segoe UI Symbol"; 42 - box-sizing: border-box; 43 - -webkit-font-smoothing: antialiased; 44 - -moz-osx-font-smoothing: grayscale; 45 - } 46 - 47 - h1 { 48 - font-size: 3.125rem; 49 - color: var(--gray-900); 50 - font-weight: 500; 51 - line-height: 100%; 52 - letter-spacing: -0.125rem; 53 - margin: 0; 54 - font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 55 - Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", 56 - "Segoe UI Symbol"; 57 - } 58 - 59 - p { 60 - margin: 0; 61 - color: var(--gray-700); 62 - } 63 - 64 - main { 65 - width: 100%; 66 - min-height: 100%; 67 - display: flex; 68 - justify-content: center; 69 - align-items: center; 70 - padding: 1rem; 71 - box-sizing: inherit; 72 - position: relative; 73 - } 74 - 75 - .angular-logo { 76 - max-width: 9.2rem; 77 - } 78 - 79 - .content { 80 - display: flex; 81 - justify-content: space-around; 82 - width: 100%; 83 - max-width: 700px; 84 - margin-bottom: 3rem; 85 - } 86 - 87 - .content h1 { 88 - margin-top: 1.75rem; 89 - } 90 - 91 - .content p { 92 - margin-top: 1.5rem; 93 - } 94 - 95 - .divider { 96 - width: 1px; 97 - background: var(--red-to-pink-to-purple-vertical-gradient); 98 - margin-inline: 0.5rem; 99 - } 100 - 101 - .pill-group { 102 - display: flex; 103 - flex-direction: column; 104 - align-items: start; 105 - flex-wrap: wrap; 106 - gap: 1.25rem; 107 - } 108 - 109 - .pill { 110 - display: flex; 111 - align-items: center; 112 - --pill-accent: var(--bright-blue); 113 - background: color-mix(in srgb, var(--pill-accent) 5%, transparent); 114 - color: var(--pill-accent); 115 - padding-inline: 0.75rem; 116 - padding-block: 0.375rem; 117 - border-radius: 2.75rem; 118 - border: 0; 119 - transition: background 0.3s ease; 120 - font-family: var(--inter-font); 121 - font-size: 0.875rem; 122 - font-style: normal; 123 - font-weight: 500; 124 - line-height: 1.4rem; 125 - letter-spacing: -0.00875rem; 126 - text-decoration: none; 127 - } 128 - 129 - .pill:hover { 130 - background: color-mix(in srgb, var(--pill-accent) 15%, transparent); 131 - } 132 - 133 - .pill-group .pill:nth-child(6n + 1) { 134 - --pill-accent: var(--bright-blue); 135 - } 136 - .pill-group .pill:nth-child(6n + 2) { 137 - --pill-accent: var(--french-violet); 138 - } 139 - .pill-group .pill:nth-child(6n + 3), 140 - .pill-group .pill:nth-child(6n + 4), 141 - .pill-group .pill:nth-child(6n + 5) { 142 - --pill-accent: var(--hot-red); 143 - } 144 - 145 - .pill-group svg { 146 - margin-inline-start: 0.25rem; 147 - } 148 - 149 - .social-links { 150 - display: flex; 151 - align-items: center; 152 - gap: 0.73rem; 153 - margin-top: 1.5rem; 154 - } 155 - 156 - .social-links path { 157 - transition: fill 0.3s ease; 158 - fill: var(--gray-400); 159 - } 160 - 161 - .social-links a:hover svg path { 162 - fill: var(--gray-900); 163 - } 164 - 165 - @media screen and (max-width: 650px) { 166 - .content { 167 - flex-direction: column; 168 - width: max-content; 169 - } 170 - 171 - .divider { 172 - height: 1px; 173 - width: 100%; 174 - background: var(--red-to-pink-to-purple-horizontal-gradient); 175 - margin-block: 1.5rem; 176 - } 177 - } 178 - </style> 179 - 180 - <main class="main"> 181 - <div class="content"> 182 - <div class="left-side"> 183 - <svg 184 - xmlns="http://www.w3.org/2000/svg" 185 - viewBox="0 0 982 239" 186 - fill="none" 187 - class="angular-logo" 188 - > 189 - <g clip-path="url(#a)"> 190 - <path 191 - fill="url(#b)" 192 - d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z" 193 - /> 194 - <path 195 - fill="url(#c)" 196 - d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z" 197 - /> 198 - </g> 199 - <defs> 200 - <radialGradient 201 - id="c" 202 - cx="0" 203 - cy="0" 204 - r="1" 205 - gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)" 206 - gradientUnits="userSpaceOnUse" 207 - > 208 - <stop stop-color="#FF41F8" /> 209 - <stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" /> 210 - <stop offset="1" stop-color="#FF41F8" stop-opacity="0" /> 211 - </radialGradient> 212 - <linearGradient 213 - id="b" 214 - x1="0" 215 - x2="982" 216 - y1="192" 217 - y2="192" 218 - gradientUnits="userSpaceOnUse" 219 - > 220 - <stop stop-color="#F0060B" /> 221 - <stop offset="0" stop-color="#F0070C" /> 222 - <stop offset=".526" stop-color="#CC26D5" /> 223 - <stop offset="1" stop-color="#7702FF" /> 224 - </linearGradient> 225 - <clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath> 226 - </defs> 227 - </svg> 228 - <h1>Hello, {{ title }}</h1> 229 - <p>Congratulations! Your app is running. 🎉</p> 230 - </div> 231 - <div class="divider" role="separator" aria-label="Divider"></div> 232 - <div class="right-side"> 233 - <div class="pill-group"> 234 - @for (item of [ 235 - { title: 'Explore the Docs', link: 'https://angular.dev' }, 236 - { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, 237 - { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, 238 - { title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' }, 239 - { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, 240 - ]; track item.title) { 241 - <a 242 - class="pill" 243 - [href]="item.link" 244 - target="_blank" 245 - rel="noopener" 246 - > 247 - <span>{{ item.title }}</span> 248 - <svg 249 - xmlns="http://www.w3.org/2000/svg" 250 - height="14" 251 - viewBox="0 -960 960 960" 252 - width="14" 253 - fill="currentColor" 254 - > 255 - <path 256 - d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z" 257 - /> 258 - </svg> 259 - </a> 260 - } 261 - </div> 262 - <div class="social-links"> 263 - <a 264 - href="https://github.com/angular/angular" 265 - aria-label="Github" 266 - target="_blank" 267 - rel="noopener" 268 - > 269 - <svg 270 - width="25" 271 - height="24" 272 - viewBox="0 0 25 24" 273 - fill="none" 274 - xmlns="http://www.w3.org/2000/svg" 275 - alt="Github" 276 - > 277 - <path 278 - d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z" 279 - /> 280 - </svg> 281 - </a> 282 - <a 283 - href="https://twitter.com/angular" 284 - aria-label="Twitter" 285 - target="_blank" 286 - rel="noopener" 287 - > 288 - <svg 289 - width="24" 290 - height="24" 291 - viewBox="0 0 24 24" 292 - fill="none" 293 - xmlns="http://www.w3.org/2000/svg" 294 - alt="Twitter" 295 - > 296 - <path 297 - d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" 298 - /> 299 - </svg> 300 - </a> 301 - <a 302 - href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw" 303 - aria-label="Youtube" 304 - target="_blank" 305 - rel="noopener" 306 - > 307 - <svg 308 - width="29" 309 - height="20" 310 - viewBox="0 0 29 20" 311 - fill="none" 312 - xmlns="http://www.w3.org/2000/svg" 313 - alt="Youtube" 314 - > 315 - <path 316 - fill-rule="evenodd" 317 - clip-rule="evenodd" 318 - d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z" 319 - /> 320 - </svg> 321 - </a> 322 - </div> 323 - </div> 324 - </div> 325 - </main> 326 - 327 - <!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> 328 - <!-- * * * * * * * * * * * The content above * * * * * * * * * * * * --> 329 - <!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * --> 330 - <!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * --> 331 - <!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> 332 - <!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * --> 333 - <!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> 334 - 335 - 336 - <router-outlet /> 1 + <div class="app-container"> 2 + <router-outlet></router-outlet> 3 + </div>
+2 -2
src/app/app.component.spec.ts
··· 1 - import { TestBed } from '@angular/core/testing'; 2 - import { AppComponent } from './app.component'; 1 + import {TestBed} from '@angular/core/testing'; 2 + import {AppComponent} from './app.component'; 3 3 4 4 describe('AppComponent', () => { 5 5 beforeEach(async () => {
+20 -3
src/app/app.component.ts
··· 1 - import { Component } from '@angular/core'; 2 - import { RouterOutlet } from '@angular/router'; 1 + import {Component} from '@angular/core'; 2 + import {Router, RouterOutlet} from '@angular/router'; 3 + import {AuthService} from '@core/auth/auth.service'; 4 + import {takeWhile} from 'rxjs'; 3 5 4 6 @Component({ 5 7 selector: 'app-root', ··· 8 10 styleUrl: './app.component.css' 9 11 }) 10 12 export class AppComponent { 11 - title = 'consolesky'; 13 + constructor( 14 + private authService: AuthService, 15 + private router: Router 16 + ) { 17 + this.initApp(); 18 + } 19 + 20 + initApp() { 21 + this.authService.authenticationState.pipe(takeWhile(res => !res, true)).subscribe(state => { 22 + if (state) { 23 + this.router.navigate(['']); 24 + } else { 25 + this.router.navigate(['login']); 26 + } 27 + }); 28 + } 12 29 }
+9 -4
src/app/app.config.ts
··· 1 - import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; 2 - import { provideRouter } from '@angular/router'; 1 + import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core'; 2 + import {provideRouter} from '@angular/router'; 3 3 4 - import { routes } from './app.routes'; 4 + import {routes} from './app.routes'; 5 + import {provideHttpClient} from '@angular/common/http'; 5 6 6 7 export const appConfig: ApplicationConfig = { 7 - providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)] 8 + providers: [ 9 + provideZoneChangeDetection({ eventCoalescing: true }), 10 + provideRouter(routes), 11 + provideHttpClient(), 12 + ] 8 13 };
+16 -2
src/app/app.routes.ts
··· 1 - import { Routes } from '@angular/router'; 1 + import {Routes} from '@angular/router'; 2 + import {AuthGuard} from '@core/auth/auth.guard'; 3 + import {DashboardComponent} from '@views/dashboard/dashboard.component'; 4 + import {LoginComponent} from '@views/login/login.component'; 2 5 3 - export const routes: Routes = []; 6 + export const routes: Routes = [ 7 + { 8 + path: '', 9 + canActivate: [AuthGuard], 10 + component: DashboardComponent, 11 + pathMatch: 'full' 12 + }, 13 + { 14 + path: 'login', 15 + component: LoginComponent 16 + } 17 + ];
+220
src/app/components/cards/notification-card/notification-card.component.html
··· 1 + <div 2 + class="flex px-2 py-3 gap-2" 3 + (click)="onClick.emit(notification())" 4 + > 5 + @if ( 6 + (notification() | isLikeNotification) || 7 + (notification() | isFollowNotification) || 8 + (notification() | isRepostNotification) || 9 + (notification() | isStarterPackNotification) 10 + ) { 11 + <ng-container 12 + [ngTemplateOutlet]="template" 13 + /> 14 + } 15 + </div> 16 + 17 + <ng-template 18 + #template 19 + > 20 + <div 21 + class="w-12 flex justify-center shrink-0" 22 + > 23 + @if (notification() | isLikeNotification) { 24 + <span 25 + class="material-icons-outlined !text-4xl !flex items-center justify-center w-8 h-8" 26 + >favorite_border</span> 27 + } 28 + @else if (notification() | isFollowNotification) { 29 + <span 30 + class="material-icons-outlined !text-4xl !flex items-center justify-center w-8 h-8" 31 + >person_add_alt</span> 32 + } 33 + @else if (notification() | isRepostNotification) { 34 + <span 35 + class="material-icons-outlined !text-4xl !flex items-center justify-center w-8 h-8" 36 + >repeat</span> 37 + } 38 + @else { 39 + starterpack 40 + } 41 + </div> 42 + 43 + <div 44 + class="flex flex-col min-w-0 grow gap-2" 45 + > 46 + <ng-container 47 + [ngTemplateOutlet]="authors" 48 + /> 49 + 50 + <ng-container 51 + [ngTemplateOutlet]="label" 52 + /> 53 + 54 + @if ( 55 + notification().reason == 'like' || 56 + notification().reason == 'repost' 57 + ) { 58 + <ng-container 59 + [ngTemplateOutlet]="postPreview" 60 + [ngTemplateOutletContext]="{attachedPost: post ? post() : undefined}" 61 + /> 62 + } 63 + </div> 64 + </ng-template> 65 + 66 + <ng-template 67 + #authors 68 + > 69 + <div 70 + class="flex gap-2" 71 + > 72 + @for (author of notification().authors | slice : 0: 5; track author.did) { 73 + <a 74 + (click)="openAuthor($event, author.did)" 75 + class="h-8 w-8 relative cursor-pointer" 76 + > 77 + <avatar 78 + [src]="author.avatar" 79 + /> 80 + </a> 81 + } 82 + @if (notification().authors.length > 5) { 83 + <span 84 + class="h-8 w-8 flex items-center justify-center" 85 + > 86 + +{{notification().authors.length - 5}} 87 + </span> 88 + } 89 + </div> 90 + </ng-template> 91 + 92 + <ng-template 93 + #label 94 + > 95 + <span 96 + > 97 + @switch (notification().authors.length) { 98 + @case (1) { 99 + <a 100 + (click)="openAuthor($event, notification().authors[0].did)" 101 + class="font-bold hover:underline cursor-pointer" 102 + >{{notification().authors[0] | displayName}}</a> 103 + } 104 + @case (2) { 105 + <a 106 + (click)="openAuthor($event, notification().authors[0].did)" 107 + class="font-bold hover:underline cursor-pointer" 108 + >{{notification().authors[0] | displayName}}</a> 109 + 110 + and 111 + 112 + <a 113 + (click)="openAuthor($event, notification().authors[1].did)" 114 + class="font-bold hover:underline cursor-pointer" 115 + >{{notification().authors[1] | displayName}}</a> 116 + } 117 + @default { 118 + <a 119 + (click)="openAuthor($event, notification().authors[0].did)" 120 + class="font-bold hover:underline cursor-pointer" 121 + >{{notification().authors[0] | displayName}}</a> 122 + 123 + and 124 + 125 + <a 126 + class="font-bold hover:underline cursor-pointer" 127 + [href]="" 128 + >{{notification().authors.length - 1}} more</a> 129 + } 130 + } 131 + 132 + @if (notification() | isLikeNotification) { 133 + liked your post 134 + } 135 + @else if (notification() | isFollowNotification) { 136 + followed you 137 + } 138 + @else if (notification() | isRepostNotification) { 139 + reposted your post 140 + } 141 + @else { 142 + added you to a starter pack 143 + } 144 + </span> 145 + </ng-template> 146 + 147 + <ng-template 148 + #postPreview 149 + let-attachedPost="attachedPost" 150 + > 151 + <div 152 + class="flex" 153 + > 154 + <div 155 + class="overflow-hidden shrink-0 h-5 w-9 flex items-center justify-center" 156 + > 157 + <span class="material-icons !text-[2.25em]">format_quote</span> 158 + </div> 159 + 160 + <div 161 + class="flex flex-col flex-1 min-w-0 gap-2 text-primary/50" 162 + > 163 + @if (attachedPost.record?.text?.length) { 164 + <span 165 + class="text-primary/50 text-sm" 166 + > 167 + {{attachedPost.record?.text}} 168 + </span> 169 + } 170 + 171 + @if (attachedPost.embed) { 172 + @if (attachedPost.embed | isEmbedImagesView) { 173 + <div 174 + class="flex gap-2" 175 + > 176 + @for (image of attachedPost.embed.images; track image.thumb) { 177 + <img 178 + [src]="image.thumb" 179 + [alt]="image.alt" 180 + class="h-16 w-16" 181 + /> 182 + } 183 + </div> 184 + } 185 + 186 + @if (attachedPost.embed | isEmbedVideoView) { 187 + <img 188 + [src]="attachedPost.embed.thumbnail" 189 + [alt]="attachedPost.embed.alt" 190 + class="h-16 w-16" 191 + /> 192 + } 193 + 194 + @if (attachedPost.embed | isEmbedRecordWithMediaView) { 195 + @if (attachedPost.embed.media | isEmbedImagesView) { 196 + <div 197 + class="flex gap-2" 198 + > 199 + @for (image of attachedPost.embed.media.images; track image.thumb) { 200 + <img 201 + [src]="image.thumb" 202 + [alt]="image.alt" 203 + class="h-16 w-16" 204 + /> 205 + } 206 + </div> 207 + } 208 + 209 + @if (attachedPost.embed.media | isEmbedVideoView) { 210 + <img 211 + [src]="attachedPost.embed.media.thumbnail" 212 + [alt]="attachedPost.embed.media.alt" 213 + class="h-16 w-16" 214 + /> 215 + } 216 + } 217 + } 218 + </div> 219 + </div> 220 + </ng-template>
+58
src/app/components/cards/notification-card/notification-card.component.ts
··· 1 + import { 2 + ChangeDetectionStrategy, 3 + ChangeDetectorRef, 4 + Component, 5 + input, 6 + OnInit, 7 + output, 8 + WritableSignal 9 + } from '@angular/core'; 10 + import {Notification} from '@models/notification'; 11 + import {AvatarComponent} from '@components/shared/avatar/avatar.component'; 12 + import {IsLikeNotificationPipe} from '@shared/pipes/type-guards/notifications/is-like-notification.pipe'; 13 + import {IsFollowNotificationPipe} from '@shared/pipes/type-guards/notifications/is-follow-notification.pipe'; 14 + import {IsRepostNotificationPipe} from '@shared/pipes/type-guards/notifications/is-repost-notification.pipe'; 15 + import {IsStarterPackNotificationPipe} from '@shared/pipes/type-guards/notifications/is-starterpack-notification.pipe'; 16 + import {NgTemplateOutlet, SlicePipe} from '@angular/common'; 17 + import {DisplayNamePipe} from '@shared/pipes/display-name.pipe'; 18 + import {AppBskyFeedDefs} from '@atproto/api'; 19 + import {IsEmbedImagesViewPipe} from '@shared/pipes/type-guards/is-embed-images-view.pipe'; 20 + import {IsEmbedVideoViewPipe} from '@shared/pipes/type-guards/is-embed-video-view.pipe'; 21 + import {IsEmbedRecordWithMediaViewPipe} from '@shared/pipes/type-guards/is-embed-recordwithmedia-view.pipe'; 22 + 23 + @Component({ 24 + selector: 'notification-card', 25 + imports: [ 26 + AvatarComponent, 27 + IsLikeNotificationPipe, 28 + IsFollowNotificationPipe, 29 + IsRepostNotificationPipe, 30 + IsStarterPackNotificationPipe, 31 + NgTemplateOutlet, 32 + SlicePipe, 33 + DisplayNamePipe, 34 + IsEmbedImagesViewPipe, 35 + IsEmbedVideoViewPipe, 36 + IsEmbedRecordWithMediaViewPipe 37 + ], 38 + templateUrl: './notification-card.component.html', 39 + changeDetection: ChangeDetectionStrategy.OnPush 40 + }) 41 + export class NotificationCardComponent implements OnInit { 42 + notification = input<Notification>(); 43 + onClick = output<Notification>(); 44 + post: WritableSignal<AppBskyFeedDefs.PostView>; 45 + 46 + constructor( 47 + private cdRef: ChangeDetectorRef 48 + ) {} 49 + 50 + ngOnInit() { 51 + this.post = this.notification().post; 52 + this.cdRef.markForCheck(); 53 + } 54 + 55 + openAuthor(event: Event, did: string) { 56 + //TODO: OpenAuthor 57 + } 58 + }
+296
src/app/components/cards/post-card/post-card.component.html
··· 1 + <div 2 + class="flex px-2 py-3 gap-2" 3 + > 4 + <avatar 5 + [src]="post().author.avatar" 6 + class="h-12 w-12 shrink-0" 7 + /> 8 + <div 9 + class="flex flex-col w-full min-w-0" 10 + > 11 + 12 + <ng-container 13 + [ngTemplateOutlet]="header" 14 + [ngTemplateOutletContext]="{author: post().author, reply: reply(), record: post().record, reason: reason()}" 15 + /> 16 + 17 + <ng-container 18 + [ngTemplateOutlet]="subheader" 19 + [ngTemplateOutletContext]="{reply: reply(), reason: reason()}" 20 + /> 21 + 22 + <ng-container 23 + [ngTemplateOutlet]="record" 24 + [ngTemplateOutletContext]="{record: post().record}" 25 + /> 26 + 27 + <ng-container 28 + [ngTemplateOutlet]="embed" 29 + [ngTemplateOutletContext]="{embed: post().embed}" 30 + /> 31 + 32 + <ng-container 33 + [ngTemplateOutlet]="info" 34 + /> 35 + </div> 36 + </div> 37 + 38 + <ng-template 39 + #header 40 + let-author="author" 41 + let-reply="reply" 42 + let-record="record" 43 + let-reason="reason" 44 + > 45 + <div 46 + class="flex mt-[0.15rem] w-full min-w-0" 47 + > 48 + <span 49 + class="font-bold [text-box:trim-both_cap_alphabetic] mr-1 grow-0 shrink-1 min-w-0 overflow-y-visible overflow-x-clip overflow-ellipsis whitespace-nowrap" 50 + >{{author | displayName}}</span> 51 + 52 + @if (reason | isFeedDefsReasonRepost) { 53 + <div 54 + class="h-0" 55 + > 56 + <span 57 + class="material-icons-outlined -translate-y-1 mr-1 text-repost" 58 + >repeat</span> 59 + </div> 60 + 61 + <span 62 + class="font-bold text-primary/50 [text-box:trim-both_cap_alphabetic] grow-1 shrink-0 max-w-1/2 min-w-0 overflow-y-visible overflow-x-clip overflow-ellipsis whitespace-nowrap" 63 + >{{reason.by | displayName}}</span> 64 + } 65 + 66 + <!-- @if (author.displayName?.length) {--> 67 + <!-- <span--> 68 + <!-- class="text-secondary [text-box:trim-both_cap_alphabetic] ml-2 shrink min-w-0 overflow-y-visible overflow-x-clip hidden whitespace-nowrap text-ellipsis"--> 69 + <!-- >{{'@' + author.handle}}</span>--> 70 + <!-- }--> 71 + 72 + @if (record | isFeedPostRecord) { 73 + <a 74 + [href]="post().uri | linkExtractor: author.handle" 75 + target="_blank" 76 + class="text-sm text-primary/50 hover:underline [text-box:trim-both_cap_alphabetic] shrink-0 ml-auto pl-3" 77 + >{{record.createdAt | dateFormatter}}</a> 78 + } 79 + </div> 80 + </ng-template> 81 + 82 + <ng-template 83 + #subheader 84 + let-reason="reason" 85 + let-reply="reply" 86 + > 87 + @if (reason || reply) { 88 + <div 89 + class="flex flex-col w-full" 90 + > 91 + @if (reply) { 92 + <div 93 + class="flex w-full min-w-0 mt-2.5" 94 + > 95 + <div 96 + class="h-0" 97 + > 98 + <span 99 + class="material-icons-outlined -translate-y-1.5 mr-1" 100 + >subdirectory_arrow_right</span> 101 + </div> 102 + 103 + @if (reply.parent | isFeedDefsPostView) { 104 + <span 105 + class="font-bold text-primary/50 [text-box:trim-both_cap_alphabetic] shrink-0 grow basis-0 min-w-0 overflow-y-visible overflow-x-clip overflow-ellipsis whitespace-nowrap" 106 + >{{reply.parent.author | displayName}}</span> 107 + } 108 + </div> 109 + } 110 + </div> 111 + } 112 + </ng-template> 113 + 114 + <ng-template 115 + #record 116 + let-record="record" 117 + > 118 + @if ((record | isFeedPostRecord) && record.text?.length) { 119 + <rich-text 120 + [text]="record.text" 121 + [facets]="record.facets" 122 + class="mt-2 text-sm" 123 + /> 124 + } 125 + </ng-template> 126 + 127 + <ng-template 128 + #embed 129 + let-embed="embed" 130 + > 131 + @if (embed | isEmbedRecordView) { 132 + <record-embed 133 + [record]="embed.record" 134 + class="mt-2 p-2 hover:bg-primary/2" 135 + /> 136 + } 137 + 138 + @if (embed | isEmbedImagesView) { 139 + <images-embed 140 + [images]="embed.images" 141 + class="mb-1" 142 + [class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'" 143 + /> 144 + } 145 + 146 + @if (embed | isEmbedVideoView) { 147 + <video-embed 148 + [embed]="embed" 149 + [class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'" 150 + /> 151 + } 152 + 153 + @if (embed | isEmbedExternalView) { 154 + <external-embed 155 + [external]="embed.external" 156 + [class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'" 157 + /> 158 + } 159 + 160 + @if (embed | isEmbedRecordWithMediaView) { 161 + @if (embed.media | isEmbedImagesView) { 162 + <images-embed 163 + [images]="embed.media.images" 164 + class="mb-1" 165 + [class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'" 166 + /> 167 + } 168 + 169 + @if (embed.media | isEmbedVideoView) { 170 + <video-embed 171 + [embed]="embed.media" 172 + [class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'" 173 + /> 174 + } 175 + 176 + @if (embed.media | isEmbedExternalView) { 177 + <external-embed 178 + [external]="embed.media.external" 179 + [class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'" 180 + /> 181 + } 182 + 183 + <record-embed 184 + [record]="embed.record.record" 185 + class="mt-2 p-2 hover:bg-primary/2" 186 + /> 187 + } 188 + </ng-template> 189 + 190 + <ng-template 191 + #info 192 + > 193 + <div 194 + class="flex mt-1 gap-4" 195 + > 196 + <div 197 + class="w-16" 198 + > 199 + <button 200 + class="flex w-fit h-7 p-2 items-center gap-1 hover:bg-primary/3 cursor-pointer" 201 + (click)="replyAction($event)" 202 + > 203 + <span 204 + class="material-icons-outlined !text-[14px]" 205 + >mode_comment</span> 206 + 207 + @if (post().replyCount) { 208 + <span 209 + class="[text-box:trim-both_cap_alphabetic]" 210 + >{{post().replyCount | numberFormatter}}</span> 211 + } 212 + </button> 213 + </div> 214 + 215 + <div 216 + class="w-16" 217 + > 218 + <button 219 + cdkOverlayOrigin 220 + #trigger="cdkOverlayOrigin" 221 + class="flex w-fit h-7 p-2 items-center gap-1 border-t border-l border-r border-transparent hover:bg-primary/3 cursor-pointer" 222 + [ngClass]="{'bg-primary/3 !border-primary' : rtMenuVisible}" 223 + (click)="!processingAction ? rtMenuVisible = !rtMenuVisible : undefined" 224 + > 225 + <span 226 + class="material-icons-outlined !text-[17px]" 227 + [class]="post().viewer.repost ? 'text-repost' : undefined" 228 + >repeat</span> 229 + 230 + @if (post().repostCount) { 231 + <span 232 + class="[text-box:trim-both_cap_alphabetic]" 233 + >{{post().repostCount | numberFormatter}}</span> 234 + } 235 + </button> 236 + 237 + <ng-template 238 + cdkConnectedOverlay 239 + [cdkConnectedOverlayOrigin]="trigger" 240 + [cdkConnectedOverlayOpen]="rtMenuVisible" 241 + (detach)="rtMenuVisible = false" 242 + (overlayOutsideClick)="rtMenuVisible = !rtMenuVisible" 243 + > 244 + <ul role="listbox" class="border border-primary"> 245 + <li> 246 + <button 247 + class="btn-dropdown" 248 + (click)="repostAction($event)" 249 + > 250 + {{post().viewer.repost ? 'Undo Repost' : 'Repost'}} 251 + </button> 252 + </li> 253 + 254 + @if (post().viewer.repost) { 255 + <li> 256 + <button 257 + class="btn-dropdown" 258 + (click)="refreshRepostAction($event)" 259 + > 260 + Repost again 261 + </button> 262 + </li> 263 + } 264 + 265 + <li> 266 + <button 267 + class="btn-dropdown" 268 + > 269 + Quote post 270 + </button> 271 + </li> 272 + </ul> 273 + </ng-template> 274 + </div> 275 + 276 + <div 277 + class="w-16" 278 + > 279 + <button 280 + class="flex w-fit h-7 p-2 items-center gap-1 hover:bg-primary/3 cursor-pointer" 281 + (click)="likeAction($event)" 282 + > 283 + <span 284 + class="material-icons-outlined transition" 285 + [class]="post().viewer.like ? 'text-like' : undefined" 286 + >{{post().viewer.like ? 'favorite' : 'favorite_border'}}</span> 287 + 288 + @if (post().likeCount) { 289 + <span 290 + class="[text-box:trim-both_cap_alphabetic]" 291 + >{{post().likeCount | numberFormatter}}</span> 292 + } 293 + </button> 294 + </div> 295 + </div> 296 + </ng-template>
+140
src/app/components/cards/post-card/post-card.component.ts
··· 1 + import {ChangeDetectionStrategy, ChangeDetectorRef, Component, input, model, OnDestroy, OnInit} from '@angular/core'; 2 + import {AppBskyFeedDefs} from '@atproto/api'; 3 + import {AvatarComponent} from '@components/shared/avatar/avatar.component'; 4 + import {DisplayNamePipe} from '@shared/pipes/display-name.pipe'; 5 + import {IsFeedPostRecordPipe} from '@shared/pipes/type-guards/is-feed-post-record'; 6 + import {RichTextComponent} from '@components/shared/rich-text/rich-text.component'; 7 + import {NgClass, NgTemplateOutlet} from '@angular/common'; 8 + import {IsFeedDefsPostViewPipe} from '@shared/pipes/type-guards/is-feed-defs-postview'; 9 + import {DateFormatterPipe} from '@shared/pipes/date-formatter.pipe'; 10 + import {IsEmbedRecordViewPipe} from '@shared/pipes/type-guards/is-embed-record-view.pipe'; 11 + import {RecordEmbedComponent} from '@components/embeds/record-embed/record-embed.component'; 12 + import {IsFeedDefsReasonRepostPipe} from '@shared/pipes/type-guards/is-feed-defs-reasonrepost'; 13 + import {IsEmbedImagesViewPipe} from '@shared/pipes/type-guards/is-embed-images-view.pipe'; 14 + import {ImagesEmbedComponent} from '@components/embeds/images-embed/images-embed.component'; 15 + import {LinkExtractorPipe} from '@shared/pipes/link-extractor.pipe'; 16 + import {IsEmbedVideoViewPipe} from '@shared/pipes/type-guards/is-embed-video-view.pipe'; 17 + import {VideoEmbedComponent} from '@components/embeds/video-embed/video-embed.component'; 18 + import {IsEmbedRecordWithMediaViewPipe} from '@shared/pipes/type-guards/is-embed-recordwithmedia-view.pipe'; 19 + import {NumberFormatterPipe} from '@shared/pipes/number-formatter.pipe'; 20 + import {PostService} from '@services/post.service'; 21 + import {OverlayModule} from '@angular/cdk/overlay'; 22 + import {ExternalEmbedComponent} from '@components/embeds/external-embed/external-embed.component'; 23 + import {IsEmbedExternalViewPipe} from '@shared/pipes/type-guards/is-embed-external-view.pipe'; 24 + 25 + @Component({ 26 + selector: 'post-card', 27 + imports: [ 28 + AvatarComponent, 29 + DisplayNamePipe, 30 + IsFeedPostRecordPipe, 31 + RichTextComponent, 32 + NgTemplateOutlet, 33 + IsFeedDefsPostViewPipe, 34 + DateFormatterPipe, 35 + IsEmbedRecordViewPipe, 36 + RecordEmbedComponent, 37 + IsFeedDefsReasonRepostPipe, 38 + IsEmbedImagesViewPipe, 39 + ImagesEmbedComponent, 40 + LinkExtractorPipe, 41 + IsEmbedVideoViewPipe, 42 + VideoEmbedComponent, 43 + IsEmbedRecordWithMediaViewPipe, 44 + NumberFormatterPipe, 45 + OverlayModule, 46 + NgClass, 47 + ExternalEmbedComponent, 48 + IsEmbedExternalViewPipe 49 + ], 50 + templateUrl: './post-card.component.html', 51 + changeDetection: ChangeDetectionStrategy.OnPush 52 + }) 53 + export class PostCardComponent implements OnInit, OnDestroy { 54 + post = model<AppBskyFeedDefs.PostView>(); 55 + reply = input<AppBskyFeedDefs.ReplyRef>(); 56 + reason = input<AppBskyFeedDefs.ReasonRepost | AppBskyFeedDefs.ReasonPin | { [k: string]: unknown; $type: string; }>(); 57 + 58 + refreshInterval: ReturnType<typeof setInterval>; 59 + processingAction = false; 60 + rtMenuVisible = false; 61 + 62 + constructor( 63 + private postService: PostService, 64 + private cdRef: ChangeDetectorRef 65 + ) {} 66 + 67 + ngOnInit() { 68 + this.refreshInterval = setInterval(() => this.cdRef.markForCheck(), 5e3); 69 + } 70 + 71 + ngOnDestroy() { 72 + clearInterval(this.refreshInterval); 73 + } 74 + 75 + replyAction(event: Event) { 76 + event.stopPropagation(); 77 + this.postService.replyPost(this.post().uri); 78 + } 79 + 80 + likeAction(event: Event) { 81 + event.stopPropagation(); 82 + if (this.processingAction) return; 83 + this.processingAction = true; 84 + let promise: Promise<void>; 85 + 86 + if (this.post().viewer.like) { 87 + promise = this.postService.deleteLike(this.post); 88 + } else { 89 + promise = this.postService.like(this.post); 90 + } 91 + 92 + promise 93 + .then(() => { 94 + this.cdRef.markForCheck(); 95 + }) 96 + .catch(err => { 97 + //TODO: MessageService 98 + }) 99 + .finally(() => this.processingAction = false); 100 + } 101 + 102 + repostAction(event: Event) { 103 + event.stopPropagation(); 104 + if (this.processingAction) return; 105 + this.rtMenuVisible = false; 106 + this.processingAction = true; 107 + let promise: Promise<void>; 108 + 109 + if (this.post().viewer.repost) { 110 + promise = this.postService.deleteRepost(this.post); 111 + } else { 112 + promise = this.postService.repost(this.post); 113 + } 114 + 115 + promise 116 + .then(() => { 117 + this.cdRef.markForCheck(); 118 + }) 119 + .catch(err => { 120 + //TODO: MessageService 121 + }) 122 + .finally(() => this.processingAction = false); 123 + } 124 + 125 + refreshRepostAction(event: Event) { 126 + event.stopPropagation(); 127 + if (this.processingAction) return; 128 + this.rtMenuVisible = false; 129 + this.processingAction = true; 130 + 131 + this.postService.refreshRepost(this.post) 132 + .then(() => { 133 + this.cdRef.markForCheck(); 134 + }) 135 + .catch(err => { 136 + //TODO: MessageService 137 + }) 138 + .finally(() => this.processingAction = false); 139 + } 140 + }
+19
src/app/components/deck-columns/author-deck-column/author-deck-column.component.html
··· 1 + <div 2 + class="flex flex-col h-full w-full" 3 + > 4 + <div 5 + class="flex h-9 w-full border-b shrink-0" 6 + > 7 + <span 8 + class="bg-primary text-bg text-xl font-medium flex items-center px-3 lowercase font-mono" 9 + >{{'@'+column().handle}}</span> 10 + </div> 11 + <div 12 + class="flex-1 min-h-0" 13 + > 14 + <author-feed 15 + [did]="column().did" 16 + class="block h-full" 17 + /> 18 + </div> 19 + </div>
+21
src/app/components/deck-columns/author-deck-column/author-deck-column.component.ts
··· 1 + import {booleanAttribute, ChangeDetectionStrategy, Component, input, model, output} from '@angular/core'; 2 + import {AuthorDeckColumn} from '@models/deck-column'; 3 + import {AuthorFeedComponent} from '@components/feeds/author-feed/author-feed.component'; 4 + 5 + @Component({ 6 + selector: 'author-deck-column', 7 + imports: [ 8 + AuthorFeedComponent 9 + ], 10 + templateUrl: './author-deck-column.component.html', 11 + changeDetection: ChangeDetectionStrategy.OnPush 12 + }) 13 + export class AuthorDeckColumnComponent { 14 + column = model.required<AuthorDeckColumn>(); 15 + firstIndex = input(false, {transform: booleanAttribute}); 16 + lastIndex = input(false, {transform: booleanAttribute}); 17 + reorderNext = output(); 18 + reorderPrev = output(); 19 + delete = output(); 20 + widthChange = output<number>(); 21 + }
+18
src/app/components/deck-columns/notification-deck-column/notification-deck-column.component.html
··· 1 + <div 2 + class="flex flex-col h-full w-full" 3 + > 4 + <div 5 + class="flex h-9 w-full border-b shrink-0" 6 + > 7 + <span 8 + class="bg-primary text-bg text-xl font-medium flex items-center px-3 lowercase font-mono" 9 + >{{column().title}}</span> 10 + </div> 11 + <div 12 + class="flex-1 min-h-0" 13 + > 14 + <notification-feed 15 + class="block h-full" 16 + /> 17 + </div> 18 + </div>
+21
src/app/components/deck-columns/notification-deck-column/notification-deck-column.component.ts
··· 1 + import {booleanAttribute, ChangeDetectionStrategy, Component, input, model, output} from '@angular/core'; 2 + import {NotificationDeckColumn} from '@models/deck-column'; 3 + import {NotificationFeedComponent} from '@components/feeds/notification-feed/notification-feed.component'; 4 + 5 + @Component({ 6 + selector: 'notification-deck-column', 7 + imports: [ 8 + NotificationFeedComponent 9 + ], 10 + templateUrl: './notification-deck-column.component.html', 11 + changeDetection: ChangeDetectionStrategy.OnPush 12 + }) 13 + export class NotificationDeckColumnComponent { 14 + column = model.required<NotificationDeckColumn>(); 15 + firstIndex = input(false, {transform: booleanAttribute}); 16 + lastIndex = input(false, {transform: booleanAttribute}); 17 + reorderNext = output(); 18 + reorderPrev = output(); 19 + delete = output(); 20 + widthChange = output<number>(); 21 + }
+18
src/app/components/deck-columns/timeline-deck-column/timeline-deck-column.component.html
··· 1 + <div 2 + class="flex flex-col h-full w-full" 3 + > 4 + <div 5 + class="flex h-9 w-full border-b shrink-0" 6 + > 7 + <span 8 + class="bg-primary text-bg text-xl font-medium flex items-center px-3 lowercase font-mono" 9 + >{{column().title}}</span> 10 + </div> 11 + <div 12 + class="flex-1 min-h-0" 13 + > 14 + <timeline-feed 15 + class="block h-full" 16 + /> 17 + </div> 18 + </div>
+21
src/app/components/deck-columns/timeline-deck-column/timeline-deck-column.component.ts
··· 1 + import {booleanAttribute, ChangeDetectionStrategy, Component, input, model, output} from '@angular/core'; 2 + import {TimelineDeckColumn} from '@models/deck-column'; 3 + import {TimelineFeedComponent} from '@components/feeds/timeline-feed/timeline-feed.component'; 4 + 5 + @Component({ 6 + selector: 'timeline-deck-column', 7 + imports: [ 8 + TimelineFeedComponent 9 + ], 10 + templateUrl: './timeline-deck-column.component.html', 11 + changeDetection: ChangeDetectionStrategy.OnPush 12 + }) 13 + export class TimelineDeckColumnComponent { 14 + column = model.required<TimelineDeckColumn>(); 15 + firstIndex = input(false, {transform: booleanAttribute}); 16 + lastIndex = input(false, {transform: booleanAttribute}); 17 + reorderNext = output(); 18 + reorderPrev = output(); 19 + delete = output(); 20 + widthChange = output<number>(); 21 + }
+55
src/app/components/embeds/external-embed/external-embed.component.html
··· 1 + @if (snippet.type == LinkSnippetType) { 2 + <a 3 + [href]="external().uri" 4 + target="_blank" 5 + (click)="$event.stopPropagation()" 6 + class="flex w-full bg-primary/2" 7 + > 8 + @if (external().thumb) { 9 + <img 10 + [ngSrc]="external().thumb" 11 + alt="thumb" 12 + width="1000" 13 + height="1000" 14 + class="h-16 w-16 object-cover" 15 + /> 16 + } 17 + 18 + <div 19 + class="flex flex-col justify-center px-2 hover:bg-primary/2" 20 + > 21 + <span 22 + class="font-semibold line-clamp-2 leading-[1.15]" 23 + >{{ external().title }}</span> 24 + <span 25 + class="text-xs" 26 + >{{ snippet.domain }}</span> 27 + </div> 28 + </a> 29 + } 30 + 31 + @if (snippet.type == BlueskyGifSnippetType) { 32 + <video 33 + #target 34 + class="video-js vjs-show-big-play-button-on-pause rounded-md overflow-hidden" 35 + (click)="$event.stopPropagation()" 36 + ></video> 37 + } 38 + 39 + @if (snippet.type == IframeSnippetType) { 40 + @if (snippet.source == YoutubeSnippetSource) { 41 + <youtube-player 42 + [videoId]="snippet.url" 43 + class="aspect-video" 44 + (click)="$event.stopPropagation()" 45 + /> 46 + } @else { 47 + <iframe 48 + [src]="safeURL" 49 + width="100%" 50 + allow="fullscreen" 51 + class="aspect-video" 52 + (click)="$event.stopPropagation()" 53 + ></iframe> 54 + } 55 + }
+102
src/app/components/embeds/external-embed/external-embed.component.ts
··· 1 + import { 2 + AfterViewInit, 3 + ChangeDetectionStrategy, 4 + Component, 5 + ElementRef, 6 + input, 7 + OnDestroy, 8 + OnInit, 9 + viewChildren 10 + } from '@angular/core'; 11 + import {AppBskyEmbedExternal} from "@atproto/api"; 12 + import {DomSanitizer, SafeResourceUrl} from "@angular/platform-browser"; 13 + import videojs from "video.js"; 14 + import type Player from "video.js/dist/types/player"; 15 + import {NgOptimizedImage} from "@angular/common"; 16 + import {BlueskyGifSnippet, IframeSnippet, LinkSnippet, SnippetSource, SnippetType} from '@models/snippet'; 17 + import {SnippetUtils} from '@shared/utils/snippet-utils'; 18 + import {YouTubePlayer} from '@angular/youtube-player'; 19 + 20 + type Options = typeof videojs.options; 21 + 22 + @Component({ 23 + selector: 'external-embed', 24 + imports: [ 25 + YouTubePlayer, 26 + NgOptimizedImage 27 + ], 28 + templateUrl: './external-embed.component.html', 29 + styles: ` 30 + :host(::ng-deep youtube-player) { 31 + display: flex; 32 + } 33 + :host(::ng-deep youtube-player) youtube-player-placeholder { 34 + width: 100% !important; 35 + height: unset !important; 36 + } 37 + :host(::ng-deep youtube-player) > div { 38 + width: 100%; 39 + } 40 + :host(::ng-deep youtube-player) > div iframe { 41 + height: unset; 42 + width: 100%; 43 + aspect-ratio: 16 / 9; 44 + } 45 + 46 + `, 47 + changeDetection: ChangeDetectionStrategy.OnPush 48 + }) 49 + export class ExternalEmbedComponent implements OnInit, OnDestroy, AfterViewInit { 50 + external = input<AppBskyEmbedExternal.ViewExternal>(); 51 + target = viewChildren<ElementRef<HTMLVideoElement>>('target'); 52 + 53 + player: Player; 54 + options: Options; 55 + snippet: LinkSnippet | BlueskyGifSnippet | IframeSnippet; 56 + safeURL: SafeResourceUrl; 57 + 58 + protected readonly LinkSnippetType = SnippetType.LINK; 59 + protected readonly BlueskyGifSnippetType = SnippetType.BLUESKY_GIF; 60 + protected readonly IframeSnippetType = SnippetType.IFRAME; 61 + protected readonly YoutubeSnippetSource = SnippetSource.YOUTUBE; 62 + 63 + constructor( 64 + private sanitizer: DomSanitizer, 65 + ) {} 66 + 67 + ngOnInit() { 68 + this.snippet = SnippetUtils.detectSnippet(this.external()); 69 + 70 + if (this.snippet.type === SnippetType.IFRAME) { 71 + this.safeURL = this.sanitizer.bypassSecurityTrustResourceUrl(this.snippet.url); 72 + } 73 + } 74 + 75 + ngAfterViewInit() { 76 + if (this.snippet.type === SnippetType.BLUESKY_GIF) { 77 + this.options = { 78 + fluid: true, 79 + aspectRatio: this.snippet.ratio, 80 + autoplay: true, 81 + loop: true, 82 + sources: { 83 + src: this.snippet.url, 84 + type: 'video/webm' 85 + }, 86 + controls: true, 87 + muted: true, 88 + playsinline: true, 89 + preload: 'none', 90 + bigPlayButton: true, 91 + controlBar: false, 92 + }; 93 + 94 + this.player = videojs(this.target()[0].nativeElement, this.options); 95 + } 96 + } 97 + 98 + ngOnDestroy() { 99 + this.player?.dispose(); 100 + } 101 + 102 + }
+54
src/app/components/embeds/images-embed/images-embed.component.html
··· 1 + @switch (images().length) { 2 + @case (1) { 3 + <img 4 + [ngSrc]="images()[0].thumb" 5 + [alt]="images()[0].alt" 6 + width="1000" 7 + height="1000" 8 + class="min-w-full min-h-full max-h-full w-auto h-auto object-cover pointer" 9 + (click)="imgClick(0, $event)" 10 + /> 11 + } 12 + @case (2) { 13 + <div class="grid grid-cols-[repeat(2,_1fr)] grid-rows-[repeat(1,_1fr)] gap-2 aspect-video overflow-hidden"> 14 + @for (image of images(); track image.thumb; let i = $index) { 15 + <img 16 + [ngSrc]="images()[i].thumb" 17 + [alt]="images()[i].alt" 18 + width="1000" 19 + height="1000" 20 + class="min-w-full min-h-full max-h-full w-auto h-auto object-cover pointer" 21 + (click)="imgClick(i, $event)" 22 + /> 23 + } 24 + </div> 25 + } 26 + @case (3) { 27 + <div class="grid grid-cols-[repeat(2,_1fr)] grid-rows-[repeat(2,_1fr)] gap-2 aspect-video overflow-hidden"> 28 + @for (image of images(); track image.thumb; let i = $index) { 29 + <img 30 + [ngSrc]="images()[i].thumb" 31 + [alt]="images()[i].alt" 32 + width="1000" 33 + height="1000" 34 + class="min-w-full min-h-full max-h-full w-auto h-auto object-cover pointer first:row-span-2 last:col-start-2 overflow-hidden" 35 + (click)="imgClick(i, $event)" 36 + /> 37 + } 38 + </div> 39 + } 40 + @case (4) { 41 + <div class="grid grid-cols-[repeat(2,_1fr)] grid-rows-[repeat(2,_1fr)] gap-2 aspect-[4/3] overflow-hidden"> 42 + @for (image of images(); track image.thumb; let i = $index) { 43 + <img 44 + [ngSrc]="images()[i].thumb" 45 + [alt]="images()[i].alt" 46 + width="1000" 47 + height="1000" 48 + class="min-w-full min-h-full max-h-full w-auto h-auto object-cover pointer" 49 + (click)="imgClick(i, $event)" 50 + /> 51 + } 52 + </div> 53 + } 54 + }
+20
src/app/components/embeds/images-embed/images-embed.component.ts
··· 1 + import {ChangeDetectionStrategy, Component, input, output} from '@angular/core'; 2 + import {AppBskyEmbedImages} from '@atproto/api'; 3 + import {NgOptimizedImage} from '@angular/common'; 4 + 5 + @Component({ 6 + selector: 'images-embed', 7 + imports: [ 8 + NgOptimizedImage 9 + ], 10 + templateUrl: './images-embed.component.html', 11 + changeDetection: ChangeDetectionStrategy.OnPush 12 + }) 13 + export class ImagesEmbedComponent { 14 + images = input<AppBskyEmbedImages.ViewImage[]>(); 15 + onClick = output<number>(); 16 + 17 + imgClick(index: number, event: Event) { 18 + 19 + } 20 + }
+191
src/app/components/embeds/record-embed/record-embed.component.html
··· 1 + @let postRecord = record(); 2 + 3 + <div 4 + class="flex" 5 + > 6 + <div 7 + class="overflow-hidden shrink-0 h-5 w-9 flex items-center justify-center" 8 + > 9 + <span class="material-icons !text-[2.25em]">format_quote</span> 10 + </div> 11 + 12 + <div 13 + class="flex flex-col flex-1 min-w-0 mt-1" 14 + > 15 + @if (postRecord | isEmbedRecordViewRecord) { 16 + <ng-container 17 + [ngTemplateOutlet]="viewRecord" 18 + [ngTemplateOutletContext]="{record: postRecord, media: postRecord.embeds ? postRecord.embeds[0] : undefined }" 19 + /> 20 + } 21 + 22 + @if (postRecord | isEmbedRecordViewBlocked) { 23 + <span> 24 + Post blocked 25 + </span> 26 + } 27 + 28 + @if (postRecord | isEmbedRecordViewNotFound) { 29 + <span> 30 + Post not found 31 + </span> 32 + } 33 + 34 + @if (postRecord | isEmbedRecordViewDetached) { 35 + <span> 36 + Post detached 37 + </span> 38 + } 39 + 40 + @if (postRecord | isFeedDefsGeneratorView) { 41 + <ng-container 42 + [ngTemplateOutlet]="feed" 43 + [ngTemplateOutletContext]="{feed: postRecord}" 44 + /> 45 + } 46 + 47 + @if (postRecord | isGraphDefsListView) { 48 + <ng-container 49 + [ngTemplateOutlet]="userList" 50 + [ngTemplateOutletContext]="{list: postRecord}" 51 + /> 52 + } 53 + 54 + <!-- Apparently there's no actual support yet? --> 55 + @if (postRecord | isLabelerDefsLabelerView) { 56 + <span> 57 + Labeler record 58 + </span> 59 + } 60 + 61 + @if (postRecord | isGraphDefsStarterPackViewBasic) { 62 + <ng-container 63 + [ngTemplateOutlet]="starterPack" 64 + [ngTemplateOutletContext]="{starterPack: postRecord}" 65 + /> 66 + } 67 + </div> 68 + </div> 69 + 70 + <ng-template 71 + #viewRecord 72 + let-record="record" 73 + let-media="media" 74 + > 75 + <span 76 + class="font-bold [text-box:trim-both_cap_alphabetic] shrink-1 grow-0 min-w-0 overflow-y-visible overflow-x-clip overflow-ellipsis whitespace-nowrap" 77 + >{{record.author | displayName}}</span> 78 + 79 + @if ((record.value | isFeedPostRecord) && record.value.text.length) { 80 + <rich-text 81 + [text]="record.value.text" 82 + class="mt-2" 83 + /> 84 + } 85 + 86 + @if (media) { 87 + <ng-container 88 + [ngTemplateOutlet]="mediaEmbeds" 89 + [ngTemplateOutletContext]="{ 90 + media: media, 91 + margin: (record.value | isFeedPostRecord) && record.value.text.length ? 'mt-2' : 'mt-3' 92 + }" 93 + /> 94 + } 95 + 96 + </ng-template> 97 + 98 + <ng-template 99 + #mediaEmbeds 100 + let-media="media" 101 + let-margin="margin" 102 + > 103 + @if (media | isEmbedImagesView) { 104 + <images-embed 105 + [images]="media.images" 106 + [class]="margin" 107 + /> 108 + } 109 + 110 + @if (media | isEmbedVideoView) { 111 + <video-embed 112 + [embed]="media" 113 + [class]="margin" 114 + /> 115 + } 116 + 117 + @if (media | isEmbedRecordWithMediaView) { 118 + @if (media.media | isEmbedImagesView) { 119 + <images-embed 120 + [images]="media.media.images" 121 + [class]="margin" 122 + /> 123 + } 124 + 125 + @if (media.media | isEmbedVideoView) { 126 + <video-embed 127 + [embed]="media.media" 128 + [class]="margin" 129 + /> 130 + } 131 + } 132 + </ng-template> 133 + 134 + <ng-template 135 + #feed 136 + let-feed="feed" 137 + > 138 + <span 139 + class="text-bold overflow-hidden whitespace-nowrap text-ellipsis" 140 + >{{feed.displayName}}</span> 141 + 142 + <span 143 + class="overflow-hidden whitespace-nowrap text-ellipsis" 144 + > 145 + @switch (feed.contentMode) { 146 + @case (AppBskyFeedDefs.CONTENTMODEVIDEO) { 147 + Video feed by {{ feed.creator | displayName }} 148 + } 149 + @default { 150 + Feed by {{ feed.creator | displayName }} 151 + } 152 + } 153 + </span> 154 + </ng-template> 155 + 156 + <ng-template 157 + #userList 158 + let-list="list" 159 + > 160 + <span 161 + class="text-bold overflow-hidden whitespace-nowrap text-ellipsis" 162 + >{{list.name}}</span> 163 + 164 + <span 165 + class="overflow-hidden whitespace-nowrap text-ellipsis" 166 + > 167 + @switch (list.purpose) { 168 + @case (AppBskyGraphDefs.MODLIST) { 169 + Mute list by {{ list.creator | displayName }} 170 + } 171 + @default { 172 + List by {{ list.creator | displayName }} 173 + } 174 + } 175 + </span> 176 + </ng-template> 177 + 178 + <ng-template 179 + #starterPack 180 + let-starterpack="starterPack" 181 + > 182 + <span 183 + class="text-bold overflow-hidden whitespace-nowrap text-ellipsis" 184 + >{{starterpack.record.name}}</span> 185 + 186 + <span 187 + class="overflow-hidden whitespace-nowrap text-ellipsis" 188 + > 189 + Starter pack by {{ starterpack.creator | displayName }} 190 + </span> 191 + </ng-template>
+59
src/app/components/embeds/record-embed/record-embed.component.ts
··· 1 + import {ChangeDetectionStrategy, Component, input} from '@angular/core'; 2 + import {$Typed, AppBskyEmbedRecord, AppBskyFeedDefs, AppBskyGraphDefs, AppBskyLabelerDefs} from '@atproto/api'; 3 + import {DisplayNamePipe} from '@shared/pipes/display-name.pipe'; 4 + import {IsEmbedRecordViewRecordPipe} from '@shared/pipes/type-guards/is-embed-record-viewrecord.pipe'; 5 + import {NgTemplateOutlet} from '@angular/common'; 6 + import {IsFeedPostRecordPipe} from '@shared/pipes/type-guards/is-feed-post-record'; 7 + import {RichTextComponent} from '@components/shared/rich-text/rich-text.component'; 8 + import {IsEmbedImagesViewPipe} from '@shared/pipes/type-guards/is-embed-images-view.pipe'; 9 + import {ImagesEmbedComponent} from '@components/embeds/images-embed/images-embed.component'; 10 + import {IsEmbedVideoViewPipe} from '@shared/pipes/type-guards/is-embed-video-view.pipe'; 11 + import {VideoEmbedComponent} from '@components/embeds/video-embed/video-embed.component'; 12 + import {IsEmbedRecordWithMediaViewPipe} from '@shared/pipes/type-guards/is-embed-recordwithmedia-view.pipe'; 13 + import {IsEmbedRecordViewBlockedPipe} from '@shared/pipes/type-guards/is-embed-record-viewblocked.pipe'; 14 + import {IsEmbedRecordViewNotFoundPipe} from '@shared/pipes/type-guards/is-embed-record-viewnotfound.pipe'; 15 + import {IsEmbedRecordViewDetachedPipe} from '@shared/pipes/type-guards/is-embed-record-viewdetached.pipe'; 16 + import {IsFeedDefsGeneratorViewPipe} from '@shared/pipes/type-guards/is-feed-defs-generator-view'; 17 + import {IsGraphDefsListViewPipe} from '@shared/pipes/type-guards/is-graph-defs-list-view'; 18 + import {IsLabelerDefsLabelerViewPipe} from '@shared/pipes/type-guards/is-labeler-defs-labeler-view'; 19 + import {IsGraphDefsStarterPackViewBasicPipe} from '@shared/pipes/type-guards/is-graph-defs-starterpack-viewbasic'; 20 + 21 + @Component({ 22 + selector: 'record-embed', 23 + imports: [ 24 + DisplayNamePipe, 25 + IsEmbedRecordViewRecordPipe, 26 + NgTemplateOutlet, 27 + IsFeedPostRecordPipe, 28 + RichTextComponent, 29 + IsEmbedImagesViewPipe, 30 + ImagesEmbedComponent, 31 + IsEmbedVideoViewPipe, 32 + VideoEmbedComponent, 33 + IsEmbedRecordWithMediaViewPipe, 34 + IsEmbedRecordViewBlockedPipe, 35 + IsEmbedRecordViewNotFoundPipe, 36 + IsEmbedRecordViewDetachedPipe, 37 + IsFeedDefsGeneratorViewPipe, 38 + IsGraphDefsListViewPipe, 39 + IsLabelerDefsLabelerViewPipe, 40 + IsGraphDefsStarterPackViewBasicPipe 41 + ], 42 + templateUrl: './record-embed.component.html', 43 + changeDetection: ChangeDetectionStrategy.OnPush 44 + }) 45 + export class RecordEmbedComponent { 46 + record = input< 47 + | $Typed<AppBskyEmbedRecord.ViewRecord> 48 + | $Typed<AppBskyEmbedRecord.ViewNotFound> 49 + | $Typed<AppBskyEmbedRecord.ViewBlocked> 50 + | $Typed<AppBskyEmbedRecord.ViewDetached> 51 + | $Typed<AppBskyFeedDefs.GeneratorView> 52 + | $Typed<AppBskyGraphDefs.ListView> 53 + | $Typed<AppBskyLabelerDefs.LabelerView> 54 + | $Typed<AppBskyGraphDefs.StarterPackViewBasic> 55 + | { $type: string } 56 + >(); 57 + protected readonly AppBskyFeedDefs = AppBskyFeedDefs; 58 + protected readonly AppBskyGraphDefs = AppBskyGraphDefs; 59 + }
+8
src/app/components/embeds/video-embed/video-embed.component.html
··· 1 + <video 2 + #target 3 + class="video-js cursor-pointer" 4 + controls 5 + muted 6 + playsinline 7 + preload="none" 8 + ></video>
+74
src/app/components/embeds/video-embed/video-embed.component.ts
··· 1 + import {ChangeDetectionStrategy, Component, ElementRef, input, OnDestroy, OnInit, viewChild} from '@angular/core'; 2 + import {AppBskyEmbedVideo} from "@atproto/api"; 3 + import videojs from "video.js"; 4 + import type Player from "video.js/dist/types/player"; 5 + 6 + type Options = typeof videojs.options; 7 + 8 + @Component({ 9 + selector: 'video-embed', 10 + templateUrl: './video-embed.component.html', 11 + styles: 12 + ` 13 + ::ng-deep .video-js .vjs-control-bar { 14 + background: linear-gradient(to top, rgba(43, 51, 63, 0.7), transparent); 15 + } 16 + ::ng-deep .video-js > .vjs-remaining-time { 17 + height: 0; 18 + position: absolute; 19 + bottom: 0; 20 + right: 0; 21 + font-size: 0.75rem; 22 + font-family: 'Inter', sans-serif; 23 + opacity: 0; 24 + } 25 + ::ng-deep .video-js.vjs-user-inactive .vjs-remaining-time { 26 + height: 2rem; 27 + opacity: 1; 28 + transition: 1.5s opacity ease; 29 + } 30 + `, 31 + changeDetection: ChangeDetectionStrategy.OnPush, 32 + }) 33 + export class VideoEmbedComponent implements OnInit, OnDestroy { 34 + embed = input<AppBskyEmbedVideo.View>(); 35 + target = viewChild('target', {read: ElementRef}); 36 + 37 + player: Player; 38 + options: Options; 39 + interacted = false; 40 + 41 + ngOnInit() { 42 + this.options = { 43 + fluid: true, 44 + aspectRatio: this.embed().aspectRatio ? `${this.embed().aspectRatio.width}:${this.embed().aspectRatio.height}` : '16:9', 45 + autoplay: true, 46 + sources: { 47 + src: this.embed().playlist, 48 + type: 'application/x-mpegURL' 49 + }, 50 + playsinline: true, 51 + preload: 'auto', 52 + loop: true, 53 + inactivityTimeout: 1000, 54 + userActions: { 55 + click: () => { 56 + if (this.interacted) { 57 + this.player.paused() ? this.player.play() : this.player.pause(); 58 + } else { 59 + this.player.loop(false); 60 + this.player.muted(false); 61 + this.interacted = true; 62 + } 63 + } 64 + } 65 + }; 66 + 67 + this.player = videojs(this.target().nativeElement, this.options); 68 + this.player.addChild('RemainingTimeDisplay', {}); 69 + } 70 + 71 + ngOnDestroy() { 72 + this.player?.dispose(); 73 + } 74 + }
+33
src/app/components/feeds/author-feed/author-feed.component.html
··· 1 + <div 2 + #feed 3 + class="w-full h-full min-h-0 flex flex-col margin-[0_auto] overflow-hidden hover:overflow-y-auto transition items-center" 4 + vScroll 5 + (scrollEnding)="nextData(); manageRefresh();" 6 + (scrollTop)="manageRefresh();" 7 + > 8 + @if (posts) { 9 + @for (post of posts; track post.uuid) { 10 + <post-card 11 + [post]="post.post()" 12 + [reply]="post.reply" 13 + [reason]="post.reason" 14 + (postChange)="post.post.set($event)" 15 + class="cursor-pointer hover:bg-primary/2 w-full" 16 + /> 17 + <div 18 + class="border-b border-b-primary/10 w-9/10" 19 + style="mask-image: linear-gradient(to right, transparent, #000000 50%, transparent);" 20 + ></div> 21 + } 22 + <!-- } @else {--> 23 + <!-- <div--> 24 + <!-- class="h-full w-full flex items-center justify-center"--> 25 + <!-- >--> 26 + <!-- <p-progress-spinner--> 27 + <!-- class="h-12"--> 28 + <!-- strokeWidth="5"--> 29 + <!-- [style]="{height: '3rem', width: '3rem'}"--> 30 + <!-- />--> 31 + <!-- </div>--> 32 + } 33 + </div>
+166
src/app/components/feeds/author-feed/author-feed.component.ts
··· 1 + import { 2 + ChangeDetectionStrategy, 3 + ChangeDetectorRef, 4 + Component, 5 + ElementRef, 6 + input, 7 + OnDestroy, 8 + OnInit, 9 + viewChild, 10 + } from '@angular/core'; 11 + import {CommonModule} from "@angular/common"; 12 + import {agent} from '@core/bsky.api'; 13 + import {ScrollDirective} from '@shared/directives/scroll.directive'; 14 + import {$Typed} from '@atproto/api'; 15 + import {ReasonRepost} from '@atproto/api/dist/client/types/app/bsky/feed/defs'; 16 + import {PostService} from '@services/post.service'; 17 + import {PostUtils} from '@shared/utils/post-utils'; 18 + import {SignalizedFeedViewPost} from '@models/signalized-feed-view-post'; 19 + import {from} from 'rxjs'; 20 + import {PostCardComponent} from '@components/cards/post-card/post-card.component'; 21 + 22 + @Component({ 23 + selector: 'author-feed', 24 + imports: [ 25 + CommonModule, 26 + ScrollDirective, 27 + PostCardComponent, 28 + ], 29 + templateUrl: './author-feed.component.html', 30 + changeDetection: ChangeDetectionStrategy.OnPush 31 + }) 32 + export class AuthorFeedComponent implements OnInit, OnDestroy { 33 + feed = viewChild<ElementRef>('feed'); 34 + did = input.required<string>(); 35 + 36 + posts: SignalizedFeedViewPost[]; 37 + cursor: string; 38 + loading = true; 39 + reloadReady = false; 40 + reloadTimeout: ReturnType<typeof setTimeout>; 41 + 42 + constructor( 43 + private postService: PostService, 44 + // private dialogService: MskyDialogService, 45 + public cdRef: ChangeDetectorRef 46 + ) {} 47 + 48 + ngOnInit() { 49 + this.initData(); 50 + 51 + // Listen to new posts to refresh 52 + this.postService.refreshFeeds.subscribe({ 53 + next: () => { 54 + if (this.feed().nativeElement.scrollTop == 0) { 55 + this.initData(); 56 + } else { 57 + this.reloadReady = true; 58 + } 59 + } 60 + }); 61 + } 62 + 63 + ngOnDestroy() { 64 + this.postService.refreshFeeds.unsubscribe(); 65 + clearTimeout(this.reloadTimeout); 66 + } 67 + 68 + initData() { 69 + this.loading = true; 70 + from(agent.getAuthorFeed({ 71 + actor: this.did(), 72 + limit: 15 73 + })).subscribe({ 74 + next: response => { 75 + this.cursor = response.data.cursor; 76 + this.posts = response.data.feed.map(fvp => PostUtils.parseFeedViewPost(fvp, this.postService)); 77 + this.cdRef.markForCheck(); 78 + setTimeout(() => { 79 + this.loading = false; 80 + this.manageRefresh(); 81 + }, 500); 82 + //TODO: MessageService 83 + }, error: err => console.log(err.message) 84 + }); 85 + } 86 + 87 + nextData() { 88 + if (this.loading) return; 89 + this.loading = true; 90 + 91 + from(agent.getAuthorFeed({ 92 + actor: this.did(), 93 + cursor: this.cursor, 94 + limit: 15 95 + })).subscribe({ 96 + next: response => { 97 + this.cursor = response.data.cursor; 98 + const newPosts = response.data.feed.map(fvp => PostUtils.parseFeedViewPost(fvp, this.postService)); 99 + this.posts = [...this.posts, ...newPosts]; 100 + this.cdRef.markForCheck(); 101 + setTimeout(() => { 102 + this.loading = false; 103 + }, 500); 104 + //TODO: MessageService 105 + }, error: err => console.log(err.message) 106 + }); 107 + } 108 + 109 + openPost(uri: string) { 110 + //TODO: OpenPost 111 + // Mute all video players 112 + // this.feed().nativeElement.querySelectorAll('video').forEach((video: HTMLVideoElement) => { 113 + // video.muted = true; 114 + // }); 115 + // 116 + // this.dialogService.openThread(uri, this.feed().nativeElement); 117 + } 118 + 119 + manageRefresh() { 120 + if (this.loading) return; 121 + 122 + if (!this.reloadReady && !this.reloadTimeout) { 123 + this.reloadTimeout = setTimeout(() => { 124 + this.reloadTimeout = undefined; 125 + 126 + if (this.feed().nativeElement.scrollTop == 0) { 127 + this.reloadReady = false; 128 + from(agent.getAuthorFeed({ 129 + actor: this.did(), 130 + limit: 1 131 + })).subscribe({ 132 + next: response => { 133 + const post = response.data.feed[0]; 134 + const lastPost = this.posts[0]; 135 + let isNewPost = false; 136 + 137 + if (post) { 138 + if (post.reason) { 139 + const reason = post.reason as $Typed<ReasonRepost>; 140 + if (!lastPost.reason) isNewPost = true; 141 + if (reason.indexedAt !== (lastPost.reason as $Typed<ReasonRepost>)?.indexedAt) isNewPost = true; 142 + } else { 143 + if (lastPost.reason) isNewPost = true; 144 + if (post.post.indexedAt !== lastPost.post().indexedAt) isNewPost = true; 145 + } 146 + } 147 + 148 + if (isNewPost) { 149 + this.initData(); 150 + } else { 151 + this.manageRefresh(); 152 + } 153 + //TODO: MessageService 154 + }, error: err => console.log(err.message) 155 + }); 156 + } else { 157 + this.reloadReady = true; 158 + } 159 + }, 30e3); 160 + // Timer in seconds 161 + } else if (this.reloadReady && this.feed().nativeElement.scrollTop == 0) { 162 + this.reloadReady = false; 163 + this.initData(); 164 + } 165 + } 166 + }
+39
src/app/components/feeds/notification-feed/notification-feed.component.html
··· 1 + <div 2 + #feed 3 + class="w-full h-full min-h-0 flex flex-col margin-[0_auto] overflow-hidden hover:overflow-y-auto transition items-center" 4 + vScroll 5 + (scrollEnding)="nextData(); manageRefresh();" 6 + (scrollTop)="manageRefresh();" 7 + > 8 + @if (notifications) { 9 + @for (notification of notifications; track notification.uuid) { 10 + @if (notification | isPostNotification) { 11 + <post-card 12 + [post]="notification.post()" 13 + (postChange)="notification.post.set($event)" 14 + class="cursor-pointer hover:bg-primary/2 w-full" 15 + /> 16 + } @else { 17 + <notification-card 18 + [notification]="notification" 19 + (onClick)="openNotification($event)" 20 + class="cursor-pointer hover:bg-primary/2 w-full" 21 + /> 22 + } 23 + <div 24 + class="border-b border-b-primary/10 w-9/10" 25 + style="mask-image: linear-gradient(to right, transparent, #000000 50%, transparent);" 26 + ></div> 27 + } 28 + <!-- } @else {--> 29 + <!-- <div--> 30 + <!-- class="h-full w-full flex items-center justify-center"--> 31 + <!-- >--> 32 + <!-- <p-progress-spinner--> 33 + <!-- class="h-12"--> 34 + <!-- strokeWidth="5"--> 35 + <!-- [style]="{height: '3rem', width: '3rem'}"--> 36 + <!-- />--> 37 + <!-- </div>--> 38 + } 39 + </div>
+146
src/app/components/feeds/notification-feed/notification-feed.component.ts
··· 1 + import { 2 + ChangeDetectionStrategy, 3 + ChangeDetectorRef, 4 + Component, 5 + ElementRef, 6 + OnDestroy, 7 + OnInit, 8 + viewChild, 9 + } from '@angular/core'; 10 + import {CommonModule} from "@angular/common"; 11 + import {agent} from '@core/bsky.api'; 12 + import {ScrollDirective} from '@shared/directives/scroll.directive'; 13 + import {PostService} from '@services/post.service'; 14 + import {from} from 'rxjs'; 15 + import {PostCardComponent} from '@components/cards/post-card/post-card.component'; 16 + import NotificationUtils from '@shared/utils/notification-utils'; 17 + import {Notification} from '@models/notification'; 18 + import {IsNotificationArrayPipe} from '@shared/pipes/type-guards/notifications/is-post-notification'; 19 + import {NotificationCardComponent} from '@components/cards/notification-card/notification-card.component'; 20 + 21 + @Component({ 22 + selector: 'notification-feed', 23 + imports: [ 24 + CommonModule, 25 + ScrollDirective, 26 + PostCardComponent, 27 + IsNotificationArrayPipe, 28 + NotificationCardComponent, 29 + ], 30 + templateUrl: './notification-feed.component.html', 31 + changeDetection: ChangeDetectionStrategy.OnPush 32 + }) 33 + export class NotificationFeedComponent implements OnInit, OnDestroy { 34 + feed = viewChild<ElementRef>('feed'); 35 + 36 + notifications: Notification[]; 37 + cursor: string; 38 + loading = true; 39 + reloadReady = false; 40 + reloadTimeout: ReturnType<typeof setTimeout>; 41 + 42 + constructor( 43 + private postService: PostService, 44 + public cdRef: ChangeDetectorRef 45 + ) {} 46 + 47 + ngOnInit() { 48 + this.initData(); 49 + } 50 + 51 + ngOnDestroy() { 52 + clearTimeout(this.reloadTimeout); 53 + } 54 + 55 + initData() { 56 + this.loading = true; 57 + from(agent.listNotifications({ 58 + limit: 15 59 + })).subscribe({ 60 + next: response => { 61 + this.cursor = response.data.cursor; 62 + NotificationUtils.parseNotifications(response.data.notifications, this.postService) 63 + .then(notifications => { 64 + this.notifications = notifications; 65 + this.cdRef.markForCheck(); 66 + setTimeout(() => { 67 + this.loading = false; 68 + this.manageRefresh(); 69 + }, 500); 70 + }); 71 + //TODO: MessageService 72 + }, error: err => console.log(err.message) 73 + }); 74 + } 75 + 76 + nextData() { 77 + if (this.loading) return; 78 + this.loading = true; 79 + 80 + from(agent.listNotifications({ 81 + cursor: this.cursor, 82 + limit: 15 83 + })).subscribe({ 84 + next: response => { 85 + this.cursor = response.data.cursor; 86 + NotificationUtils.parseNotifications(response.data.notifications, this.postService) 87 + .then(notifications => { 88 + this.notifications = [...this.notifications, ...notifications]; 89 + this.cdRef.markForCheck(); 90 + setTimeout(() => { 91 + this.loading = false; 92 + this.manageRefresh(); 93 + }, 500); 94 + }); 95 + setTimeout(() => { 96 + this.loading = false; 97 + }, 500); 98 + //TODO: MessageService 99 + }, error: err => console.log(err.message) 100 + }); 101 + } 102 + 103 + openNotification(notification: Notification) { 104 + //TODO: OpenNotification 105 + // Mute all video players 106 + // this.feed().nativeElement.querySelectorAll('video').forEach((video: HTMLVideoElement) => { 107 + // video.muted = true; 108 + // }); 109 + // 110 + // this.dialogService.openThread(uri, this.feed().nativeElement); 111 + } 112 + 113 + manageRefresh() { 114 + if (this.loading) return; 115 + 116 + if (!this.reloadReady && !this.reloadTimeout) { 117 + this.reloadTimeout = setTimeout(() => { 118 + this.reloadTimeout = undefined; 119 + 120 + if (this.feed().nativeElement.scrollTop == 0) { 121 + this.reloadReady = false; 122 + from(agent.listNotifications({ 123 + limit: 1 124 + })).subscribe({ 125 + next: response => { 126 + const notification = response.data.notifications[0]; 127 + const lastNotification = this.notifications[0]; 128 + 129 + if (notification?.indexedAt !== lastNotification?.notification.indexedAt) { 130 + this.initData(); 131 + } else { 132 + this.manageRefresh(); 133 + } 134 + } 135 + }); 136 + } else { 137 + this.reloadReady = true; 138 + } 139 + }, 30e3); 140 + // Timer in seconds 141 + } else if (this.reloadReady && this.feed().nativeElement.scrollTop == 0) { 142 + this.reloadReady = false; 143 + this.initData(); 144 + } 145 + } 146 + }
+33
src/app/components/feeds/timeline-feed/timeline-feed.component.html
··· 1 + <div 2 + #feed 3 + class="w-full h-full min-h-0 flex flex-col margin-[0_auto] overflow-hidden hover:overflow-y-auto transition items-center" 4 + vScroll 5 + (scrollEnding)="nextData(); manageRefresh();" 6 + (scrollTop)="manageRefresh();" 7 + > 8 + @if (posts) { 9 + @for (post of posts; track post.uuid) { 10 + <post-card 11 + [post]="post.post()" 12 + [reply]="post.reply" 13 + [reason]="post.reason" 14 + (postChange)="post.post.set($event)" 15 + class="cursor-pointer hover:bg-primary/2 w-full" 16 + /> 17 + <div 18 + class="border-b border-b-primary/10 w-9/10" 19 + style="mask-image: linear-gradient(to right, transparent, #000000 50%, transparent);" 20 + ></div> 21 + } 22 + <!-- } @else {--> 23 + <!-- <div--> 24 + <!-- class="h-full w-full flex items-center justify-center"--> 25 + <!-- >--> 26 + <!-- <p-progress-spinner--> 27 + <!-- class="h-12"--> 28 + <!-- strokeWidth="5"--> 29 + <!-- [style]="{height: '3rem', width: '3rem'}"--> 30 + <!-- />--> 31 + <!-- </div>--> 32 + } 33 + </div>
+161
src/app/components/feeds/timeline-feed/timeline-feed.component.ts
··· 1 + import { 2 + ChangeDetectionStrategy, 3 + ChangeDetectorRef, 4 + Component, 5 + ElementRef, 6 + OnDestroy, 7 + OnInit, 8 + viewChild, 9 + } from '@angular/core'; 10 + import {CommonModule} from "@angular/common"; 11 + import {agent} from '@core/bsky.api'; 12 + import {ScrollDirective} from '@shared/directives/scroll.directive'; 13 + import {$Typed} from '@atproto/api'; 14 + import {ReasonRepost} from '@atproto/api/dist/client/types/app/bsky/feed/defs'; 15 + import {PostService} from '@services/post.service'; 16 + import {PostUtils} from '@shared/utils/post-utils'; 17 + import {SignalizedFeedViewPost} from '@models/signalized-feed-view-post'; 18 + import {from} from 'rxjs'; 19 + import {PostCardComponent} from '@components/cards/post-card/post-card.component'; 20 + 21 + @Component({ 22 + selector: 'timeline-feed', 23 + imports: [ 24 + CommonModule, 25 + ScrollDirective, 26 + PostCardComponent, 27 + ], 28 + templateUrl: './timeline-feed.component.html', 29 + changeDetection: ChangeDetectionStrategy.OnPush 30 + }) 31 + export class TimelineFeedComponent implements OnInit, OnDestroy { 32 + feed = viewChild<ElementRef>('feed'); 33 + 34 + posts: SignalizedFeedViewPost[]; 35 + cursor: string; 36 + loading = true; 37 + reloadReady = false; 38 + reloadTimeout: ReturnType<typeof setTimeout>; 39 + 40 + constructor( 41 + private postService: PostService, 42 + // private dialogService: MskyDialogService, 43 + public cdRef: ChangeDetectorRef 44 + ) {} 45 + 46 + ngOnInit() { 47 + this.initData(); 48 + 49 + // Listen to new posts to refresh 50 + this.postService.refreshFeeds.subscribe({ 51 + next: () => { 52 + if (this.feed().nativeElement.scrollTop == 0) { 53 + this.initData(); 54 + } else { 55 + this.reloadReady = true; 56 + } 57 + } 58 + }); 59 + } 60 + 61 + ngOnDestroy() { 62 + this.postService.refreshFeeds.unsubscribe(); 63 + clearTimeout(this.reloadTimeout); 64 + } 65 + 66 + initData() { 67 + this.loading = true; 68 + from(agent.getTimeline({ 69 + limit: 15 70 + })).subscribe({ 71 + next: response => { 72 + this.cursor = response.data.cursor; 73 + this.posts = response.data.feed.map(fvp => PostUtils.parseFeedViewPost(fvp, this.postService)); 74 + this.cdRef.markForCheck(); 75 + setTimeout(() => { 76 + this.loading = false; 77 + this.manageRefresh(); 78 + }, 500); 79 + //TODO: MessageService 80 + }, error: err => console.log(err.message) 81 + }); 82 + } 83 + 84 + nextData() { 85 + if (this.loading) return; 86 + this.loading = true; 87 + 88 + from(agent.getTimeline({ 89 + cursor: this.cursor, 90 + limit: 15 91 + })).subscribe({ 92 + next: response => { 93 + this.cursor = response.data.cursor; 94 + const newPosts = response.data.feed.map(fvp => PostUtils.parseFeedViewPost(fvp, this.postService)); 95 + this.posts = [...this.posts, ...newPosts]; 96 + this.cdRef.markForCheck(); 97 + setTimeout(() => { 98 + this.loading = false; 99 + }, 500); 100 + //TODO: MessageService 101 + }, error: err => console.log(err.message) 102 + }); 103 + } 104 + 105 + openPost(uri: string) { 106 + //TODO: OpenPost 107 + // Mute all video players 108 + // this.feed().nativeElement.querySelectorAll('video').forEach((video: HTMLVideoElement) => { 109 + // video.muted = true; 110 + // }); 111 + // 112 + // this.dialogService.openThread(uri, this.feed().nativeElement); 113 + } 114 + 115 + manageRefresh() { 116 + if (this.loading) return; 117 + 118 + if (!this.reloadReady && !this.reloadTimeout) { 119 + this.reloadTimeout = setTimeout(() => { 120 + this.reloadTimeout = undefined; 121 + 122 + if (this.feed().nativeElement.scrollTop == 0) { 123 + this.reloadReady = false; 124 + from(agent.getTimeline({ 125 + limit: 1 126 + })).subscribe({ 127 + next: response => { 128 + const post = response.data.feed[0]; 129 + const lastPost = this.posts[0]; 130 + let isNewPost = false; 131 + 132 + if (post) { 133 + if (post.reason) { 134 + const reason = post.reason as $Typed<ReasonRepost>; 135 + if (!lastPost.reason) isNewPost = true; 136 + if (reason.indexedAt !== (lastPost.reason as $Typed<ReasonRepost>)?.indexedAt) isNewPost = true; 137 + } else { 138 + if (lastPost.reason) isNewPost = true; 139 + if (post.post.indexedAt !== lastPost.post().indexedAt) isNewPost = true; 140 + } 141 + } 142 + 143 + if (isNewPost) { 144 + this.initData(); 145 + } else { 146 + this.manageRefresh(); 147 + } 148 + //TODO: MessageService 149 + }, error: err => console.log(err.message) 150 + }); 151 + } else { 152 + this.reloadReady = true; 153 + } 154 + }, 30e3); 155 + // Timer in seconds 156 + } else if (this.reloadReady && this.feed().nativeElement.scrollTop == 0) { 157 + this.reloadReady = false; 158 + this.initData(); 159 + } 160 + } 161 + }
+16
src/app/components/navigation/auxbar/auxbar.component.html
··· 1 + <div 2 + class="h-full w-xs flex flex-col" 3 + > 4 + <div 5 + class="flex h-9 w-full shrink-0" 6 + > 7 + <!-- <span--> 8 + <!-- class="bg-primary text-bg text-xl font-bold flex items-center px-3"--> 9 + <!-- >//consolesky.</span>--> 10 + </div> 11 + <div 12 + class="flex flex-col flex-1 border-l border-primary" 13 + > 14 + 15 + </div> 16 + </div>
+15
src/app/components/navigation/auxbar/auxbar.component.ts
··· 1 + import {ChangeDetectionStrategy, Component} from '@angular/core'; 2 + import {PostService} from '@services/post.service'; 3 + 4 + @Component({ 5 + selector: 'auxbar', 6 + imports: [], 7 + templateUrl: './auxbar.component.html', 8 + changeDetection: ChangeDetectionStrategy.OnPush 9 + }) 10 + export class AuxbarComponent { 11 + constructor( 12 + protected postService: PostService 13 + ) {} 14 + 15 + }
+28
src/app/components/navigation/deck/deck.component.html
··· 1 + <div 2 + class="flex grow h-full min-w-0 overflow-x-auto ![scrollbar-gutter:auto]" 3 + > 4 + 5 + @for (column of columns(); track column.uuid) { 6 + @if (column | isDeckColumnTimeline) { 7 + <timeline-deck-column 8 + [column]="column" 9 + class="flex grow shrink-0 basis-md max-w-md not-first:border-l border-primary" 10 + /> 11 + } 12 + @else if (column | isDeckColumnNotification) { 13 + <notification-deck-column 14 + [column]="column" 15 + class="flex grow shrink-0 basis-md max-w-md not-first:border-l border-primary" 16 + /> 17 + } 18 + @else if (column | isDeckColumnAuthor) { 19 + <author-deck-column 20 + [column]="column" 21 + class="flex grow shrink-0 basis-md max-w-md not-first:border-l border-primary" 22 + /> 23 + } 24 + } 25 + <div 26 + class="flex-1 mt-[calc(2.25rem_-_1px)] border-t border-primary" 27 + ></div> 28 + </div>
+37
src/app/components/navigation/deck/deck.component.ts
··· 1 + import {ChangeDetectionStrategy, Component, WritableSignal} from '@angular/core'; 2 + import {DeckColumn} from '@models/deck-column'; 3 + import {ColumnService} from '@services/column.service'; 4 + import {IsDeckColumnTimelinePipe} from '@shared/pipes/type-guards/is-deckcolumn-timeline'; 5 + import { 6 + TimelineDeckColumnComponent 7 + } from '@components/deck-columns/timeline-deck-column/timeline-deck-column.component'; 8 + import {IsDeckColumnNotificationPipe} from '@shared/pipes/type-guards/is-deckcolumn-notifications'; 9 + import { 10 + NotificationDeckColumnComponent 11 + } from '@components/deck-columns/notification-deck-column/notification-deck-column.component'; 12 + import {IsDeckColumnAuthorPipe} from '@shared/pipes/type-guards/is-deckcolumn-author'; 13 + import {AuthorDeckColumnComponent} from '@components/deck-columns/author-deck-column/author-deck-column.component'; 14 + 15 + @Component({ 16 + selector: 'deck', 17 + imports: [ 18 + IsDeckColumnTimelinePipe, 19 + TimelineDeckColumnComponent, 20 + IsDeckColumnNotificationPipe, 21 + NotificationDeckColumnComponent, 22 + IsDeckColumnAuthorPipe, 23 + AuthorDeckColumnComponent 24 + ], 25 + templateUrl: './deck.component.html', 26 + changeDetection: ChangeDetectionStrategy.OnPush 27 + }) 28 + export class DeckComponent { 29 + columns: WritableSignal<Partial<DeckColumn>[]>; 30 + 31 + constructor( 32 + // protected postService: PostService, 33 + private columnService: ColumnService, 34 + ) { 35 + this.columns = columnService.getColumns(); 36 + } 37 + }
+85
src/app/components/navigation/post-composer/post-composer.component.html
··· 1 + <div 2 + class="flex w-full border-b border-primary box-border" 3 + > 4 + <div 5 + class="relative flex-1" 6 + > 7 + <div 8 + #text autofocus 9 + contenteditable="plaintext-only" 10 + spellcheck="false" 11 + class="absolute top-0 left-0 z-1 w-full h-full p-2 bg-transparent text-transparent outline-0 caret-black" 12 + (input)="formatText($event)" 13 + (paste)="postService.attachMedia($any($event.clipboardData.files))" 14 + (keydown.control.enter)="postBtn.click()" 15 + [mention]="mentionItems" 16 + [mentionConfig]="{ 17 + triggerChar: '@', 18 + labelKey: 'value', 19 + disableSearch: true, 20 + dropUp: true 21 + }" 22 + (searchTerm)="searchMentions($event)" 23 + ></div> 24 + <div 25 + [innerHTML]="text.textContent.length ? formattedText : undefined" 26 + class="w-full h-full p-2 bg-white text-black empty:text-primary/50 outline-0 break-words whitespace-pre-wrap empty:before:content-['user@consolesky:/$_\_']" 27 + ></div> 28 + </div> 29 + 30 + <div 31 + class="flex items-end shrink-0 p-[0.35rem]" 32 + > 33 + <button 34 + class="btn-secondary h-22 w-22 flex flex-col justify-center items-center" 35 + > 36 + <div 37 + class="flex items-center justify-center h-10" 38 + > 39 + <span 40 + class="material-icons !text-6xl" 41 + >format_quote</span> 42 + </div> 43 + 44 + Quote 45 + </button> 46 + </div> 47 + 48 + <div 49 + class="w-28 flex flex-col shrink-0 justify-end" 50 + > 51 + <div 52 + class="flex h-fit w-full border-primary" 53 + [class.border-t]="text | postComposerHeight" 54 + > 55 + <button 56 + class="btn-secondary h-9 flex-1 border-r-0 border-t-0 p-0 flex items-center justify-center" 57 + > 58 + <span 59 + class="material-icons-outlined" 60 + >mode_comment</span> 61 + </button> 62 + 63 + <button 64 + class="btn-secondary h-9 flex-1 border-r-0 border-t-0 p-0 flex items-center justify-center" 65 + > 66 + <span 67 + class="material-icons-outlined" 68 + >mode_comment</span> 69 + </button> 70 + 71 + <button 72 + class="btn-secondary h-9 flex-1 border-r-0 border-t-0 p-0 flex items-center justify-center" 73 + > 74 + <span 75 + class="material-icons-outlined" 76 + >mode_comment</span> 77 + </button> 78 + </div> 79 + <button 80 + #postBtn 81 + class="btn-primary h-16 w-full border-r-0 border-b-0" 82 + (click)="publishPost()" 83 + >Post</button> 84 + </div> 85 + </div>
+190
src/app/components/navigation/post-composer/post-composer.component.ts
··· 1 + import {ChangeDetectionStrategy, ChangeDetectorRef, Component, signal, WritableSignal} from '@angular/core'; 2 + import {$Typed, AppBskyFeedDefs, AppBskyGraphDefs, RichText} from '@atproto/api'; 3 + import {ExternalEmbed, ImageEmbed, RecordEmbed} from '@models/embed'; 4 + import {EmbedUtils} from '@shared/utils/embed-utils'; 5 + import {PostService} from '@services/post.service'; 6 + import {EmbedService} from '@services/embed.service'; 7 + import {agent} from '@core/bsky.api'; 8 + import {from} from 'rxjs'; 9 + import {SnippetType} from '@models/snippet'; 10 + import {SnippetUtils} from '@shared/utils/snippet-utils'; 11 + import {MentionModule} from 'angular-mentions'; 12 + import {PostComposerHeightPipe} from '@shared/pipes/post-composer-height.pipe'; 13 + 14 + @Component({ 15 + selector: 'post-composer', 16 + imports: [ 17 + MentionModule, 18 + PostComposerHeightPipe 19 + ], 20 + templateUrl: './post-composer.component.html', 21 + styles: ` 22 + :host(::ng-deep mention-list) { 23 + transform: translateY(-1.25rem); 24 + } 25 + ::ng-deep mention-list ul { 26 + margin: 0 !important; 27 + padding: 0 !important; 28 + overflow: unset !important; 29 + border-radius: 0 !important; 30 + border: 1px solid var(--color-primary) !important; 31 + } 32 + ::ng-deep mention-list .mention-active a { 33 + background-color: var(--color-primary) !important; 34 + color: var(--color-bg) !important; 35 + } 36 + ::ng-deep mention-list .mention-item a { 37 + text-box: trim-both cap alphabetic !important; 38 + padding: 0.75em !important; 39 + } 40 + `, 41 + changeDetection: ChangeDetectionStrategy.OnPush 42 + }) 43 + export class PostComposerComponent { 44 + formattedText = ''; 45 + rt: RichText; 46 + mentionItems = []; 47 + loading = false; 48 + embedSuggestions = signal<Array<RecordEmbed | ExternalEmbed>>([]); 49 + 50 + constructor( 51 + protected postService: PostService, 52 + private embedService: EmbedService, 53 + private cdRef: ChangeDetectorRef 54 + ) {} 55 + 56 + formatText(event: Event) { 57 + const text = (event.target as HTMLDivElement).innerText; 58 + this.postService.postCompose().post().text = text; 59 + 60 + this.rt = new RichText({text: text}); 61 + this.rt.detectFacetsWithoutResolution(); 62 + const segments = [...this.rt.segments()]; 63 + 64 + this.embedSuggestions.set(EmbedUtils.findEmbedSuggestions(segments.filter(s => s.facet).map(s => s.text).join(' '))); 65 + 66 + let htmlText = ''; 67 + 68 + segments.forEach(segment => { 69 + if (segment.isMention()) { 70 + htmlText += `<span class="text-blue-800">${segment.text}</span>`; 71 + } else if (segment.isTag()) { 72 + htmlText += `<span class="text-purple-800">${segment.text}</span>`; 73 + } else if (segment.isLink()) { 74 + htmlText += `<span class="text-red-800">${segment.text}</span>`; 75 + } else { 76 + htmlText += `<span>${segment.text.replace('<','&lt;').replace('>','&gt;')}</span>`; 77 + } 78 + }); 79 + 80 + this.formattedText = htmlText; 81 + this.cdRef.markForCheck(); 82 + } 83 + 84 + searchMentions(searchTerm: string) { 85 + from(agent.searchActorsTypeahead({q: searchTerm, limit: 5})).subscribe({ 86 + next: response => { 87 + this.mentionItems = response.data.actors.map(actor => { 88 + return { 89 + id: actor.did, 90 + value: actor.handle 91 + } 92 + }); 93 + this.cdRef.markForCheck(); 94 + } 95 + }); 96 + } 97 + 98 + removeImage(index: number) { 99 + const imageEmbed = this.postService.postCompose().mediaEmbed as WritableSignal<ImageEmbed>; 100 + 101 + if (imageEmbed().images.length == 1) { 102 + imageEmbed.set(undefined); 103 + } else { 104 + imageEmbed.update(embed => { 105 + embed.images.splice(index, 1); 106 + return embed; 107 + }); 108 + } 109 + } 110 + 111 + embedLink() { 112 + const embed = this.embedSuggestions()[0] as ExternalEmbed; 113 + embed.snippet = SnippetUtils.detectSnippet({uri: embed.url, description: ''}); 114 + 115 + if (embed.snippet.type !== SnippetType.BLUESKY_GIF) { 116 + this.embedService.getUrlMetadata(embed.url).subscribe({ 117 + next: metadata => { 118 + embed.metadata = metadata; 119 + this.postService.postCompose().mediaEmbed.set(embed); 120 + }, 121 + //TODO: MessageService 122 + error: err => console.log(err.message) 123 + }); 124 + } 125 + } 126 + 127 + embedQuote() { 128 + const embed = this.embedSuggestions()[0] as RecordEmbed; 129 + agent.resolveHandle({ 130 + handle: embed.author 131 + }).then(response => { 132 + this.postService.quotePost('at://' + response.data.did + '/app.bsky.feed.post/' + embed.rkey); 133 + }); 134 + } 135 + 136 + embedFeed() { 137 + const embed = this.embedSuggestions()[0] as RecordEmbed; 138 + 139 + agent.resolveHandle({ 140 + handle: embed.author 141 + }).then(response => agent.app.bsky.feed.getFeedGenerator({ 142 + feed: 'at://' + response.data.did + '/app.bsky.feed.generator/' + embed.rkey 143 + })).then(response => { 144 + let feed = response.data.view; 145 + feed['$type'] = 'app.bsky.feed.defs#generatorView'; 146 + this.postService.postCompose().recordEmbed.set(feed as $Typed<AppBskyFeedDefs.GeneratorView>); 147 + }); 148 + } 149 + 150 + embedList() { 151 + const embed = this.embedSuggestions()[0] as RecordEmbed; 152 + 153 + agent.resolveHandle({ 154 + handle: embed.author 155 + }).then(response => agent.app.bsky.graph.getList({ 156 + list: 'at://' + response.data.did + '/app.bsky.graph.list/' + embed.rkey 157 + })).then(response => { 158 + let list = response.data.list; 159 + list['$type'] = 'app.bsky.graph.defs#listView'; 160 + this.postService.postCompose().recordEmbed.set(list as $Typed<AppBskyGraphDefs.ListView>); 161 + }); 162 + } 163 + 164 + embedStarterPack() { 165 + const embed = this.embedSuggestions()[0] as RecordEmbed; 166 + 167 + agent.resolveHandle({ 168 + handle: embed.author 169 + }).then(response => agent.app.bsky.graph.getStarterPack({ 170 + starterPack: 'at://' + response.data.did + '/app.bsky.graph.starterpack/' + embed.rkey 171 + })).then(response => { 172 + let starterPack = response.data.starterPack; 173 + starterPack['$type'] = 'app.bsky.graph.defs#starterPackView'; 174 + this.postService.postCompose().recordEmbed.set(starterPack as $Typed<AppBskyGraphDefs.StarterPackView>); 175 + }); 176 + } 177 + 178 + publishPost() { 179 + this.loading = true; 180 + 181 + this.postService.publishPost().then( 182 + () => { 183 + //TODO: MessageService 184 + // this.messageService.info('Your post has been successfully published'); 185 + }, 186 + //TODO: MessageService 187 + err => console.log(err.message) 188 + ).finally(() => this.loading = false); 189 + } 190 + }
+28
src/app/components/navigation/sidebar/sidebar.component.html
··· 1 + <div 2 + class="h-full w-xs flex flex-col" 3 + > 4 + <div 5 + class="flex h-9 w-full border-b border-primary shrink-0" 6 + > 7 + <span 8 + class="bg-primary text-bg text-xl font-bold flex items-center px-3" 9 + >//consolesky.</span> 10 + </div> 11 + <div 12 + class="flex flex-col flex-1 border-r border-primary" 13 + > 14 + 15 + </div> 16 + 17 + <button 18 + class="btn-primary relative w-full font-semibold shrink-0 group" 19 + (click)="postService.postCompose() ? postService.postCompose.set(undefined) : postService.createPost()" 20 + > 21 + <span 22 + class="absolute left-2 font-black text-bg group-hover:text-primary" 23 + > 24 + {{ postService.postCompose() ? 'X' : '>_' }} 25 + </span> 26 + {{ postService.postCompose() ? 'Cancel post' : 'Write post' }} 27 + </button> 28 + </div>
+15
src/app/components/navigation/sidebar/sidebar.component.ts
··· 1 + import {ChangeDetectionStrategy, Component} from '@angular/core'; 2 + import {PostService} from '@services/post.service'; 3 + 4 + @Component({ 5 + selector: 'sidebar', 6 + imports: [], 7 + templateUrl: './sidebar.component.html', 8 + changeDetection: ChangeDetectionStrategy.OnPush 9 + }) 10 + export class SidebarComponent { 11 + constructor( 12 + protected postService: PostService 13 + ) {} 14 + 15 + }
+11
src/app/components/shared/avatar/avatar.component.html
··· 1 + @if (src()) { 2 + <img 3 + [src]="src()" 4 + [alt]="alt()" 5 + class="h-full w-full" 6 + /> 7 + } @else { 8 + <div 9 + class="h-full w-full bg-primary/50" 10 + ></div> 11 + }
+12
src/app/components/shared/avatar/avatar.component.ts
··· 1 + import {ChangeDetectionStrategy, Component, input} from '@angular/core'; 2 + 3 + @Component({ 4 + selector: 'avatar', 5 + imports: [], 6 + templateUrl: './avatar.component.html', 7 + changeDetection: ChangeDetectionStrategy.OnPush 8 + }) 9 + export class AvatarComponent { 10 + src = input<string>(); 11 + alt = input<string>(); 12 + }
+30
src/app/components/shared/rich-text/rich-text.component.html
··· 1 + <span 2 + class="whitespace-pre-line block [overflow-wrap:break-word]" 3 + > 4 + @for (segment of segments; track $index) { 5 + @if (segment.isLink()) { 6 + <a 7 + [href]="segment.link?.uri" 8 + target="_blank" 9 + class="hover:underline text-blue-500 visited:text-purple-500 break-all" 10 + (click)="$event.stopPropagation()" 11 + > 12 + {{segment.text}} 13 + </a> 14 + } @else if (segment.isMention()) { 15 + <a 16 + href="https://bsky.app/profile/{{segment.mention?.did}}" 17 + class="hover:underline text-blue-500" 18 + (click)="openAuthor($event, segment.mention?.did)" 19 + >{{segment.text}}</a> 20 + } @else if (segment.isTag()) { 21 + <a 22 + href="https://bsky.app/hashtag/{{segment.tag?.tag}}" 23 + class="hover:underline text-blue-500 visited:text-purple-500" 24 + (click)="$event.stopPropagation(); $event.preventDefault()" 25 + >{{segment.text}}</a> 26 + } @else { 27 + <span class="break-words">{{segment.text}}</span> 28 + } 29 + } 30 + </span>
+55
src/app/components/shared/rich-text/rich-text.component.ts
··· 1 + import { 2 + ChangeDetectionStrategy, 3 + ChangeDetectorRef, 4 + Component, 5 + EventEmitter, 6 + Input, 7 + OnInit, 8 + Output, 9 + } from '@angular/core'; 10 + import {Facet, RichText, RichTextSegment} from "@atproto/api"; 11 + // import {MskyDialogService} from '@services/msky-dialog.service'; 12 + import {agent} from '@core/bsky.api'; 13 + 14 + @Component({ 15 + selector: 'rich-text', 16 + templateUrl: './rich-text.component.html', 17 + changeDetection: ChangeDetectionStrategy.OnPush 18 + }) 19 + export class RichTextComponent implements OnInit { 20 + @Input() text: string; 21 + @Input() facets: Facet[]; 22 + @Output() onMentionClick: EventEmitter<any> 23 + @Output() onTagClick: EventEmitter<any> 24 + segments: RichTextSegment[] = []; 25 + 26 + constructor( 27 + private cdRef: ChangeDetectorRef, 28 + // private dialogService: MskyDialogService 29 + ) {} 30 + 31 + ngOnInit() { 32 + const rt = new RichText( 33 + { 34 + text: this.text, 35 + facets: this.facets 36 + } 37 + ); 38 + 39 + if (!this.facets) { 40 + rt.detectFacets(agent).then(() => { 41 + this.segments = [...rt.segments()]; 42 + this.cdRef.markForCheck(); 43 + }); 44 + } else { 45 + this.segments = [...rt.segments()]; 46 + } 47 + } 48 + 49 + openAuthor(event: MouseEvent, did: string) { 50 + event.preventDefault(); 51 + event.stopPropagation(); 52 + 53 + //TODO: OpenAuthor 54 + } 55 + }
+21
src/app/core/auth/auth.guard.ts
··· 1 + import {Injectable} from '@angular/core'; 2 + import {CanActivate, Router} from '@angular/router'; 3 + import {AuthService} from './auth.service'; 4 + 5 + @Injectable({ 6 + providedIn: 'root' 7 + }) 8 + export class AuthGuard implements CanActivate { 9 + 10 + constructor(private auth: AuthService, 11 + private router: Router) {} 12 + 13 + canActivate(): boolean { 14 + if (this.auth.isAuthenticated()) { 15 + return true; 16 + } else { 17 + this.router.navigate(['login']); 18 + return false; 19 + } 20 + } 21 + }
+83
src/app/core/auth/auth.service.ts
··· 1 + import {Injectable, signal} from '@angular/core'; 2 + import {BehaviorSubject, from} from 'rxjs'; 3 + import {AppBskyActorDefs, AtpAgentLoginOpts} from "@atproto/api"; 4 + import {Router} from "@angular/router"; 5 + import {HttpErrorResponse} from "@angular/common/http"; 6 + import {StorageKeys} from '@core/storage-keys'; 7 + import {agent} from '@core/bsky.api'; 8 + import {StorageService} from '@services/storage.service'; 9 + // import {MskyMessageService} from '@services/msky-message.service'; 10 + import {ColumnService} from '@services/column.service'; 11 + 12 + @Injectable({ 13 + providedIn: 'root', 14 + }) 15 + export class AuthService { 16 + authenticationState = new BehaviorSubject(false); 17 + loggedUser = signal<AppBskyActorDefs.ProfileViewDetailed>(undefined); 18 + 19 + constructor( 20 + private router: Router, 21 + private storageService: StorageService, 22 + // private messageService: MskyMessageService, 23 + private columnService: ColumnService 24 + ) { 25 + this.checkToken(); 26 + } 27 + 28 + checkToken() { 29 + const sessionData = this.storageService.get(StorageKeys.TOKEN_KEY); 30 + if (sessionData) { 31 + agent.resumeSession(JSON.parse(sessionData)).then( 32 + () => { 33 + this.storageService.set(StorageKeys.TOKEN_KEY, JSON.stringify(agent.session)); 34 + 35 + agent.getProfile({actor: agent.session.did}).then(response => { 36 + this.loggedUser.set(response.data); 37 + this.columnService.checkColumns(); 38 + 39 + this.authenticationState.next(true); 40 + }); 41 + } 42 + ); 43 + } 44 + } 45 + 46 + login(credentials: AtpAgentLoginOpts) { 47 + from(agent.login(credentials)).subscribe({ 48 + next: () => { 49 + this.storageService.set(StorageKeys.TOKEN_KEY, JSON.stringify(agent.session)); 50 + 51 + from(agent.getProfile({ 52 + actor: agent.session.did 53 + })).subscribe({ 54 + next: response => { 55 + this.loggedUser.set(response.data); 56 + this.columnService.checkColumns(); 57 + 58 + this.authenticationState.next(true); 59 + this.router.navigate(['']); 60 + //TODO: MessageService 61 + }, error: err => console.log(err.message) 62 + }); 63 + }, 64 + error: (err: HttpErrorResponse) => { 65 + //TODO: MessageService 66 + // this.messageService.warn(err.message, 'Oops!'); 67 + } 68 + }); 69 + } 70 + 71 + logout() { 72 + agent.logout().then( 73 + () => { 74 + this.storageService.remove(StorageKeys.TOKEN_KEY); 75 + this.authenticationState.next(false); 76 + } 77 + ); 78 + } 79 + 80 + isAuthenticated() { 81 + return this.authenticationState.value; 82 + } 83 + }
+5
src/app/core/bsky.api.ts
··· 1 + import {AtpAgent} from "@atproto/api"; 2 + 3 + export const agent = new AtpAgent({ 4 + service: 'https://bsky.social' 5 + });
+5
src/app/core/storage-keys.ts
··· 1 + export enum StorageKeys { 2 + TOKEN_KEY = 'session', 3 + LOGGED_USER = 'logged_user', 4 + DECK_COLUMNS = 'columns' 5 + }
+57
src/app/models/deck-column.ts
··· 1 + import * as uuid from "uuid"; 2 + 3 + export class DeckColumn { 4 + uuid: string = uuid.v4(); 5 + title: string = ''; 6 + width: number = 400; 7 + index: number; 8 + } 9 + 10 + export class TimelineDeckColumn extends DeckColumn { 11 + type: DeckColumnType.TIMELINE = DeckColumnType.TIMELINE; 12 + } 13 + 14 + export class NotificationDeckColumn extends DeckColumn { 15 + type: DeckColumnType.NOTIFICATION = DeckColumnType.NOTIFICATION; 16 + } 17 + 18 + export class AuthorDeckColumn extends DeckColumn { 19 + type: DeckColumnType.AUTHOR = DeckColumnType.AUTHOR; 20 + did: string; 21 + handle: string; 22 + displayName: string; 23 + mode: AuthorDeckColumnMode = AuthorDeckColumnMode.POSTS; 24 + } 25 + 26 + export class ListDeckColumn extends DeckColumn { 27 + type: DeckColumnType.LIST = DeckColumnType.LIST; 28 + did: string; 29 + } 30 + 31 + export class GeneratorDeckColumn extends DeckColumn { 32 + type: DeckColumnType.GENERATOR = DeckColumnType.GENERATOR; 33 + uri: string; 34 + avatar: string; 35 + } 36 + 37 + export class SearchDeckColumn extends DeckColumn { 38 + type: DeckColumnType.SEARCH = DeckColumnType.SEARCH; 39 + query: string; 40 + sort: 'top' | 'latest'; 41 + } 42 + 43 + export enum DeckColumnType { 44 + TIMELINE = 'TIMELINE', 45 + NOTIFICATION = 'NOTIFICATION', 46 + AUTHOR = 'AUTHOR', 47 + LIST = 'LIST', 48 + GENERATOR = 'GENERATOR', 49 + SEARCH = 'SEARCH' 50 + } 51 + 52 + export enum AuthorDeckColumnMode { 53 + POSTS = 'posts_no_replies', 54 + REPLIES = 'posts_with_replies', 55 + MEDIA = 'posts_with_media', 56 + VIDEO = 'posts_with_video' 57 + }
+67
src/app/models/embed.ts
··· 1 + import {BlueskyGifSnippet, IframeSnippet, LinkSnippet} from '@models/snippet'; 2 + import {UrlMetadata} from '@models/url-metadata'; 3 + 4 + 5 + export const enum EmbedType { 6 + IMAGE = 'IMAGE', 7 + VIDEO = 'VIDEO', 8 + EXTERNAL = 'EXTERNAL', 9 + RECORD = 'RECORD', 10 + } 11 + export const enum RecordEmbedType { 12 + POST = 'POST', 13 + FEED = 'FEED', 14 + LIST = 'LIST', 15 + STARTER_PACK = 'STARTER_PACK', 16 + } 17 + 18 + interface Embed { 19 + type: EmbedType.IMAGE | EmbedType.VIDEO | EmbedType.EXTERNAL | EmbedType.RECORD; 20 + } 21 + 22 + export class ImageEmbed implements Embed { 23 + type: EmbedType.IMAGE = EmbedType.IMAGE; 24 + /** Image array */ 25 + images: {data: string, alt: string}[] = []; 26 + } 27 + 28 + export class VideoEmbed implements Embed { 29 + type: EmbedType.VIDEO = EmbedType.VIDEO; 30 + /** Video file */ 31 + file: File; 32 + thumbnail: string; 33 + 34 + constructor(file: File, thumbnail: string) { 35 + this.file = file; 36 + this.thumbnail = thumbnail; 37 + } 38 + } 39 + 40 + export class ExternalEmbed implements Embed { 41 + type: EmbedType.EXTERNAL = EmbedType.EXTERNAL; 42 + /** Url */ 43 + url: string; 44 + /** Extended info */ 45 + metadata: UrlMetadata; 46 + /** Extended info */ 47 + snippet: LinkSnippet | BlueskyGifSnippet | IframeSnippet; 48 + 49 + constructor(url: string) { 50 + this.url = url; 51 + } 52 + } 53 + 54 + export class RecordEmbed implements Embed { 55 + type: EmbedType.RECORD = EmbedType.RECORD; 56 + /** Record type */ 57 + recordType: RecordEmbedType; 58 + /** Record info */ 59 + author: string; 60 + rkey: string; 61 + 62 + constructor(recordType: RecordEmbedType, author: string, rkey: string) { 63 + this.recordType = recordType; 64 + this.author = author; 65 + this.rkey = rkey; 66 + } 67 + }
+18
src/app/models/notification.ts
··· 1 + import {AppBskyActorDefs, AppBskyFeedDefs, AppBskyNotificationListNotifications} from "@atproto/api"; 2 + import * as uuid from "uuid"; 3 + import {WritableSignal} from '@angular/core'; 4 + 5 + export class Notification { 6 + /** Notification list object */ 7 + notification: AppBskyNotificationListNotifications.Notification; 8 + /** Notification reason */ 9 + reason: "like" | "repost" | "follow" | "mention" | "reply" | "quote" | "starterpack-joined" | string; 10 + /** Authors' profile */ 11 + authors: AppBskyActorDefs.ProfileView[] = []; 12 + /** Record URI */ 13 + uri?: string; 14 + /** Record */ 15 + post?: WritableSignal<AppBskyFeedDefs.PostView>; 16 + /** Uuid */ 17 + uuid: string = uuid.v4(); 18 + }
+23
src/app/models/post-compose.ts
··· 1 + import {signal, WritableSignal} from "@angular/core"; 2 + import {$Typed, AppBskyEmbedRecord, AppBskyFeedDefs, AppBskyFeedPost, AppBskyGraphDefs} from "@atproto/api"; 3 + import {ExternalEmbed, ImageEmbed, VideoEmbed} from "@models/embed"; 4 + 5 + type Record = AppBskyFeedPost.Record; 6 + 7 + export class PostCompose { 8 + post: WritableSignal<AppBskyFeedPost.Record> = signal(undefined); 9 + reply: WritableSignal<AppBskyFeedDefs.PostView> = signal(undefined); 10 + recordEmbed: WritableSignal<$Typed<AppBskyEmbedRecord.ViewRecord> | $Typed<AppBskyFeedDefs.GeneratorView> | $Typed<AppBskyGraphDefs.ListView> | $Typed<AppBskyGraphDefs.StarterPackView>> = signal(undefined); 11 + mediaEmbed: WritableSignal<ImageEmbed | VideoEmbed | ExternalEmbed> = signal(undefined); 12 + 13 + constructor() { 14 + this.post.set({ 15 + $type: 'app.bsky.feed.post', 16 + text: '', 17 + facets: [], 18 + createdAt: '', 19 + langs: [], 20 + tags: [], 21 + } as Record); 22 + } 23 + }
+12
src/app/models/signalized-feed-view-post.ts
··· 1 + import {AppBskyFeedDefs} from "@atproto/api"; 2 + import {WritableSignal} from "@angular/core"; 3 + import * as uuid from "uuid"; 4 + 5 + export class SignalizedFeedViewPost { 6 + [k: string]: unknown; 7 + post: WritableSignal<AppBskyFeedDefs.PostView>; 8 + reply?: AppBskyFeedDefs.ReplyRef | undefined; 9 + reason?: AppBskyFeedDefs.ReasonRepost | AppBskyFeedDefs.ReasonPin | { [k: string]: unknown; $type: string; } | undefined; 10 + feedContext?: string | undefined; 11 + uuid: string = uuid.v4(); 12 + }
+72
src/app/models/snippet.ts
··· 1 + export const enum SnippetType { 2 + LINK= 'LINK', 3 + BLUESKY_GIF = 'BLUESKY_GIF', 4 + IFRAME = 'IFRAME', 5 + } 6 + 7 + export const enum SnippetSource { 8 + YOUTUBE = 'YOUTUBE', 9 + SOUNDCLOUD = 'SOUNDCLOUD', 10 + } 11 + 12 + interface Snippet { 13 + type: SnippetType.LINK | SnippetType.BLUESKY_GIF | SnippetType.IFRAME; 14 + /** Domain name */ 15 + domain: string; 16 + /** URL */ 17 + url: string; 18 + } 19 + 20 + export class LinkSnippet implements Snippet { 21 + type: SnippetType.LINK = SnippetType.LINK; 22 + /** Domain name */ 23 + domain: string; 24 + /** URL */ 25 + url: string; 26 + 27 + constructor(domain: string, url: string) { 28 + this.domain = domain; 29 + this.url = url; 30 + } 31 + } 32 + 33 + export class BlueskyGifSnippet implements Snippet { 34 + type: SnippetType.BLUESKY_GIF = SnippetType.BLUESKY_GIF; 35 + /** Domain name */ 36 + domain: string; 37 + /** Video URL */ 38 + url: string; 39 + /** Aspect ratio */ 40 + ratio: string; 41 + /** Alt text description */ 42 + description: string; 43 + 44 + constructor(domain: string, url: string, ratio: string, description: string) { 45 + this.domain = domain; 46 + this.url = url; 47 + this.ratio = ratio; 48 + this.description = description; 49 + } 50 + } 51 + 52 + export class IframeSnippet implements Snippet { 53 + type: SnippetType.IFRAME = SnippetType.IFRAME; 54 + /** Source domain */ 55 + domain: string; 56 + /** Iframe URL or Youtube ID */ 57 + url: string; 58 + /** Aspect ratio */ 59 + ratio: string; 60 + /** Source type */ 61 + source: SnippetSource; 62 + /** The second is supposed to start at in a Youtube video */ 63 + seek: number; 64 + 65 + constructor(domain: string, url: string, ratio: string, source: SnippetSource, seek?: number) { 66 + this.domain = domain; 67 + this.url = url; 68 + this.ratio = ratio; 69 + this.source = source; 70 + this.seek = seek; 71 + } 72 + }
+14
src/app/models/thread-reply.ts
··· 1 + import {SignalizedFeedViewPost} from "@models/signalized-feed-view-post"; 2 + import * as uuid from "uuid"; 3 + import {AppBskyFeedDefs} from "@atproto/api"; 4 + 5 + export class ThreadReply { 6 + post: SignalizedFeedViewPost; 7 + replies: Array<ThreadReply | AppBskyFeedDefs.NotFoundPost | AppBskyFeedDefs.BlockedPost>; 8 + /** Uuid */ 9 + uuid: string = uuid.v4(); 10 + 11 + constructor(post: SignalizedFeedViewPost) { 12 + this.post = post; 13 + } 14 + }
+8
src/app/models/url-metadata.ts
··· 1 + export class UrlMetadata { 2 + url: string; 3 + title: string; 4 + description: string; 5 + imageUrl: string; 6 + likelyType: string; 7 + error: string; 8 + }
+111
src/app/services/column.service.ts
··· 1 + import {StorageKeys} from "@core/storage-keys"; 2 + import { 3 + AuthorDeckColumn, 4 + DeckColumn, 5 + GeneratorDeckColumn, 6 + NotificationDeckColumn, 7 + SearchDeckColumn, 8 + TimelineDeckColumn 9 + } from "@models/deck-column"; 10 + import {Injectable, signal, WritableSignal} from "@angular/core"; 11 + import * as uuid from "uuid"; 12 + 13 + const columns: WritableSignal<Partial<DeckColumn>[]> = signal([]); 14 + 15 + @Injectable({ 16 + providedIn: 'root' 17 + }) 18 + export class ColumnService { 19 + public addColumn(column: Partial<DeckColumn>) { 20 + columns.update(columns => [...columns, column]); 21 + this.saveColumns(); 22 + } 23 + 24 + public updateColumn(column: Partial<DeckColumn>) { 25 + columns.update(columns => { 26 + columns[columns.findIndex(col => col.uuid == column.uuid)] = column; 27 + return columns; 28 + }); 29 + this.saveColumns(); 30 + } 31 + 32 + public deleteColumn(uuid: string) { 33 + columns.update(columns => { 34 + columns.splice(columns.findIndex(col => col.uuid == uuid), 1); 35 + columns.forEach((column, index) => column.index = index); 36 + return columns; 37 + }); 38 + this.saveColumns(); 39 + } 40 + 41 + public getColumns(): WritableSignal<Partial<DeckColumn>[]> { 42 + return columns; 43 + } 44 + 45 + public saveColumns() { 46 + localStorage.setItem(StorageKeys.DECK_COLUMNS, JSON.stringify(columns())); 47 + } 48 + 49 + public checkColumns() { 50 + let storageColumns: Partial<DeckColumn>[] = JSON.parse(localStorage.getItem(StorageKeys.DECK_COLUMNS)); 51 + 52 + if (!storageColumns || !storageColumns.length) { 53 + this.initColumns(); 54 + return; 55 + } 56 + 57 + storageColumns.forEach((column: any) => { 58 + if (!column.width) column.width = 400; 59 + if (!column.uuid) column.uuid = uuid.v4(); 60 + }); 61 + 62 + localStorage.setItem(StorageKeys.DECK_COLUMNS, JSON.stringify(storageColumns)); 63 + columns.set(storageColumns); 64 + } 65 + 66 + public initColumns() { 67 + this.createTimelineColumn(); 68 + this.createNotificationsColumn(); 69 + this.createAuthorColumn(JSON.parse(localStorage.getItem(StorageKeys.LOGGED_USER))); 70 + } 71 + 72 + public createTimelineColumn() { 73 + let column = new TimelineDeckColumn(); 74 + column.title = 'Home'; 75 + column.index = columns().length; 76 + this.addColumn(column); 77 + } 78 + 79 + public createNotificationsColumn() { 80 + let column = new NotificationDeckColumn(); 81 + column.title = 'Notifications'; 82 + column.index = columns().length; 83 + this.addColumn(column); 84 + } 85 + 86 + public createAuthorColumn(author: Partial<{did: string, handle: string, displayName: string}>) { 87 + let column = new AuthorDeckColumn(); 88 + column.did = author.did; 89 + column.handle = author.handle; 90 + column.displayName = author.displayName; 91 + column.index = columns().length; 92 + this.addColumn(column); 93 + } 94 + 95 + public createGeneratorColumn(generator: Partial<{uri: string, avatar: string, displayName: string}>) { 96 + let column = new GeneratorDeckColumn(); 97 + column.title = generator.displayName; 98 + column.uri = generator.uri; 99 + column.avatar = generator.avatar; 100 + column.index = columns().length; 101 + this.addColumn(column); 102 + } 103 + 104 + public createSearchColumn(query: string, sort: 'top' | 'latest') { 105 + let column = new SearchDeckColumn(); 106 + column.query = query; 107 + column.sort = sort; 108 + column.index = columns().length; 109 + this.addColumn(column); 110 + } 111 + }
+33
src/app/services/embed.service.ts
··· 1 + import {Injectable} from '@angular/core'; 2 + import {HttpClient} from "@angular/common/http"; 3 + import {map, Observable} from "rxjs"; 4 + import {UrlMetadata} from "@models/url-metadata"; 5 + 6 + @Injectable({ 7 + providedIn: 'root' 8 + }) 9 + export class EmbedService { 10 + constructor( 11 + private httpClient: HttpClient 12 + ) {} 13 + 14 + getUrlMetadata(url: string): Observable<UrlMetadata> { 15 + if (!url.startsWith('https://') && !url.startsWith('http://')) { 16 + url = 'https://' + url; 17 + } 18 + 19 + return this.httpClient.get(`https://cardyb.bsky.app/v1/extract?url=${url}`) 20 + .pipe( 21 + map((res: any) => { 22 + const metadata = new UrlMetadata(); 23 + metadata.url = res.url; 24 + metadata.title = res.title; 25 + metadata.description = res.description; 26 + metadata.imageUrl = res.image; 27 + metadata.likelyType = res.likely_type; 28 + metadata.error = res.error; 29 + return metadata; 30 + }) 31 + ); 32 + } 33 + }
+440
src/app/services/post.service.ts
··· 1 + import {Injectable, signal, WritableSignal} from "@angular/core"; 2 + import { 3 + $Typed, 4 + AppBskyEmbedExternal, 5 + AppBskyEmbedImages, 6 + AppBskyEmbedRecord, 7 + AppBskyEmbedRecordWithMedia, 8 + AppBskyEmbedVideo, 9 + AppBskyFeedDefs, 10 + RichText 11 + } from "@atproto/api"; 12 + import {from, Subject} from "rxjs"; 13 + import {EmbedType, ExternalEmbed, ImageEmbed, VideoEmbed} from "@models/embed"; 14 + import {DOC_ORIENTATION, NgxImageCompressService} from "ngx-image-compress"; 15 + import {HttpErrorResponse} from "@angular/common/http"; 16 + import {PostCompose} from '@models/post-compose'; 17 + import {agent} from '@core/bsky.api'; 18 + 19 + export const posts: Map<string, WritableSignal<AppBskyFeedDefs.PostView>> = 20 + new Map<string, WritableSignal<AppBskyFeedDefs.PostView>>(); 21 + 22 + @Injectable({ 23 + providedIn: 'root' 24 + }) 25 + export class PostService { 26 + public postCompose: WritableSignal<PostCompose> = signal(undefined); 27 + public refreshFeeds: Subject<void> = new Subject<void>(); 28 + 29 + constructor( 30 + // private dialogService: MskyDialogService, 31 + private imageCompressService: NgxImageCompressService 32 + ) {} 33 + 34 + setPost(post: AppBskyFeedDefs.PostView): WritableSignal<AppBskyFeedDefs.PostView> { 35 + const existingPost = posts.get(post.uri); 36 + if (existingPost) { 37 + existingPost.set(post); 38 + return existingPost; 39 + } else { 40 + const newPost = signal(post); 41 + posts.set(post.uri, newPost); 42 + return newPost; 43 + } 44 + } 45 + 46 + getPost(uri: string): WritableSignal<AppBskyFeedDefs.PostView> | undefined { 47 + return posts.get(uri); 48 + } 49 + 50 + createPost() { 51 + if (this.postCompose()) return; 52 + 53 + this.postCompose.set(new PostCompose()); 54 + // this.dialogService.openPostComposer(this.postCompose); 55 + } 56 + 57 + like(post: WritableSignal<AppBskyFeedDefs.PostView>): Promise<void> { 58 + return new Promise<void>((resolve, reject) => { 59 + // Update UI 60 + post.update(post => { 61 + post.viewer.like = 'placeholder'; 62 + return post; 63 + }); 64 + 65 + // API call (delayed to not step over placeholder change) 66 + from(agent.like(post().uri, post().cid)).subscribe({ 67 + next: () => { 68 + setTimeout(() => { 69 + from(agent.getPosts({ 70 + uris: [post().uri] 71 + })).subscribe({ 72 + next: response => { 73 + post.set(response.data.posts[0]); 74 + resolve(); 75 + }, 76 + error: err => reject(err) 77 + }); 78 + }, 100); 79 + }, 80 + error: err => { 81 + post.update(post => { 82 + post.viewer.like = undefined; 83 + return post; 84 + }); 85 + reject(err); 86 + } 87 + }); 88 + }); 89 + } 90 + 91 + deleteLike(post: WritableSignal<AppBskyFeedDefs.PostView>): Promise<void> { 92 + return new Promise<void>((resolve, reject) => { 93 + // Update UI 94 + const likeRef = post().viewer.like; 95 + post.update(post => { 96 + post.viewer.like = undefined; 97 + return post; 98 + }); 99 + 100 + // API call (delayed to not step over placeholder change) 101 + from(agent.deleteLike(likeRef)).subscribe({ 102 + next: () => { 103 + setTimeout(() => { 104 + from(agent.getPosts({ 105 + uris: [post().uri] 106 + })).subscribe({ 107 + next: response => { 108 + post.set(response.data.posts[0]); 109 + resolve(); 110 + }, 111 + error: err => reject(err) 112 + }); 113 + }, 200); 114 + }, 115 + error: err => { 116 + post.update(post => { 117 + post.viewer.like = likeRef; 118 + return post; 119 + }); 120 + reject(err); 121 + } 122 + }); 123 + }); 124 + } 125 + 126 + repost(post: WritableSignal<AppBskyFeedDefs.PostView>): Promise<void> { 127 + return new Promise<void>((resolve, reject) => { 128 + // Update UI 129 + post.update(post => { 130 + post.viewer.repost = 'placeholder'; 131 + return post; 132 + }); 133 + 134 + // API call (delayed to not step over placeholder change) 135 + from(agent.repost(post().uri, post().cid)).subscribe({ 136 + next: () => { 137 + setTimeout(() => { 138 + from(agent.getPosts({ 139 + uris: [post().uri] 140 + })).subscribe({ 141 + next: response => { 142 + post.set(response.data.posts[0]); 143 + resolve(); 144 + }, 145 + error: err => reject(err) 146 + }); 147 + }, 100); 148 + }, 149 + error: err => { 150 + post.update(post => { 151 + post.viewer.repost = undefined; 152 + return post; 153 + }); 154 + reject(err); 155 + } 156 + }); 157 + }); 158 + } 159 + 160 + deleteRepost(post: WritableSignal<AppBskyFeedDefs.PostView>): Promise<void> { 161 + return new Promise<void>((resolve, reject) => { 162 + // Update UI 163 + const rtRef = post().viewer.repost; 164 + post.update(post => { 165 + post.viewer.repost = undefined; 166 + return post; 167 + }); 168 + 169 + // API call (delayed to not step over placeholder change) 170 + from(agent.deleteRepost(rtRef)).subscribe({ 171 + next: () => { 172 + setTimeout(() => { 173 + from(agent.getPosts({ 174 + uris: [post().uri] 175 + })).subscribe({ 176 + next: response => { 177 + post.set(response.data.posts[0]); 178 + resolve(); 179 + }, 180 + error: err => reject(err) 181 + }); 182 + }, 200); 183 + }, 184 + error: err => { 185 + post.update(post => { 186 + post.viewer.repost = rtRef; 187 + return post; 188 + }); 189 + reject(err); 190 + } 191 + }); 192 + }); 193 + } 194 + 195 + refreshRepost(post: WritableSignal<AppBskyFeedDefs.PostView>): Promise<void> { 196 + return new Promise<void>((resolve, reject) => { 197 + from(agent.deleteRepost(post().viewer.repost)).subscribe({ 198 + next: () => this.repost(post).then(() => resolve()).catch(err => reject(err)), 199 + error: err => reject(err) 200 + }); 201 + }); 202 + } 203 + 204 + replyPost(uri: string) { 205 + if (!this.postCompose()) this.createPost(); 206 + 207 + agent.getPostThread({ 208 + uri: uri 209 + }).then(response => { 210 + if (!AppBskyFeedDefs.isThreadViewPost(response.data.thread)) return; 211 + this.setPost(response.data.thread.post as AppBskyFeedDefs.PostView); 212 + 213 + let root; 214 + if (AppBskyFeedDefs.isThreadViewPost(response.data.thread.parent)) { 215 + root = response.data.thread.parent; 216 + 217 + while (AppBskyFeedDefs.isThreadViewPost(root.parent)) { 218 + root = root.parent; 219 + } 220 + 221 + root = root.post; 222 + } else { 223 + root = response.data.thread.post; 224 + } 225 + 226 + let replyRef = { 227 + parent: { 228 + uri: (response.data.thread.post).uri, 229 + cid: (response.data.thread.post).cid 230 + }, 231 + root: { 232 + uri: root.uri, 233 + cid: root.cid 234 + }, 235 + }; 236 + 237 + this.postCompose().post.update(post => { 238 + post.reply = replyRef; 239 + return post; 240 + }); 241 + 242 + this.postCompose().reply.set(response.data.thread.post); 243 + }); 244 + } 245 + 246 + quotePost(uri:string) { 247 + if (!this.postCompose()) this.createPost(); 248 + 249 + agent.getPosts({ 250 + uris: [uri] 251 + }).then(response => { 252 + if (!response.data.posts[0]) return; 253 + const quotedPost = this.setPost(response.data.posts[0]); 254 + 255 + if (!this.postCompose()) this.createPost(); 256 + this.postCompose().recordEmbed.set({ 257 + $type: 'app.bsky.embed.record#viewRecord', 258 + uri: quotedPost().uri, 259 + cid: quotedPost().cid, 260 + author: quotedPost().author, 261 + indexedAt: quotedPost().indexedAt, 262 + value: quotedPost().record, 263 + embeds: [quotedPost().embed] 264 + } as $Typed<AppBskyEmbedRecord.ViewRecord>); 265 + }); 266 + } 267 + 268 + attachMedia(files: File[]) { 269 + if (!files.length) return; 270 + if (!this.postCompose()) this.createPost(); 271 + 272 + //Fix array methods because it comes as FileList 273 + files = Array.from(files); 274 + 275 + if (files.some(f => f.type.includes('image'))) { 276 + //Filelist has images 277 + if (!this.postCompose().mediaEmbed()) { 278 + this.postCompose().mediaEmbed.set(new ImageEmbed()); 279 + } 280 + if (this.postCompose().mediaEmbed().type == EmbedType.IMAGE) { 281 + const imageEmbed = this.postCompose().mediaEmbed as WritableSignal<ImageEmbed>; 282 + 283 + //Our embed list is for images 284 + files.forEach(file => { 285 + if (file.type.includes('image') && imageEmbed().images.length < 4) { 286 + const reader = new FileReader(); 287 + reader.onload = (event: any) => { 288 + const newEmbed = new ImageEmbed(); 289 + newEmbed.images = [...imageEmbed().images, {data: event.srcElement.result, alt: ''}]; 290 + imageEmbed.set(newEmbed); 291 + }; 292 + reader.readAsDataURL(file); 293 + } 294 + }) 295 + } 296 + } else if (files.some(f => f.type.includes('video'))) { 297 + const videoEmbed = this.postCompose().mediaEmbed as WritableSignal<VideoEmbed>; 298 + 299 + //Filelist has video 300 + while (!videoEmbed()) { 301 + files.forEach(file => { 302 + if (file.type.includes('video')) { 303 + videoEmbed.set(new VideoEmbed(file, undefined)); 304 + } 305 + }); 306 + } 307 + } 308 + } 309 + 310 + publishPost(): Promise<void> { 311 + return new Promise((resolve, reject) => { 312 + const rt = new RichText({ 313 + text: this.postCompose().post().text 314 + }); 315 + 316 + Promise.all([ 317 + this.prepareRecord(), 318 + this.prepareMedia(), 319 + rt.detectFacets(agent) 320 + ]).then(([record, media]) => { 321 + if (record && media) { 322 + this.postCompose().post().embed = { 323 + $type: 'app.bsky.embed.recordWithMedia', 324 + record: record, 325 + media: media 326 + } as $Typed<AppBskyEmbedRecordWithMedia.Main>; 327 + } else { 328 + this.postCompose().post().embed = record ?? media; 329 + } 330 + this.postCompose().post.update(post => { 331 + post.text = rt.text; 332 + post.facets = rt.facets; 333 + post.createdAt = new Date().toISOString(); 334 + return post; 335 + }); 336 + 337 + from(agent.post(this.postCompose().post())).subscribe({ 338 + next: () => { 339 + this.postCompose.set(undefined); 340 + 341 + setTimeout(() => { 342 + this.refreshFeeds.next(); 343 + }, 1e3); 344 + 345 + resolve(); 346 + }, 347 + error: (err: HttpErrorResponse) => { 348 + reject(err); 349 + } 350 + }); 351 + }); 352 + }); 353 + } 354 + 355 + private prepareRecord(): Promise<$Typed<AppBskyEmbedRecord.Main>> { 356 + return new Promise(resolve => { 357 + if (!this.postCompose().recordEmbed()) { 358 + resolve(undefined) 359 + } else { 360 + resolve({ 361 + $type: 'app.bsky.embed.record', 362 + record: { 363 + uri: this.postCompose().recordEmbed().uri, 364 + cid: this.postCompose().recordEmbed().cid 365 + } 366 + }); 367 + } 368 + }); 369 + } 370 + 371 + private prepareMedia(): Promise<$Typed<AppBskyEmbedImages.Main> | $Typed<AppBskyEmbedVideo.Main> | $Typed<AppBskyEmbedExternal.Main>> { 372 + return new Promise((resolve, reject) => { 373 + if (!this.postCompose().mediaEmbed()) resolve(undefined); 374 + 375 + if (this.postCompose().mediaEmbed()?.type == EmbedType.IMAGE) { 376 + const imageEmbed = this.postCompose().mediaEmbed as WritableSignal<ImageEmbed>; 377 + 378 + from(Promise.all( 379 + imageEmbed().images.map(i => { 380 + return this.imageCompressService.compressFile(i.data, DOC_ORIENTATION.Default, undefined, undefined, 2000, 2000); 381 + }) 382 + )).subscribe({ 383 + next: images64 => { 384 + from( 385 + Promise.all(images64.map(image => fetch(image).then(res => res.blob()))) 386 + ).subscribe({ 387 + next: blobs => { 388 + from( 389 + Promise.all(blobs.map(b => agent.uploadBlob(b))) 390 + ).subscribe({ 391 + next: upload => { 392 + resolve({ 393 + $type: 'app.bsky.embed.images', 394 + images: upload.map(response => { 395 + return { 396 + alt: '', 397 + image: response.data.blob 398 + } 399 + }) 400 + } as $Typed<AppBskyEmbedImages.Main>); 401 + }, 402 + error: err => reject(err) 403 + }) 404 + }, 405 + error: err => reject(err) 406 + }) 407 + }, 408 + error: err => reject(err) 409 + }); 410 + } 411 + 412 + if (this.postCompose().mediaEmbed()?.type == EmbedType.VIDEO) { 413 + // const videoEmbed = this.postCompose().mediaEmbed as WritableSignal<VideoEmbed>; 414 + resolve(undefined); 415 + } 416 + 417 + if (this.postCompose().mediaEmbed().type == EmbedType.EXTERNAL) { 418 + const externalEmbed = this.postCompose().mediaEmbed as WritableSignal<ExternalEmbed>; 419 + 420 + from( 421 + fetch(externalEmbed().metadata.imageUrl) 422 + .then(res => res.blob()) 423 + .then(blob => agent.uploadBlob(blob)) 424 + ).subscribe({ 425 + next: response => { 426 + resolve({ 427 + $type: 'app.bsky.embed.external', 428 + external: { 429 + uri: externalEmbed().metadata.url, 430 + title: externalEmbed().metadata.title, 431 + description: externalEmbed().metadata.description, 432 + thumb: response.data.blob 433 + } 434 + } as $Typed<AppBskyEmbedExternal.Main>) 435 + }, error: err => reject(err) 436 + }) 437 + } 438 + }); 439 + } 440 + }
+29
src/app/services/storage.service.ts
··· 1 + import {inject, Injectable, PLATFORM_ID} from '@angular/core'; 2 + import {isPlatformBrowser} from '@angular/common'; 3 + 4 + @Injectable({ 5 + providedIn: 'root' 6 + }) 7 + export class StorageService { 8 + private readonly platformId = inject(PLATFORM_ID); 9 + 10 + set(id: string, item: string) { 11 + if (isPlatformBrowser(this.platformId)) { 12 + localStorage.setItem(id, item); 13 + } 14 + } 15 + 16 + get(id: string): string | null { 17 + if (isPlatformBrowser(this.platformId)) { 18 + return localStorage.getItem(id); 19 + } else { 20 + return null; 21 + } 22 + } 23 + 24 + remove(id: string) { 25 + if (isPlatformBrowser(this.platformId)) { 26 + localStorage.removeItem(id); 27 + } 28 + } 29 + }
+28
src/app/shared/directives/scroll.directive.ts
··· 1 + import {Directive, ElementRef, HostListener, output} from '@angular/core'; 2 + 3 + @Directive({ 4 + selector: '[vScroll]' 5 + }) 6 + export class ScrollDirective { 7 + scrollEnding = output(); 8 + scrollTop = output(); 9 + emitted = false; 10 + 11 + constructor( 12 + private elemRef: ElementRef 13 + ) {} 14 + 15 + @HostListener('scroll', []) 16 + onScroll() { 17 + if (!this.elemRef.nativeElement.scrollTop) { 18 + this.emitted = true; 19 + this.scrollTop.emit(); 20 + } else if (this.elemRef.nativeElement.scrollTop + this.elemRef.nativeElement.offsetHeight + 300 >= this.elemRef.nativeElement.scrollHeight && !this.emitted) { 21 + this.emitted = true; 22 + this.scrollEnding.emit(); 23 + } else if (this.elemRef.nativeElement.scrollTop + this.elemRef.nativeElement.offsetHeight + 300 < this.elemRef.nativeElement.scrollHeight) { 24 + this.emitted = false; 25 + } 26 + } 27 + 28 + }
+38
src/app/shared/pipes/date-formatter.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {formatDistanceToNowStrict} from "date-fns"; 3 + 4 + @Pipe({ 5 + name: 'dateFormatter', 6 + pure: false 7 + }) 8 + export class DateFormatterPipe implements PipeTransform { 9 + transform(value: string | Date): string { 10 + return formatDistanceToNowStrict(value, { 11 + locale: { 12 + formatDistance: (unit, count) => { 13 + switch (true) { 14 + case unit === 'xSeconds': 15 + return `${count}s`; 16 + 17 + case unit === 'xMinutes': 18 + return `${count}m`; 19 + 20 + case unit === 'xHours': 21 + return `${count}h`; 22 + 23 + case unit === 'xDays': 24 + return `${count}d`; 25 + 26 + case unit === 'xMonths': 27 + return `${count}mon`; 28 + 29 + case unit === 'xYears': 30 + return `${count}y`; 31 + } 32 + 33 + return '%d hours'; 34 + } 35 + } 36 + }); 37 + } 38 + }
+10
src/app/shared/pipes/display-name.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + 3 + @Pipe({ 4 + name: 'displayName' 5 + }) 6 + export class DisplayNamePipe implements PipeTransform { 7 + transform(author: Partial<{displayName: string, handle: string}>): string { 8 + return author.displayName?.trim().length ? author.displayName : `@${author.handle}`; 9 + } 10 + }
+11
src/app/shared/pipes/is-logged-user.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyActorDefs} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isLoggedUser' 6 + }) 7 + export class IsLoggedUserPipe implements PipeTransform { 8 + transform(did: string, loggedUser: AppBskyActorDefs.ProfileViewDetailed): boolean { 9 + return did == loggedUser.did; 10 + } 11 + }
+12
src/app/shared/pipes/is-post-reposted.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyFeedDefs} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isPostReposted', 6 + standalone: true 7 + }) 8 + export class IsPostRepostedPipe implements PipeTransform { 9 + transform(post: AppBskyFeedDefs.PostView): boolean { 10 + return !!post.viewer.repost; 11 + } 12 + }
+12
src/app/shared/pipes/number-formatter.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + 3 + @Pipe({ 4 + name: 'numberFormatter' 5 + }) 6 + export class NumberFormatterPipe implements PipeTransform { 7 + transform(value: number): string { 8 + if (value >= 1e5) return (value / 1e3).toFixed() + 'K'; 9 + if (value >= 1e3) return (value / 1e3).toFixed(1) + 'K'; 10 + return value.toString(); 11 + } 12 + }
+11
src/app/shared/pipes/post-composer-height.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + 3 + @Pipe({ 4 + name: 'postComposerHeight', 5 + pure: false 6 + }) 7 + export class PostComposerHeightPipe implements PipeTransform { 8 + transform(textElement: HTMLDivElement): boolean { 9 + return textElement.offsetHeight > parseFloat(window.getComputedStyle(textElement).fontSize) * 7; 10 + } 11 + }
+12
src/app/shared/pipes/type-guards/is-actor-defs-profileviewbasic.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyActorDefs} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isActorDefsProfileViewBasic', 6 + standalone: true 7 + }) 8 + export class IsActorDefsProfileViewBasicPipe implements PipeTransform { 9 + transform(value: unknown): value is AppBskyActorDefs.ProfileViewBasic { 10 + return AppBskyActorDefs.isProfileViewBasic(value); 11 + } 12 + }
+12
src/app/shared/pipes/type-guards/is-deckcolumn-author.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AuthorDeckColumn, DeckColumnType} from "@models/deck-column"; 3 + 4 + @Pipe({ 5 + name: 'isDeckColumnAuthor' 6 + }) 7 + export class IsDeckColumnAuthorPipe implements PipeTransform { 8 + transform(value: unknown): value is AuthorDeckColumn { 9 + const typedValue = value as AuthorDeckColumn; 10 + return typedValue && typedValue.type && typedValue.type == DeckColumnType.AUTHOR; 11 + } 12 + }
+12
src/app/shared/pipes/type-guards/is-deckcolumn-generator.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {DeckColumnType, GeneratorDeckColumn} from "@models/deck-column"; 3 + 4 + @Pipe({ 5 + name: 'isDeckColumnGenerator' 6 + }) 7 + export class IsDeckColumnGeneratorPipe implements PipeTransform { 8 + transform(value: unknown): value is GeneratorDeckColumn { 9 + const typedValue = value as GeneratorDeckColumn; 10 + return typedValue && typedValue.type && typedValue.type == DeckColumnType.GENERATOR; 11 + } 12 + }
+12
src/app/shared/pipes/type-guards/is-deckcolumn-list.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {DeckColumnType, ListDeckColumn} from "@models/deck-column"; 3 + 4 + @Pipe({ 5 + name: 'isDeckColumnList' 6 + }) 7 + export class IsDeckColumnListPipe implements PipeTransform { 8 + transform(value: unknown): value is ListDeckColumn { 9 + const typedValue = value as ListDeckColumn; 10 + return typedValue && typedValue.type && typedValue.type == DeckColumnType.LIST; 11 + } 12 + }
+12
src/app/shared/pipes/type-guards/is-deckcolumn-notifications.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {DeckColumnType, NotificationDeckColumn} from "@models/deck-column"; 3 + 4 + @Pipe({ 5 + name: 'isDeckColumnNotification' 6 + }) 7 + export class IsDeckColumnNotificationPipe implements PipeTransform { 8 + transform(value: unknown): value is NotificationDeckColumn { 9 + const typedValue = value as NotificationDeckColumn; 10 + return typedValue && typedValue.type && typedValue.type == DeckColumnType.NOTIFICATION; 11 + } 12 + }
+12
src/app/shared/pipes/type-guards/is-deckcolumn-search.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {DeckColumnType, SearchDeckColumn} from "@models/deck-column"; 3 + 4 + @Pipe({ 5 + name: 'isDeckColumnSearch' 6 + }) 7 + export class IsDeckColumnSearchPipe implements PipeTransform { 8 + transform(value: unknown): value is SearchDeckColumn { 9 + const typedValue = value as SearchDeckColumn; 10 + return typedValue && typedValue.type && typedValue.type == DeckColumnType.SEARCH; 11 + } 12 + }
+12
src/app/shared/pipes/type-guards/is-deckcolumn-timeline.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {DeckColumnType, TimelineDeckColumn} from "@models/deck-column"; 3 + 4 + @Pipe({ 5 + name: 'isDeckColumnTimeline' 6 + }) 7 + export class IsDeckColumnTimelinePipe implements PipeTransform { 8 + transform(value: unknown): value is TimelineDeckColumn { 9 + const typedValue = value as TimelineDeckColumn; 10 + return typedValue && typedValue.type && typedValue.type == DeckColumnType.TIMELINE; 11 + } 12 + }
+11
src/app/shared/pipes/type-guards/is-embed-external-view.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyEmbedExternal} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isEmbedExternalView' 6 + }) 7 + export class IsEmbedExternalViewPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyEmbedExternal.View { 9 + return AppBskyEmbedExternal.isView(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-embed-images-view.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyEmbedImages} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isEmbedImagesView' 6 + }) 7 + export class IsEmbedImagesViewPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyEmbedImages.View { 9 + return AppBskyEmbedImages.isView(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-embed-record-view.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyEmbedRecord} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isEmbedRecordView' 6 + }) 7 + export class IsEmbedRecordViewPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyEmbedRecord.View { 9 + return AppBskyEmbedRecord.isView(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-embed-record-viewblocked.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyEmbedRecord} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isEmbedRecordViewBlocked' 6 + }) 7 + export class IsEmbedRecordViewBlockedPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyEmbedRecord.ViewBlocked { 9 + return AppBskyEmbedRecord.isViewBlocked(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-embed-record-viewdetached.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyEmbedRecord} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isEmbedRecordViewDetached' 6 + }) 7 + export class IsEmbedRecordViewDetachedPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyEmbedRecord.ViewDetached { 9 + return AppBskyEmbedRecord.isViewDetached(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-embed-record-viewnotfound.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyEmbedRecord} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isEmbedRecordViewNotFound' 6 + }) 7 + export class IsEmbedRecordViewNotFoundPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyEmbedRecord.ViewNotFound { 9 + return AppBskyEmbedRecord.isViewNotFound(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-embed-record-viewrecord.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyEmbedRecord} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isEmbedRecordViewRecord' 6 + }) 7 + export class IsEmbedRecordViewRecordPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyEmbedRecord.ViewRecord { 9 + return AppBskyEmbedRecord.isViewRecord(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-embed-recordwithmedia-view.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyEmbedRecordWithMedia} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isEmbedRecordWithMediaView' 6 + }) 7 + export class IsEmbedRecordWithMediaViewPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyEmbedRecordWithMedia.View { 9 + return AppBskyEmbedRecordWithMedia.isView(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-embed-video-view.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyEmbedVideo} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isEmbedVideoView' 6 + }) 7 + export class IsEmbedVideoViewPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyEmbedVideo.View { 9 + return AppBskyEmbedVideo.isView(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-feed-defs-blockedpost.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyFeedDefs} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isFeedDefsBlockedPost' 6 + }) 7 + export class IsFeedDefsBlockedPostPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyFeedDefs.BlockedPost { 9 + return AppBskyFeedDefs.isBlockedPost(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-feed-defs-feedviewpost-array.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyFeedDefs} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isFeedDefsFeedViewPostArray', 6 + }) 7 + export class IsFeedDefsFeedViewPostArrayPipe implements PipeTransform { 8 + transform(value: unknown[]): value is AppBskyFeedDefs.FeedViewPost[] { 9 + return value && value.length && !!(value[0] as AppBskyFeedDefs.FeedViewPost).post; 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-feed-defs-generator-view.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyFeedDefs} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isFeedDefsGeneratorView' 6 + }) 7 + export class IsFeedDefsGeneratorViewPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyFeedDefs.GeneratorView { 9 + return AppBskyFeedDefs.isGeneratorView(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-feed-defs-notfoundpost.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyFeedDefs} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isFeedDefsNotFoundPost' 6 + }) 7 + export class IsFeedDefsNotFoundPostPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyFeedDefs.NotFoundPost { 9 + return AppBskyFeedDefs.isNotFoundPost(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-feed-defs-postview.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyFeedDefs} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isFeedDefsPostView' 6 + }) 7 + export class IsFeedDefsPostViewPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyFeedDefs.PostView { 9 + return AppBskyFeedDefs.isPostView(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-feed-defs-reasonpin.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyFeedDefs} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isFeedDefsReasonPin' 6 + }) 7 + export class IsFeedDefsReasonPinPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyFeedDefs.ReasonPin { 9 + return AppBskyFeedDefs.isReasonPin(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-feed-defs-reasonrepost.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyFeedDefs} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isFeedDefsReasonRepost' 6 + }) 7 + export class IsFeedDefsReasonRepostPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyFeedDefs.ReasonRepost { 9 + return AppBskyFeedDefs.isReasonRepost(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-feed-defs-replyref.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyFeedDefs} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isFeedDefsReplyRef' 6 + }) 7 + export class IsFeedDefsReplyRefPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyFeedDefs.ReplyRef { 9 + return AppBskyFeedDefs.isReplyRef(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-feed-post-record.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyFeedPost} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isFeedPostRecord' 6 + }) 7 + export class IsFeedPostRecordPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyFeedPost.Record { 9 + return AppBskyFeedPost.isRecord(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-feed-post-replyref.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyFeedPost} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isFeedPostReplyRef' 6 + }) 7 + export class IsFeedPostReplyRefPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyFeedPost.ReplyRef { 9 + return AppBskyFeedPost.isReplyRef(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-graph-defs-list-view.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyGraphDefs} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isGraphDefsListView' 6 + }) 7 + export class IsGraphDefsListViewPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyGraphDefs.ListView { 9 + return AppBskyGraphDefs.isListView(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-graph-defs-starterpack-view.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyGraphDefs} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isGraphDefsStarterPackView' 6 + }) 7 + export class IsGraphDefsStarterPackViewPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyGraphDefs.StarterPackView { 9 + return AppBskyGraphDefs.isStarterPackView(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-graph-defs-starterpack-viewbasic.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyGraphDefs} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isGraphDefsStarterPackViewBasic' 6 + }) 7 + export class IsGraphDefsStarterPackViewBasicPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyGraphDefs.StarterPackViewBasic { 9 + return AppBskyGraphDefs.isStarterPackViewBasic(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-labeler-defs-labeler-view.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AppBskyLabelerDefs} from "@atproto/api"; 3 + 4 + @Pipe({ 5 + name: 'isLabelerDefsLabelerView' 6 + }) 7 + export class IsLabelerDefsLabelerViewPipe implements PipeTransform { 8 + transform(value: unknown): value is AppBskyLabelerDefs.LabelerView { 9 + return AppBskyLabelerDefs.isLabelerView(value); 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-media-embed-external.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {EmbedType, ExternalEmbed} from "@models/embed"; 3 + 4 + @Pipe({ 5 + name: 'isMediaEmbedExternal' 6 + }) 7 + export class IsMediaEmbedExternalPipe implements PipeTransform { 8 + transform(value: unknown): value is ExternalEmbed { 9 + return (value as ExternalEmbed)?.type == EmbedType.EXTERNAL; 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-media-embed-image.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {EmbedType, ImageEmbed} from '@models/embed'; 3 + 4 + @Pipe({ 5 + name: 'isMediaEmbedImage' 6 + }) 7 + export class IsMediaEmbedImagePipe implements PipeTransform { 8 + transform(value: unknown): value is ImageEmbed { 9 + return (value as ImageEmbed)?.type == EmbedType.IMAGE; 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/is-media-embed-video.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {EmbedType, VideoEmbed} from "@models/embed"; 3 + 4 + @Pipe({ 5 + name: 'isMediaEmbedVideo' 6 + }) 7 + export class IsMediaEmbedVideoPipe implements PipeTransform { 8 + transform(value: unknown): value is VideoEmbed { 9 + return (value as VideoEmbed)?.type == EmbedType.VIDEO; 10 + } 11 + }
+15
src/app/shared/pipes/type-guards/is-signalized-feedviewpost.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {SignalizedFeedViewPost} from "@models/signalized-feed-view-post"; 3 + import {AppBskyFeedDefs} from "@atproto/api"; 4 + 5 + @Pipe({ 6 + name: 'isSignalizedFeedViewPost' 7 + }) 8 + export class IsSignalizedFeedViewPostPipe implements PipeTransform { 9 + transform(value: unknown): value is SignalizedFeedViewPost { 10 + return value && 11 + (value as SignalizedFeedViewPost).post && 12 + (value as SignalizedFeedViewPost).post() && 13 + AppBskyFeedDefs.isFeedViewPost((value as SignalizedFeedViewPost).post()); 14 + } 15 + }
+11
src/app/shared/pipes/type-guards/notifications/is-follow-notification.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {Notification} from "@models/notification"; 3 + 4 + @Pipe({ 5 + name: 'isFollowNotification' 6 + }) 7 + export class IsFollowNotificationPipe implements PipeTransform { 8 + transform(value: Notification): boolean { 9 + return value.reason == "follow"; 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/notifications/is-like-notification.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {Notification} from "@models/notification"; 3 + 4 + @Pipe({ 5 + name: 'isLikeNotification' 6 + }) 7 + export class IsLikeNotificationPipe implements PipeTransform { 8 + transform(value: Notification): boolean { 9 + return value.reason == "like"; 10 + } 11 + }
+15
src/app/shared/pipes/type-guards/notifications/is-post-notification.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {Notification} from "@models/notification"; 3 + 4 + @Pipe({ 5 + name: 'isPostNotification' 6 + }) 7 + export class IsNotificationArrayPipe implements PipeTransform { 8 + transform(value: Notification): boolean { 9 + return value.post && ( 10 + value.reason == 'reply' || 11 + value.reason == 'quote' || 12 + value.reason == 'mention' 13 + ); 14 + } 15 + }
+11
src/app/shared/pipes/type-guards/notifications/is-repost-notification.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {Notification} from "@models/notification"; 3 + 4 + @Pipe({ 5 + name: 'isRepostNotification' 6 + }) 7 + export class IsRepostNotificationPipe implements PipeTransform { 8 + transform(value: Notification): boolean { 9 + return value.reason == "repost"; 10 + } 11 + }
+11
src/app/shared/pipes/type-guards/notifications/is-starterpack-notification.pipe.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {Notification} from '@models/notification'; 3 + 4 + @Pipe({ 5 + name: 'isStarterPackNotification' 6 + }) 7 + export class IsStarterPackNotificationPipe implements PipeTransform { 8 + transform(value: Notification): boolean { 9 + return value.reason == "starterpack-joined"; 10 + } 11 + }
+44
src/app/shared/utils/embed-utils.ts
··· 1 + import {ExternalEmbed, RecordEmbed, RecordEmbedType} from "@models/embed"; 2 + import {URL_REGEX} from "@atproto/api"; 3 + 4 + export const BSKY_PROFILE_URL_RE = /\/profile\/([^\/]+)$/; 5 + export const BSKY_POST_URL_RE = /\/profile\/([^\/]+)\/post\/([^\/]+)$/; 6 + export const BSKY_FEED_URL_RE = /\/profile\/([^\/]+)\/feed\/([^\/]+)$/; 7 + export const BSKY_LIST_URL_RE = /\/profile\/([^\/]+)\/lists\/([^\/]+)$/; 8 + export const BSKY_STARTER_PACK_URL_RE = /\/starter-pack\/([^\/]+)\/([^\/]+)$/; 9 + 10 + export class EmbedUtils { 11 + public static findEmbedSuggestions(text: string): Array<ExternalEmbed | RecordEmbed> { 12 + const embeds: Array<ExternalEmbed | RecordEmbed> = []; 13 + const matches = text.match(URL_REGEX) ?? []; 14 + let split: RegExpExecArray | null; 15 + 16 + matches.forEach(match => { 17 + if (match.includes('bsky.app')) { 18 + if ((split = BSKY_POST_URL_RE.exec(match))) { 19 + embeds.push(new RecordEmbed(RecordEmbedType.POST, split[1], split[2])); 20 + return; 21 + } 22 + if ((split = BSKY_FEED_URL_RE.exec(match))) { 23 + embeds.push(new RecordEmbed(RecordEmbedType.FEED, split[1], split[2])); 24 + return; 25 + } 26 + if ((split = BSKY_LIST_URL_RE.exec(match))) { 27 + embeds.push(new RecordEmbed(RecordEmbedType.LIST, split[1], split[2])); 28 + return; 29 + } 30 + if ((split = BSKY_STARTER_PACK_URL_RE.exec(match))) { 31 + embeds.push(new RecordEmbed(RecordEmbedType.STARTER_PACK, split[1], split[2])); 32 + return; 33 + } 34 + 35 + embeds.push(new ExternalEmbed(match)); 36 + return; 37 + } 38 + 39 + embeds.push(new ExternalEmbed(match)); 40 + }); 41 + 42 + return embeds; 43 + } 44 + }
+67
src/app/shared/utils/notification-utils.ts
··· 1 + import {AppBskyFeedGetPosts, AppBskyNotificationListNotifications} from '@atproto/api'; 2 + import {Notification} from '@models/notification' 3 + import {agent} from '@core/bsky.api'; 4 + import {PostService} from '@services/post.service'; 5 + 6 + export default class NotificationUtils { 7 + public static parseNotifications(notifications: AppBskyNotificationListNotifications.Notification[], postService: PostService): Promise<Notification[]> { 8 + return new Promise<Notification[]>((resolve) => { 9 + const target: Notification[] = []; 10 + while (notifications.length) { 11 + const temp = new Notification(); 12 + if (notifications[0].reason === 'reply' || notifications[0].reason === 'quote' || notifications[0].reason === 'mention') { 13 + temp.authors = [notifications[0].author]; 14 + temp.reason = notifications[0].reason; 15 + temp.notification = notifications[0]; 16 + temp.uri = notifications[0].uri; 17 + 18 + target.push(temp); 19 + notifications.shift(); 20 + } else if (notifications[0].reason) { 21 + const slice = notifications.filter(n => 22 + (n.reasonSubject === notifications[0].reasonSubject && n.reason === notifications[0].reason) 23 + ); 24 + 25 + temp.authors = slice.map(s => s.author); 26 + temp.reason = notifications[0].reason; 27 + temp.notification = notifications[0]; 28 + temp.uri = notifications[0].reasonSubject; 29 + 30 + target.push(temp); 31 + 32 + notifications = notifications.filter(n => 33 + !(n.reasonSubject === notifications[0].reasonSubject && n.reason === notifications[0].reason) 34 + ); 35 + } 36 + } 37 + 38 + const chunkFn = (arr: Notification[], size: number) => 39 + Array.from({ length: Math.ceil(arr.length / size) }, (v, i) => 40 + arr.slice(i * size, i * size + size) 41 + ); 42 + 43 + const promises: Promise<AppBskyFeedGetPosts.Response>[] = []; 44 + const tempChunks = chunkFn(target, 25); 45 + 46 + tempChunks.forEach(array => { 47 + promises.push( 48 + agent.getPosts({ 49 + uris: array.map(n => n.uri).filter(n => n) 50 + }) 51 + ) 52 + }); 53 + 54 + Promise.all(promises).then(chunk => { 55 + 56 + chunk.forEach(response => { 57 + target.forEach(notification => { 58 + const postView = response.data.posts.find(post => post.uri === notification.uri); 59 + if (postView) { 60 + notification.post = postService.setPost(postView); 61 + } 62 + }) 63 + }) 64 + }, error => console.error(error)).then(() => resolve(target)); 65 + }); 66 + } 67 + }
+16
src/app/shared/utils/post-utils.ts
··· 1 + import {AppBskyFeedDefs, AppBskyFeedPost} from "@atproto/api"; 2 + import {PostService} from '@services/post.service'; 3 + import {SignalizedFeedViewPost} from '@models/signalized-feed-view-post'; 4 + 5 + export class PostUtils { 6 + public static parseFeedViewPost(feedViewPost: AppBskyFeedDefs.FeedViewPost, postService: PostService): SignalizedFeedViewPost { 7 + const signalizedFeedViewPost = new SignalizedFeedViewPost(); 8 + feedViewPost.post.record = feedViewPost.post.record as AppBskyFeedPost.Record; 9 + signalizedFeedViewPost.post = postService.setPost(feedViewPost.post); 10 + signalizedFeedViewPost.reply = feedViewPost.reply; 11 + signalizedFeedViewPost.reason = feedViewPost.reason; 12 + signalizedFeedViewPost.feedContext = feedViewPost.feedContext; 13 + 14 + return signalizedFeedViewPost; 15 + } 16 + }
+86
src/app/shared/utils/snippet-utils.ts
··· 1 + import {AppBskyEmbedExternal} from "@atproto/api"; 2 + import {BlueskyGifSnippet, IframeSnippet, LinkSnippet, SnippetSource, SnippetType} from "@models/snippet"; 3 + 4 + export class SnippetUtils { 5 + public static detectSnippet(link: Pick<AppBskyEmbedExternal.ViewExternal, 'uri' | 'description'>): LinkSnippet | BlueskyGifSnippet | IframeSnippet { 6 + const url = link.uri; 7 + 8 + let u: URL; 9 + let m: RegExpExecArray | null | undefined; 10 + 11 + try { 12 + u = new URL(url); 13 + 14 + if (u.protocol !== 'https:' && u.protocol !== 'http:') { 15 + return { type: SnippetType.LINK } as LinkSnippet; 16 + } 17 + } catch { 18 + return { type: SnippetType.LINK } as LinkSnippet; 19 + } 20 + 21 + const h = u.host; 22 + const p = u.pathname; 23 + const q = u.searchParams; 24 + 25 + const d = h.startsWith('www.') ? h.slice(4) : h; 26 + 27 + if (d === 'media.tenor.com') { 28 + // Bluesky GIFs 29 + if ((m = /\/([^/]+?AAAAC)\/([^/]+?)\?hh=(\d+?)&ww=(\d+?)$/.exec(url))) { 30 + const id = m[1].replace(/AAAAC$/, 'AAAP3'); 31 + const file = m[2].replace(/\.gif$/, '.webm'); 32 + 33 + const width = m[4]; 34 + const height = m[3]; 35 + 36 + return new BlueskyGifSnippet( 37 + d, 38 + `https://t.gifs.bsky.app/${id}/${file}`, 39 + `${width}:${height}`, 40 + link.description.replace(/^ALT: /, '') 41 + ); 42 + } 43 + } else if (d === 'youtube.com' || d === 'm.youtube.com' || d === 'music.youtube.com') { 44 + // YouTube iframe 45 + if (p === '/watch') { 46 + const videoId = q.get('v'); 47 + const seek = q.get('t') || 0; 48 + 49 + return new IframeSnippet( 50 + d, 51 + videoId, 52 + d !== 'music.youtube.com' ? `16:9` : `1:1`, 53 + SnippetSource.YOUTUBE, 54 + Number.parseInt(seek.toString()) 55 + ); 56 + } 57 + } else if (d === 'youtu.be') { 58 + // YouTube iframe 59 + if ((m = /^\/([^/]+?)$/.exec(p))) { 60 + const videoId = m[1]; 61 + const seek = q.get('t') || 0; 62 + 63 + return new IframeSnippet( 64 + d, 65 + videoId, 66 + `16:9`, 67 + SnippetSource.YOUTUBE, 68 + Number.parseInt(seek.toString()) 69 + ); 70 + } 71 + } else if (d === 'soundcloud.com' || d === 'www.soundcloud.com') { 72 + // SoundCloud embed 73 + if ((m = /^\/([^/]+?)(?:\/(?!reposts$)([^/]+?)|\/sets\/([^/]+?))?$/.exec(p))) { 74 + return new IframeSnippet( 75 + d, 76 + `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&auto_play=false&visual=false&hide_related=true`, 77 + m[3] ? `1:1` : `16:9`, 78 + SnippetSource.SOUNDCLOUD 79 + ); 80 + } 81 + } 82 + 83 + // Link snippet, always matches 84 + return new LinkSnippet(d, u.toString()); 85 + } 86 + }
+33
src/app/views/dashboard/dashboard.component.html
··· 1 + <div 2 + class="flex h-full w-full p-4 overflow-hidden" 3 + > 4 + <sidebar/> 5 + <!-- <sidebar--> 6 + <!-- (onProfile)="dialogService.openAuthor($event)"--> 7 + <!-- (onSearch)="dialogService.openSearch()"--> 8 + <!-- (onFeeds)="dialogService.openLikedFeeds()"--> 9 + <!-- (onPost)="postService.createPost()"--> 10 + <!-- />--> 11 + 12 + <!-- <div--> 13 + <!-- class="relative flex-1 overflow-hidden"--> 14 + <!-- >--> 15 + <!-- <deck--> 16 + <!-- class="relative flex w-full h-full max-h-full min-h-0 p-4 overflow-x-auto"--> 17 + <!-- >--> 18 + <!-- </deck>--> 19 + <!-- </div>--> 20 + <div 21 + class="flex flex-col flex-1 min-w-0" 22 + > 23 + <deck 24 + class="flex-1 min-h-0 min-w-0 border-b border-primary" 25 + /> 26 + @if (postService.postCompose()) { 27 + <post-composer 28 + class="shrink-0" 29 + /> 30 + } 31 + </div> 32 + <auxbar/> 33 + </div>
+26
src/app/views/dashboard/dashboard.component.ts
··· 1 + import {ChangeDetectionStrategy, Component} from '@angular/core'; 2 + import {SidebarComponent} from '@components/navigation/sidebar/sidebar.component'; 3 + import {DeckComponent} from '@components/navigation/deck/deck.component'; 4 + import {PostComposerComponent} from '@components/navigation/post-composer/post-composer.component'; 5 + import {PostService} from '@services/post.service'; 6 + import {AuxbarComponent} from '@components/navigation/auxbar/auxbar.component'; 7 + // import {MskyDialogService} from '@services/msky-dialog.service'; 8 + // import {PostService} from '@services/post.service'; 9 + 10 + @Component({ 11 + selector: 'app-dashboard', 12 + imports: [ 13 + SidebarComponent, 14 + DeckComponent, 15 + PostComposerComponent, 16 + AuxbarComponent 17 + ], 18 + templateUrl: './dashboard.component.html', 19 + changeDetection: ChangeDetectionStrategy.OnPush 20 + }) 21 + export class DashboardComponent { 22 + constructor( 23 + // protected dialogService: MskyDialogService, 24 + protected postService: PostService 25 + ) {} 26 + }
+46
src/app/views/login/login.component.html
··· 1 + <div 2 + class="h-full w-full flex flex-col items-center justify-center" 3 + > 4 + <div 5 + class="w-80 border flex-col" 6 + > 7 + <div 8 + class="h-12 bg-primary flex items-center justify-center" 9 + > 10 + <span 11 + class="text-bg font-bold text-2xl" 12 + >//consolesky.</span> 13 + </div> 14 + 15 + <div 16 + class="p-2 flex flex-col font-mono" 17 + > 18 + <span 19 + class="text-sm" 20 + >handle</span> 21 + <input 22 + [(ngModel)]="credentials().identifier" 23 + (keydown.enter)="loginBtn.click()" 24 + name="handle" 25 + class="border outline-none px-1" 26 + /> 27 + 28 + <span 29 + class="text-sm mt-2" 30 + >password</span> 31 + <input 32 + [(ngModel)]="credentials().password" 33 + (keydown.enter)="loginBtn.click()" 34 + name="password" 35 + type="password" 36 + class="border outline-none px-1" 37 + /> 38 + 39 + <button 40 + #loginBtn 41 + (click)="onLogin()" 42 + class="btn-primary mt-2 ml-auto px-2 py-0.5" 43 + >Login</button> 44 + </div> 45 + </div> 46 + </div>
+35
src/app/views/login/login.component.ts
··· 1 + import {Component, signal} from '@angular/core'; 2 + import {AuthService} from '@core/auth/auth.service'; 3 + // import {MskyMessageService} from '@services/msky-message.service'; 4 + import {FormsModule} from '@angular/forms'; 5 + 6 + @Component({ 7 + selector: 'app-login', 8 + imports: [ 9 + FormsModule 10 + ], 11 + templateUrl: './login.component.html' 12 + }) 13 + export class LoginComponent { 14 + credentials = signal({ 15 + identifier: '', 16 + password: '' 17 + }); 18 + 19 + constructor( 20 + private authService: AuthService, 21 + // private messageService: MskyMessageService 22 + ) {} 23 + 24 + onLogin() { 25 + this.credentials().identifier = this.credentials().identifier.trim(); 26 + 27 + if ( 28 + this.credentials().identifier.length 29 + ) { 30 + this.authService.login(this.credentials()); 31 + } else { 32 + // this.messageService.warn('Please check your credentials.', 'Oops!'); 33 + } 34 + } 35 + }
+6
src/index.html
··· 6 6 <base href="/"> 7 7 <meta name="viewport" content="width=device-width, initial-scale=1"> 8 8 <link rel="icon" type="image/x-icon" href="favicon.ico"> 9 + <link rel="preconnect" href="https://fonts.googleapis.com"> 10 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 11 + <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap"> 12 + <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"> 13 + <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"> 14 + <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined"> 9 15 </head> 10 16 <body> 11 17 <app-root></app-root>
+3 -3
src/main.ts
··· 1 - import { bootstrapApplication } from '@angular/platform-browser'; 2 - import { appConfig } from './app/app.config'; 3 - import { AppComponent } from './app/app.component'; 1 + import {bootstrapApplication} from '@angular/platform-browser'; 2 + import {appConfig} from './app/app.config'; 3 + import {AppComponent} from './app/app.component'; 4 4 5 5 bootstrapApplication(AppComponent, appConfig) 6 6 .catch((err) => console.error(err));
+244 -1
src/styles.css
··· 1 - /* You can add global styles to this file, and also import other style files */ 1 + @import "tailwindcss"; 2 + @import '@angular/cdk/overlay-prebuilt.css'; 3 + @custom-variant theme-dark (&:where([data-theme="dark"] *)); 4 + @custom-variant theme-hacker (&:where([data-theme="hacker"] *)); 5 + 6 + html, body, .app-container { 7 + font-size: 14px; 8 + height: 100%; 9 + text-align: left; 10 + line-height: 1.3; 11 + letter-spacing: -0.025em; 12 + 13 + background-color: var(--color-bg); 14 + color: var(--color-primary); 15 + } 16 + 17 + * { 18 + box-sizing: border-box; 19 + scrollbar-width: thin; 20 + scrollbar-gutter: stable; 21 + } 22 + 23 + .material-icons { 24 + font-family: 'Material Icons'; 25 + font-weight: normal; 26 + font-style: normal; 27 + font-size: 16px; 28 + display: inline-block; 29 + line-height: 1; 30 + text-transform: none; 31 + letter-spacing: normal; 32 + word-wrap: normal; 33 + white-space: nowrap; 34 + direction: ltr; 35 + 36 + /* Support for all WebKit browsers. */ 37 + -webkit-font-smoothing: antialiased; 38 + /* Support for Safari and Chrome. */ 39 + text-rendering: optimizeLegibility; 40 + 41 + /* Support for Firefox. */ 42 + -moz-osx-font-smoothing: grayscale; 43 + 44 + /* Support for IE. */ 45 + font-feature-settings: 'liga'; 46 + } 47 + 48 + .material-icons-outlined { 49 + font-family: 'Material Icons Outlined'; 50 + font-weight: normal; 51 + font-style: normal; 52 + font-size: 16px; 53 + display: inline-block; 54 + line-height: 1; 55 + text-transform: none; 56 + letter-spacing: normal; 57 + word-wrap: normal; 58 + white-space: nowrap; 59 + direction: ltr; 60 + 61 + /* Support for all WebKit browsers. */ 62 + -webkit-font-smoothing: antialiased; 63 + /* Support for Safari and Chrome. */ 64 + text-rendering: optimizeLegibility; 65 + 66 + /* Support for Firefox. */ 67 + -moz-osx-font-smoothing: grayscale; 68 + 69 + /* Support for IE. */ 70 + font-feature-settings: 'liga'; 71 + } 72 + 73 + /* VIDEO.JS */ 74 + 75 + ::ng-deep .video-js .vjs-control-bar { 76 + background: linear-gradient(to top, rgba(43, 51, 63, 0.7), transparent); 77 + } 78 + ::ng-deep .video-js > .vjs-remaining-time { 79 + height: 0; 80 + position: absolute; 81 + bottom: 0; 82 + right: 0; 83 + font-size: 0.75rem; 84 + font-family: 'Inter', sans-serif; 85 + opacity: 0; 86 + } 87 + ::ng-deep .video-js.vjs-user-inactive .vjs-remaining-time { 88 + height: 2rem; 89 + opacity: 1; 90 + transition: 1.5s opacity ease; 91 + } 92 + 93 + @theme { 94 + --font-mono: "Source Code Pro", monospace; 95 + --font-sans: "Source Code Pro", sans-serif; 96 + 97 + --text-sm: 0.9285rem; 98 + 99 + --color-bg: #FFF; 100 + --color-primary: #000; 101 + --color-secondary: #E1E1E1; 102 + --color-repost: #00aa00; 103 + --color-like: #F00000; 104 + --color-selection-bg: rgba(0, 0, 0, 0.99); 105 + --color-selection-text: #FFF; 106 + --color-background: #FFF; 107 + --color-text: var(--color-base); 108 + --color-placeholder: var(--color-base); 109 + --color-link: var(--color-base); 110 + --color-code-1: #aaaaaa; 111 + --color-code-2: #ffffcc; 112 + --color-code-3: #F00000; 113 + --color-code-4: #F0A0A0; 114 + --color-code-5: #0000aa; 115 + --color-code-6: #4c8317; 116 + --color-code-7: #aa0000; 117 + --color-code-8: #000080; 118 + --color-code-9: #00aa00; 119 + --color-code-10: #888888; 120 + --color-code-11: #555555; 121 + --color-code-12: #800080; 122 + --color-code-13: #00aaaa; 123 + --color-code-14: #009999; 124 + --color-code-15: #aa5500; 125 + --color-code-16: #1e90ff; 126 + --color-code-17: #800000; 127 + --color-code-18: #bbbbbb; 128 + } 129 + 130 + @custom-variant midnight { 131 + &:where([data-theme="midnight"] *) { 132 + --color-base: #DBDBDB; 133 + --border: dashed 1px rgba(219, 219, 219, 0.9); 134 + --color-selection-bg: rgba(219, 219, 219, 0.99); 135 + --color-selection-text: #000; 136 + --color-background: #000; 137 + --color-text: var(--color-base); 138 + --color-placeholder: var(--color-base); 139 + --color-link: var(--color-base); 140 + --color-code-1: #aaaaaa; 141 + --color-code-2: #ffffcc; 142 + --color-code-3: #F00000; 143 + --color-code-4: #F0A0A0; 144 + --color-code-5: #b38aff; 145 + --color-code-6: #5ba711; 146 + --color-code-7: #e4e477; 147 + --color-code-8: #000080; 148 + --color-code-9: #05ca05; 149 + --color-code-10: #888888; 150 + --color-code-11: #555555; 151 + --color-code-12: #800080; 152 + --color-code-13: #00d4d4; 153 + --color-code-14: #00c1c1; 154 + --color-code-15: #ed9d13; 155 + --color-code-16: #1e90ff; 156 + --color-code-17: #800000; 157 + --color-code-18: #bbbbbb; 158 + } 159 + } 160 + 161 + @custom-variant hacker { 162 + &:where([data-theme="hacker"] *) { 163 + --color-base: #00ff00; 164 + --border: dashed 1px rgba(0, 255, 0, 0.9); 165 + --color-selection-bg: rgba(0, 255, 0, 0.99); 166 + --color-selection-text: #000; 167 + --color-background: #000; 168 + --color-text: var(--color-base); 169 + --color-placeholder: var(--color-base); 170 + --color-link: var(--color-base); 171 + --color-code-1: #aaaaaa; 172 + --color-code-2: #ffffcc; 173 + --color-code-3: #F00000; 174 + --color-code-4: #F0A0A0; 175 + --color-code-5: #b38aff; 176 + --color-code-6: #5ba711; 177 + --color-code-7: #e4e477; 178 + --color-code-8: #000080; 179 + --color-code-9: #05ca05; 180 + --color-code-10: #888888; 181 + --color-code-11: #555555; 182 + --color-code-12: #800080; 183 + --color-code-13: #00d4d4; 184 + --color-code-14: #00c1c1; 185 + --color-code-15: #ed9d13; 186 + --color-code-16: #1e90ff; 187 + --color-code-17: #800000; 188 + --color-code-18: #bbbbbb; 189 + } 190 + } 191 + 192 + @layer components { 193 + .btn-primary { 194 + box-sizing: border-box; 195 + border: 1px solid var(--color-primary); 196 + background-color: var(--color-primary); 197 + color: var(--color-bg); 198 + width: fit-content; 199 + text-box: trim-both cap alphabetic; 200 + padding: 0.5em 0.75em; 201 + cursor: pointer; 202 + min-height: 2em; 203 + 204 + &:hover { 205 + background-color: var(--color-bg); 206 + color: var(--color-primary); 207 + } 208 + } 209 + 210 + .btn-secondary { 211 + box-sizing: border-box; 212 + border: 1px solid var(--color-primary); 213 + background-color: var(--color-bg); 214 + color: var(--color-primary); 215 + width: fit-content; 216 + text-box: trim-both cap alphabetic; 217 + padding: 0.5em 0.75em; 218 + cursor: pointer; 219 + min-height: 2em; 220 + 221 + &:hover { 222 + background-color: color-mix(in oklab, var(--color-primary) /* #000 = #000000 */ 15%, transparent); 223 + color: var(--color-primary); 224 + } 225 + } 226 + 227 + .btn-dropdown { 228 + text-box: trim-both cap alphabetic; 229 + box-sizing: border-box; 230 + background-color: var(--color-bg); 231 + color: var(--color-primary); 232 + width: 100%; 233 + text-align: left; 234 + font-family: var(--font-mono); 235 + padding: 0.5em 0.75em; 236 + cursor: pointer; 237 + 238 + &:hover { 239 + background-color: var(--color-primary); 240 + color: var(--color-bg); 241 + } 242 + } 243 + } 244 +
+15 -2
tsconfig.json
··· 3 3 { 4 4 "compileOnSave": false, 5 5 "compilerOptions": { 6 + "baseUrl": "./src", 6 7 "outDir": "./dist/out-tsc", 7 8 "strict": true, 8 9 "noImplicitOverride": true, ··· 15 16 "experimentalDecorators": true, 16 17 "moduleResolution": "bundler", 17 18 "importHelpers": true, 18 - "target": "ES2022", 19 - "module": "ES2022" 19 + "target": "ES2023", 20 + "module": "ESNext", 21 + "paths": { 22 + "@components/*": ["app/components/*"], 23 + "@core/*": ["app/core/*"], 24 + "@models/*": ["app/models/*"], 25 + "@services/*": ["app/services/*"], 26 + "@shared/*": ["app/shared/*"], 27 + "@views/*": ["app/views/*"] 28 + }, 29 + "strictPropertyInitialization": false, 30 + "verbatimModuleSyntax": false, 31 + "strictNullChecks": false, 32 + "noImplicitAny": false 20 33 }, 21 34 "angularCompilerOptions": { 22 35 "enableI18nLegacyMessageIdFormat": false,