tangled
alpha
login
or
join now
dragon.gal
/
consolesky
4
fork
atom
Multicolumn Bluesky client powered by Angular
4
fork
atom
overview
issues
3
pulls
pipelines
first version
kryst4line
11 months ago
1a9b2ad0
c73cb93d
+4888
-360
122 changed files
expand all
collapse all
unified
split
.gitignore
.postcssrc.json
angular.json
main.js
package.json
src
app
app.component.html
app.component.spec.ts
app.component.ts
app.config.ts
app.routes.ts
components
cards
notification-card
notification-card.component.html
notification-card.component.ts
post-card
post-card.component.html
post-card.component.ts
deck-columns
author-deck-column
author-deck-column.component.html
author-deck-column.component.ts
notification-deck-column
notification-deck-column.component.html
notification-deck-column.component.ts
timeline-deck-column
timeline-deck-column.component.html
timeline-deck-column.component.ts
embeds
external-embed
external-embed.component.html
external-embed.component.ts
images-embed
images-embed.component.html
images-embed.component.ts
record-embed
record-embed.component.html
record-embed.component.ts
video-embed
video-embed.component.html
video-embed.component.ts
feeds
author-feed
author-feed.component.html
author-feed.component.ts
notification-feed
notification-feed.component.html
notification-feed.component.ts
timeline-feed
timeline-feed.component.html
timeline-feed.component.ts
navigation
auxbar
auxbar.component.html
auxbar.component.ts
deck
deck.component.html
deck.component.ts
post-composer
post-composer.component.html
post-composer.component.ts
sidebar
sidebar.component.html
sidebar.component.ts
shared
avatar
avatar.component.html
avatar.component.ts
rich-text
rich-text.component.html
rich-text.component.ts
core
auth
auth.guard.ts
auth.service.ts
bsky.api.ts
storage-keys.ts
models
deck-column.ts
embed.ts
notification.ts
post-compose.ts
signalized-feed-view-post.ts
snippet.ts
thread-reply.ts
url-metadata.ts
services
column.service.ts
embed.service.ts
post.service.ts
storage.service.ts
shared
directives
scroll.directive.ts
pipes
date-formatter.pipe.ts
display-name.pipe.ts
is-logged-user.pipe.ts
is-post-reposted.pipe.ts
link-extractor-starterpack.pipe.ts
link-extractor.pipe.ts
number-formatter.pipe.ts
post-composer-height.pipe.ts
type-guards
is-actor-defs-profileviewbasic.ts
is-deckcolumn-author.ts
is-deckcolumn-generator.ts
is-deckcolumn-list.ts
is-deckcolumn-notifications.ts
is-deckcolumn-search.ts
is-deckcolumn-timeline.ts
is-embed-external-view.pipe.ts
is-embed-images-view.pipe.ts
is-embed-record-view.pipe.ts
is-embed-record-viewblocked.pipe.ts
is-embed-record-viewdetached.pipe.ts
is-embed-record-viewnotfound.pipe.ts
is-embed-record-viewrecord.pipe.ts
is-embed-recordwithmedia-view.pipe.ts
is-embed-video-view.pipe.ts
is-feed-defs-blockedpost.ts
is-feed-defs-feedviewpost-array.ts
is-feed-defs-generator-view.ts
is-feed-defs-notfoundpost.ts
is-feed-defs-postview.ts
is-feed-defs-reasonpin.ts
is-feed-defs-reasonrepost.ts
is-feed-defs-replyref.ts
is-feed-post-record.ts
is-feed-post-replyref.ts
is-graph-defs-list-view.ts
is-graph-defs-starterpack-view.ts
is-graph-defs-starterpack-viewbasic.ts
is-labeler-defs-labeler-view.ts
is-media-embed-external.ts
is-media-embed-image.ts
is-media-embed-video.ts
is-signalized-feedviewpost.ts
notifications
is-follow-notification.pipe.ts
is-like-notification.pipe.ts
is-post-notification.ts
is-repost-notification.pipe.ts
is-starterpack-notification.pipe.ts
utils
embed-utils.ts
notification-utils.ts
post-utils.ts
snippet-utils.ts
views
dashboard
dashboard.component.html
dashboard.component.ts
login
login.component.html
login.component.ts
index.html
main.ts
styles.css
tsconfig.json
+3
.gitignore
···
40
40
# System files
41
41
.DS_Store
42
42
Thumbs.db
43
43
+
44
44
+
/docs
45
45
+
.package-lock.json
+5
.postcssrc.json
···
1
1
+
{
2
2
+
"plugins": {
3
3
+
"@tailwindcss/postcss": {}
4
4
+
}
5
5
+
}
+10
-5
angular.json
···
13
13
"build": {
14
14
"builder": "@angular-devkit/build-angular:application",
15
15
"options": {
16
16
-
"outputPath": "dist/consolesky",
16
16
+
"outputPath": {
17
17
+
"base": "docs",
18
18
+
"browser": ""
19
19
+
},
17
20
"index": "src/index.html",
18
21
"browser": "src/main.ts",
19
22
"polyfills": [
···
27
30
}
28
31
],
29
32
"styles": [
30
30
-
"src/styles.css"
33
33
+
"src/styles.css",
34
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
39
-
"maximumWarning": "500kB",
40
40
-
"maximumError": "1MB"
43
43
+
"maximumWarning": "2MB",
44
44
+
"maximumError": "5MB"
41
45
},
42
46
{
43
47
"type": "anyComponentStyle",
···
85
89
}
86
90
],
87
91
"styles": [
88
88
-
"src/styles.css"
92
92
+
"src/styles.css",
93
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
1
+
const {app, BrowserWindow} = require('electron')
2
2
+
const url = require("url");
3
3
+
const path = require("path");
4
4
+
5
5
+
let mainWindow
6
6
+
7
7
+
function createWindow () {
8
8
+
mainWindow = new BrowserWindow({
9
9
+
width: 800,
10
10
+
height: 600,
11
11
+
webPreferences: {
12
12
+
nodeIntegration: true
13
13
+
}
14
14
+
})
15
15
+
16
16
+
mainWindow.loadURL(
17
17
+
url.format({
18
18
+
pathname: path.join(__dirname, `docs/index.html`),
19
19
+
protocol: "file:",
20
20
+
slashes: true
21
21
+
})
22
22
+
);
23
23
+
// Open the DevTools.
24
24
+
mainWindow.webContents.openDevTools()
25
25
+
26
26
+
mainWindow.on('closed', function () {
27
27
+
mainWindow = null
28
28
+
})
29
29
+
}
30
30
+
31
31
+
app.on('ready', createWindow)
32
32
+
33
33
+
app.on('window-all-closed', function () {
34
34
+
if (process.platform !== 'darwin') app.quit()
35
35
+
})
36
36
+
37
37
+
app.on('activate', function () {
38
38
+
if (mainWindow === null) createWindow()
39
39
+
})
+16
-2
package.json
···
1
1
{
2
2
"name": "consolesky",
3
3
"version": "0.0.0",
4
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
9
-
"test": "ng test"
10
10
+
"test": "ng test",
11
11
+
"electron": "ng build --base-href ./ && electron ."
10
12
},
11
13
"private": true,
12
14
"dependencies": {
15
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
23
+
"@angular/youtube-player": "^19.2.8",
24
24
+
"@atproto/api": "^0.14.16",
25
25
+
"@tailwindcss/postcss": "^4.0.17",
26
26
+
"angular-mentions": "^1.5.0",
27
27
+
"date-fns": "^4.1.0",
28
28
+
"ngx-image-compress": "^18.1.5",
29
29
+
"postcss": "^8.5.3",
20
30
"rxjs": "~7.8.0",
31
31
+
"tailwindcss": "^4.0.17",
21
32
"tslib": "^2.3.0",
33
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
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
35
-
"typescript": "~5.7.2"
48
48
+
"typescript": "~5.7.2",
49
49
+
"video.js": "^8.22.0"
36
50
}
37
51
}
+3
-336
src/app/app.component.html
···
1
1
-
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
2
2
-
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
3
3
-
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
4
4
-
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
5
5
-
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
6
6
-
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
7
7
-
<!-- * * * * * * * to get started with your project! * * * * * * * -->
8
8
-
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
9
9
-
10
10
-
<style>
11
11
-
:host {
12
12
-
--bright-blue: oklch(51.01% 0.274 263.83);
13
13
-
--electric-violet: oklch(53.18% 0.28 296.97);
14
14
-
--french-violet: oklch(47.66% 0.246 305.88);
15
15
-
--vivid-pink: oklch(69.02% 0.277 332.77);
16
16
-
--hot-red: oklch(61.42% 0.238 15.34);
17
17
-
--orange-red: oklch(63.32% 0.24 31.68);
18
18
-
19
19
-
--gray-900: oklch(19.37% 0.006 300.98);
20
20
-
--gray-700: oklch(36.98% 0.014 302.71);
21
21
-
--gray-400: oklch(70.9% 0.015 304.04);
22
22
-
23
23
-
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
24
24
-
180deg,
25
25
-
var(--orange-red) 0%,
26
26
-
var(--vivid-pink) 50%,
27
27
-
var(--electric-violet) 100%
28
28
-
);
29
29
-
30
30
-
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
31
31
-
90deg,
32
32
-
var(--orange-red) 0%,
33
33
-
var(--vivid-pink) 50%,
34
34
-
var(--electric-violet) 100%
35
35
-
);
36
36
-
37
37
-
--pill-accent: var(--bright-blue);
38
38
-
39
39
-
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
40
40
-
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
41
41
-
"Segoe UI Symbol";
42
42
-
box-sizing: border-box;
43
43
-
-webkit-font-smoothing: antialiased;
44
44
-
-moz-osx-font-smoothing: grayscale;
45
45
-
}
46
46
-
47
47
-
h1 {
48
48
-
font-size: 3.125rem;
49
49
-
color: var(--gray-900);
50
50
-
font-weight: 500;
51
51
-
line-height: 100%;
52
52
-
letter-spacing: -0.125rem;
53
53
-
margin: 0;
54
54
-
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
55
55
-
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
56
56
-
"Segoe UI Symbol";
57
57
-
}
58
58
-
59
59
-
p {
60
60
-
margin: 0;
61
61
-
color: var(--gray-700);
62
62
-
}
63
63
-
64
64
-
main {
65
65
-
width: 100%;
66
66
-
min-height: 100%;
67
67
-
display: flex;
68
68
-
justify-content: center;
69
69
-
align-items: center;
70
70
-
padding: 1rem;
71
71
-
box-sizing: inherit;
72
72
-
position: relative;
73
73
-
}
74
74
-
75
75
-
.angular-logo {
76
76
-
max-width: 9.2rem;
77
77
-
}
78
78
-
79
79
-
.content {
80
80
-
display: flex;
81
81
-
justify-content: space-around;
82
82
-
width: 100%;
83
83
-
max-width: 700px;
84
84
-
margin-bottom: 3rem;
85
85
-
}
86
86
-
87
87
-
.content h1 {
88
88
-
margin-top: 1.75rem;
89
89
-
}
90
90
-
91
91
-
.content p {
92
92
-
margin-top: 1.5rem;
93
93
-
}
94
94
-
95
95
-
.divider {
96
96
-
width: 1px;
97
97
-
background: var(--red-to-pink-to-purple-vertical-gradient);
98
98
-
margin-inline: 0.5rem;
99
99
-
}
100
100
-
101
101
-
.pill-group {
102
102
-
display: flex;
103
103
-
flex-direction: column;
104
104
-
align-items: start;
105
105
-
flex-wrap: wrap;
106
106
-
gap: 1.25rem;
107
107
-
}
108
108
-
109
109
-
.pill {
110
110
-
display: flex;
111
111
-
align-items: center;
112
112
-
--pill-accent: var(--bright-blue);
113
113
-
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
114
114
-
color: var(--pill-accent);
115
115
-
padding-inline: 0.75rem;
116
116
-
padding-block: 0.375rem;
117
117
-
border-radius: 2.75rem;
118
118
-
border: 0;
119
119
-
transition: background 0.3s ease;
120
120
-
font-family: var(--inter-font);
121
121
-
font-size: 0.875rem;
122
122
-
font-style: normal;
123
123
-
font-weight: 500;
124
124
-
line-height: 1.4rem;
125
125
-
letter-spacing: -0.00875rem;
126
126
-
text-decoration: none;
127
127
-
}
128
128
-
129
129
-
.pill:hover {
130
130
-
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
131
131
-
}
132
132
-
133
133
-
.pill-group .pill:nth-child(6n + 1) {
134
134
-
--pill-accent: var(--bright-blue);
135
135
-
}
136
136
-
.pill-group .pill:nth-child(6n + 2) {
137
137
-
--pill-accent: var(--french-violet);
138
138
-
}
139
139
-
.pill-group .pill:nth-child(6n + 3),
140
140
-
.pill-group .pill:nth-child(6n + 4),
141
141
-
.pill-group .pill:nth-child(6n + 5) {
142
142
-
--pill-accent: var(--hot-red);
143
143
-
}
144
144
-
145
145
-
.pill-group svg {
146
146
-
margin-inline-start: 0.25rem;
147
147
-
}
148
148
-
149
149
-
.social-links {
150
150
-
display: flex;
151
151
-
align-items: center;
152
152
-
gap: 0.73rem;
153
153
-
margin-top: 1.5rem;
154
154
-
}
155
155
-
156
156
-
.social-links path {
157
157
-
transition: fill 0.3s ease;
158
158
-
fill: var(--gray-400);
159
159
-
}
160
160
-
161
161
-
.social-links a:hover svg path {
162
162
-
fill: var(--gray-900);
163
163
-
}
164
164
-
165
165
-
@media screen and (max-width: 650px) {
166
166
-
.content {
167
167
-
flex-direction: column;
168
168
-
width: max-content;
169
169
-
}
170
170
-
171
171
-
.divider {
172
172
-
height: 1px;
173
173
-
width: 100%;
174
174
-
background: var(--red-to-pink-to-purple-horizontal-gradient);
175
175
-
margin-block: 1.5rem;
176
176
-
}
177
177
-
}
178
178
-
</style>
179
179
-
180
180
-
<main class="main">
181
181
-
<div class="content">
182
182
-
<div class="left-side">
183
183
-
<svg
184
184
-
xmlns="http://www.w3.org/2000/svg"
185
185
-
viewBox="0 0 982 239"
186
186
-
fill="none"
187
187
-
class="angular-logo"
188
188
-
>
189
189
-
<g clip-path="url(#a)">
190
190
-
<path
191
191
-
fill="url(#b)"
192
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
193
-
/>
194
194
-
<path
195
195
-
fill="url(#c)"
196
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
197
-
/>
198
198
-
</g>
199
199
-
<defs>
200
200
-
<radialGradient
201
201
-
id="c"
202
202
-
cx="0"
203
203
-
cy="0"
204
204
-
r="1"
205
205
-
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
206
206
-
gradientUnits="userSpaceOnUse"
207
207
-
>
208
208
-
<stop stop-color="#FF41F8" />
209
209
-
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
210
210
-
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
211
211
-
</radialGradient>
212
212
-
<linearGradient
213
213
-
id="b"
214
214
-
x1="0"
215
215
-
x2="982"
216
216
-
y1="192"
217
217
-
y2="192"
218
218
-
gradientUnits="userSpaceOnUse"
219
219
-
>
220
220
-
<stop stop-color="#F0060B" />
221
221
-
<stop offset="0" stop-color="#F0070C" />
222
222
-
<stop offset=".526" stop-color="#CC26D5" />
223
223
-
<stop offset="1" stop-color="#7702FF" />
224
224
-
</linearGradient>
225
225
-
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
226
226
-
</defs>
227
227
-
</svg>
228
228
-
<h1>Hello, {{ title }}</h1>
229
229
-
<p>Congratulations! Your app is running. 🎉</p>
230
230
-
</div>
231
231
-
<div class="divider" role="separator" aria-label="Divider"></div>
232
232
-
<div class="right-side">
233
233
-
<div class="pill-group">
234
234
-
@for (item of [
235
235
-
{ title: 'Explore the Docs', link: 'https://angular.dev' },
236
236
-
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
237
237
-
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
238
238
-
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
239
239
-
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
240
240
-
]; track item.title) {
241
241
-
<a
242
242
-
class="pill"
243
243
-
[href]="item.link"
244
244
-
target="_blank"
245
245
-
rel="noopener"
246
246
-
>
247
247
-
<span>{{ item.title }}</span>
248
248
-
<svg
249
249
-
xmlns="http://www.w3.org/2000/svg"
250
250
-
height="14"
251
251
-
viewBox="0 -960 960 960"
252
252
-
width="14"
253
253
-
fill="currentColor"
254
254
-
>
255
255
-
<path
256
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
257
-
/>
258
258
-
</svg>
259
259
-
</a>
260
260
-
}
261
261
-
</div>
262
262
-
<div class="social-links">
263
263
-
<a
264
264
-
href="https://github.com/angular/angular"
265
265
-
aria-label="Github"
266
266
-
target="_blank"
267
267
-
rel="noopener"
268
268
-
>
269
269
-
<svg
270
270
-
width="25"
271
271
-
height="24"
272
272
-
viewBox="0 0 25 24"
273
273
-
fill="none"
274
274
-
xmlns="http://www.w3.org/2000/svg"
275
275
-
alt="Github"
276
276
-
>
277
277
-
<path
278
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
279
-
/>
280
280
-
</svg>
281
281
-
</a>
282
282
-
<a
283
283
-
href="https://twitter.com/angular"
284
284
-
aria-label="Twitter"
285
285
-
target="_blank"
286
286
-
rel="noopener"
287
287
-
>
288
288
-
<svg
289
289
-
width="24"
290
290
-
height="24"
291
291
-
viewBox="0 0 24 24"
292
292
-
fill="none"
293
293
-
xmlns="http://www.w3.org/2000/svg"
294
294
-
alt="Twitter"
295
295
-
>
296
296
-
<path
297
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
298
-
/>
299
299
-
</svg>
300
300
-
</a>
301
301
-
<a
302
302
-
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
303
303
-
aria-label="Youtube"
304
304
-
target="_blank"
305
305
-
rel="noopener"
306
306
-
>
307
307
-
<svg
308
308
-
width="29"
309
309
-
height="20"
310
310
-
viewBox="0 0 29 20"
311
311
-
fill="none"
312
312
-
xmlns="http://www.w3.org/2000/svg"
313
313
-
alt="Youtube"
314
314
-
>
315
315
-
<path
316
316
-
fill-rule="evenodd"
317
317
-
clip-rule="evenodd"
318
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
319
-
/>
320
320
-
</svg>
321
321
-
</a>
322
322
-
</div>
323
323
-
</div>
324
324
-
</div>
325
325
-
</main>
326
326
-
327
327
-
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
328
328
-
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
329
329
-
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
330
330
-
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
331
331
-
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
332
332
-
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
333
333
-
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
334
334
-
335
335
-
336
336
-
<router-outlet />
1
1
+
<div class="app-container">
2
2
+
<router-outlet></router-outlet>
3
3
+
</div>
+2
-2
src/app/app.component.spec.ts
···
1
1
-
import { TestBed } from '@angular/core/testing';
2
2
-
import { AppComponent } from './app.component';
1
1
+
import {TestBed} from '@angular/core/testing';
2
2
+
import {AppComponent} from './app.component';
3
3
4
4
describe('AppComponent', () => {
5
5
beforeEach(async () => {
+20
-3
src/app/app.component.ts
···
1
1
-
import { Component } from '@angular/core';
2
2
-
import { RouterOutlet } from '@angular/router';
1
1
+
import {Component} from '@angular/core';
2
2
+
import {Router, RouterOutlet} from '@angular/router';
3
3
+
import {AuthService} from '@core/auth/auth.service';
4
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
11
-
title = 'consolesky';
13
13
+
constructor(
14
14
+
private authService: AuthService,
15
15
+
private router: Router
16
16
+
) {
17
17
+
this.initApp();
18
18
+
}
19
19
+
20
20
+
initApp() {
21
21
+
this.authService.authenticationState.pipe(takeWhile(res => !res, true)).subscribe(state => {
22
22
+
if (state) {
23
23
+
this.router.navigate(['']);
24
24
+
} else {
25
25
+
this.router.navigate(['login']);
26
26
+
}
27
27
+
});
28
28
+
}
12
29
}
+9
-4
src/app/app.config.ts
···
1
1
-
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
2
2
-
import { provideRouter } from '@angular/router';
1
1
+
import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core';
2
2
+
import {provideRouter} from '@angular/router';
3
3
4
4
-
import { routes } from './app.routes';
4
4
+
import {routes} from './app.routes';
5
5
+
import {provideHttpClient} from '@angular/common/http';
5
6
6
7
export const appConfig: ApplicationConfig = {
7
7
-
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)]
8
8
+
providers: [
9
9
+
provideZoneChangeDetection({ eventCoalescing: true }),
10
10
+
provideRouter(routes),
11
11
+
provideHttpClient(),
12
12
+
]
8
13
};
+16
-2
src/app/app.routes.ts
···
1
1
-
import { Routes } from '@angular/router';
1
1
+
import {Routes} from '@angular/router';
2
2
+
import {AuthGuard} from '@core/auth/auth.guard';
3
3
+
import {DashboardComponent} from '@views/dashboard/dashboard.component';
4
4
+
import {LoginComponent} from '@views/login/login.component';
2
5
3
3
-
export const routes: Routes = [];
6
6
+
export const routes: Routes = [
7
7
+
{
8
8
+
path: '',
9
9
+
canActivate: [AuthGuard],
10
10
+
component: DashboardComponent,
11
11
+
pathMatch: 'full'
12
12
+
},
13
13
+
{
14
14
+
path: 'login',
15
15
+
component: LoginComponent
16
16
+
}
17
17
+
];
+220
src/app/components/cards/notification-card/notification-card.component.html
···
1
1
+
<div
2
2
+
class="flex px-2 py-3 gap-2"
3
3
+
(click)="onClick.emit(notification())"
4
4
+
>
5
5
+
@if (
6
6
+
(notification() | isLikeNotification) ||
7
7
+
(notification() | isFollowNotification) ||
8
8
+
(notification() | isRepostNotification) ||
9
9
+
(notification() | isStarterPackNotification)
10
10
+
) {
11
11
+
<ng-container
12
12
+
[ngTemplateOutlet]="template"
13
13
+
/>
14
14
+
}
15
15
+
</div>
16
16
+
17
17
+
<ng-template
18
18
+
#template
19
19
+
>
20
20
+
<div
21
21
+
class="w-12 flex justify-center shrink-0"
22
22
+
>
23
23
+
@if (notification() | isLikeNotification) {
24
24
+
<span
25
25
+
class="material-icons-outlined !text-4xl !flex items-center justify-center w-8 h-8"
26
26
+
>favorite_border</span>
27
27
+
}
28
28
+
@else if (notification() | isFollowNotification) {
29
29
+
<span
30
30
+
class="material-icons-outlined !text-4xl !flex items-center justify-center w-8 h-8"
31
31
+
>person_add_alt</span>
32
32
+
}
33
33
+
@else if (notification() | isRepostNotification) {
34
34
+
<span
35
35
+
class="material-icons-outlined !text-4xl !flex items-center justify-center w-8 h-8"
36
36
+
>repeat</span>
37
37
+
}
38
38
+
@else {
39
39
+
starterpack
40
40
+
}
41
41
+
</div>
42
42
+
43
43
+
<div
44
44
+
class="flex flex-col min-w-0 grow gap-2"
45
45
+
>
46
46
+
<ng-container
47
47
+
[ngTemplateOutlet]="authors"
48
48
+
/>
49
49
+
50
50
+
<ng-container
51
51
+
[ngTemplateOutlet]="label"
52
52
+
/>
53
53
+
54
54
+
@if (
55
55
+
notification().reason == 'like' ||
56
56
+
notification().reason == 'repost'
57
57
+
) {
58
58
+
<ng-container
59
59
+
[ngTemplateOutlet]="postPreview"
60
60
+
[ngTemplateOutletContext]="{attachedPost: post ? post() : undefined}"
61
61
+
/>
62
62
+
}
63
63
+
</div>
64
64
+
</ng-template>
65
65
+
66
66
+
<ng-template
67
67
+
#authors
68
68
+
>
69
69
+
<div
70
70
+
class="flex gap-2"
71
71
+
>
72
72
+
@for (author of notification().authors | slice : 0: 5; track author.did) {
73
73
+
<a
74
74
+
(click)="openAuthor($event, author.did)"
75
75
+
class="h-8 w-8 relative cursor-pointer"
76
76
+
>
77
77
+
<avatar
78
78
+
[src]="author.avatar"
79
79
+
/>
80
80
+
</a>
81
81
+
}
82
82
+
@if (notification().authors.length > 5) {
83
83
+
<span
84
84
+
class="h-8 w-8 flex items-center justify-center"
85
85
+
>
86
86
+
+{{notification().authors.length - 5}}
87
87
+
</span>
88
88
+
}
89
89
+
</div>
90
90
+
</ng-template>
91
91
+
92
92
+
<ng-template
93
93
+
#label
94
94
+
>
95
95
+
<span
96
96
+
>
97
97
+
@switch (notification().authors.length) {
98
98
+
@case (1) {
99
99
+
<a
100
100
+
(click)="openAuthor($event, notification().authors[0].did)"
101
101
+
class="font-bold hover:underline cursor-pointer"
102
102
+
>{{notification().authors[0] | displayName}}</a>
103
103
+
}
104
104
+
@case (2) {
105
105
+
<a
106
106
+
(click)="openAuthor($event, notification().authors[0].did)"
107
107
+
class="font-bold hover:underline cursor-pointer"
108
108
+
>{{notification().authors[0] | displayName}}</a>
109
109
+
110
110
+
and
111
111
+
112
112
+
<a
113
113
+
(click)="openAuthor($event, notification().authors[1].did)"
114
114
+
class="font-bold hover:underline cursor-pointer"
115
115
+
>{{notification().authors[1] | displayName}}</a>
116
116
+
}
117
117
+
@default {
118
118
+
<a
119
119
+
(click)="openAuthor($event, notification().authors[0].did)"
120
120
+
class="font-bold hover:underline cursor-pointer"
121
121
+
>{{notification().authors[0] | displayName}}</a>
122
122
+
123
123
+
and
124
124
+
125
125
+
<a
126
126
+
class="font-bold hover:underline cursor-pointer"
127
127
+
[href]=""
128
128
+
>{{notification().authors.length - 1}} more</a>
129
129
+
}
130
130
+
}
131
131
+
132
132
+
@if (notification() | isLikeNotification) {
133
133
+
liked your post
134
134
+
}
135
135
+
@else if (notification() | isFollowNotification) {
136
136
+
followed you
137
137
+
}
138
138
+
@else if (notification() | isRepostNotification) {
139
139
+
reposted your post
140
140
+
}
141
141
+
@else {
142
142
+
added you to a starter pack
143
143
+
}
144
144
+
</span>
145
145
+
</ng-template>
146
146
+
147
147
+
<ng-template
148
148
+
#postPreview
149
149
+
let-attachedPost="attachedPost"
150
150
+
>
151
151
+
<div
152
152
+
class="flex"
153
153
+
>
154
154
+
<div
155
155
+
class="overflow-hidden shrink-0 h-5 w-9 flex items-center justify-center"
156
156
+
>
157
157
+
<span class="material-icons !text-[2.25em]">format_quote</span>
158
158
+
</div>
159
159
+
160
160
+
<div
161
161
+
class="flex flex-col flex-1 min-w-0 gap-2 text-primary/50"
162
162
+
>
163
163
+
@if (attachedPost.record?.text?.length) {
164
164
+
<span
165
165
+
class="text-primary/50 text-sm"
166
166
+
>
167
167
+
{{attachedPost.record?.text}}
168
168
+
</span>
169
169
+
}
170
170
+
171
171
+
@if (attachedPost.embed) {
172
172
+
@if (attachedPost.embed | isEmbedImagesView) {
173
173
+
<div
174
174
+
class="flex gap-2"
175
175
+
>
176
176
+
@for (image of attachedPost.embed.images; track image.thumb) {
177
177
+
<img
178
178
+
[src]="image.thumb"
179
179
+
[alt]="image.alt"
180
180
+
class="h-16 w-16"
181
181
+
/>
182
182
+
}
183
183
+
</div>
184
184
+
}
185
185
+
186
186
+
@if (attachedPost.embed | isEmbedVideoView) {
187
187
+
<img
188
188
+
[src]="attachedPost.embed.thumbnail"
189
189
+
[alt]="attachedPost.embed.alt"
190
190
+
class="h-16 w-16"
191
191
+
/>
192
192
+
}
193
193
+
194
194
+
@if (attachedPost.embed | isEmbedRecordWithMediaView) {
195
195
+
@if (attachedPost.embed.media | isEmbedImagesView) {
196
196
+
<div
197
197
+
class="flex gap-2"
198
198
+
>
199
199
+
@for (image of attachedPost.embed.media.images; track image.thumb) {
200
200
+
<img
201
201
+
[src]="image.thumb"
202
202
+
[alt]="image.alt"
203
203
+
class="h-16 w-16"
204
204
+
/>
205
205
+
}
206
206
+
</div>
207
207
+
}
208
208
+
209
209
+
@if (attachedPost.embed.media | isEmbedVideoView) {
210
210
+
<img
211
211
+
[src]="attachedPost.embed.media.thumbnail"
212
212
+
[alt]="attachedPost.embed.media.alt"
213
213
+
class="h-16 w-16"
214
214
+
/>
215
215
+
}
216
216
+
}
217
217
+
}
218
218
+
</div>
219
219
+
</div>
220
220
+
</ng-template>
+58
src/app/components/cards/notification-card/notification-card.component.ts
···
1
1
+
import {
2
2
+
ChangeDetectionStrategy,
3
3
+
ChangeDetectorRef,
4
4
+
Component,
5
5
+
input,
6
6
+
OnInit,
7
7
+
output,
8
8
+
WritableSignal
9
9
+
} from '@angular/core';
10
10
+
import {Notification} from '@models/notification';
11
11
+
import {AvatarComponent} from '@components/shared/avatar/avatar.component';
12
12
+
import {IsLikeNotificationPipe} from '@shared/pipes/type-guards/notifications/is-like-notification.pipe';
13
13
+
import {IsFollowNotificationPipe} from '@shared/pipes/type-guards/notifications/is-follow-notification.pipe';
14
14
+
import {IsRepostNotificationPipe} from '@shared/pipes/type-guards/notifications/is-repost-notification.pipe';
15
15
+
import {IsStarterPackNotificationPipe} from '@shared/pipes/type-guards/notifications/is-starterpack-notification.pipe';
16
16
+
import {NgTemplateOutlet, SlicePipe} from '@angular/common';
17
17
+
import {DisplayNamePipe} from '@shared/pipes/display-name.pipe';
18
18
+
import {AppBskyFeedDefs} from '@atproto/api';
19
19
+
import {IsEmbedImagesViewPipe} from '@shared/pipes/type-guards/is-embed-images-view.pipe';
20
20
+
import {IsEmbedVideoViewPipe} from '@shared/pipes/type-guards/is-embed-video-view.pipe';
21
21
+
import {IsEmbedRecordWithMediaViewPipe} from '@shared/pipes/type-guards/is-embed-recordwithmedia-view.pipe';
22
22
+
23
23
+
@Component({
24
24
+
selector: 'notification-card',
25
25
+
imports: [
26
26
+
AvatarComponent,
27
27
+
IsLikeNotificationPipe,
28
28
+
IsFollowNotificationPipe,
29
29
+
IsRepostNotificationPipe,
30
30
+
IsStarterPackNotificationPipe,
31
31
+
NgTemplateOutlet,
32
32
+
SlicePipe,
33
33
+
DisplayNamePipe,
34
34
+
IsEmbedImagesViewPipe,
35
35
+
IsEmbedVideoViewPipe,
36
36
+
IsEmbedRecordWithMediaViewPipe
37
37
+
],
38
38
+
templateUrl: './notification-card.component.html',
39
39
+
changeDetection: ChangeDetectionStrategy.OnPush
40
40
+
})
41
41
+
export class NotificationCardComponent implements OnInit {
42
42
+
notification = input<Notification>();
43
43
+
onClick = output<Notification>();
44
44
+
post: WritableSignal<AppBskyFeedDefs.PostView>;
45
45
+
46
46
+
constructor(
47
47
+
private cdRef: ChangeDetectorRef
48
48
+
) {}
49
49
+
50
50
+
ngOnInit() {
51
51
+
this.post = this.notification().post;
52
52
+
this.cdRef.markForCheck();
53
53
+
}
54
54
+
55
55
+
openAuthor(event: Event, did: string) {
56
56
+
//TODO: OpenAuthor
57
57
+
}
58
58
+
}
+296
src/app/components/cards/post-card/post-card.component.html
···
1
1
+
<div
2
2
+
class="flex px-2 py-3 gap-2"
3
3
+
>
4
4
+
<avatar
5
5
+
[src]="post().author.avatar"
6
6
+
class="h-12 w-12 shrink-0"
7
7
+
/>
8
8
+
<div
9
9
+
class="flex flex-col w-full min-w-0"
10
10
+
>
11
11
+
12
12
+
<ng-container
13
13
+
[ngTemplateOutlet]="header"
14
14
+
[ngTemplateOutletContext]="{author: post().author, reply: reply(), record: post().record, reason: reason()}"
15
15
+
/>
16
16
+
17
17
+
<ng-container
18
18
+
[ngTemplateOutlet]="subheader"
19
19
+
[ngTemplateOutletContext]="{reply: reply(), reason: reason()}"
20
20
+
/>
21
21
+
22
22
+
<ng-container
23
23
+
[ngTemplateOutlet]="record"
24
24
+
[ngTemplateOutletContext]="{record: post().record}"
25
25
+
/>
26
26
+
27
27
+
<ng-container
28
28
+
[ngTemplateOutlet]="embed"
29
29
+
[ngTemplateOutletContext]="{embed: post().embed}"
30
30
+
/>
31
31
+
32
32
+
<ng-container
33
33
+
[ngTemplateOutlet]="info"
34
34
+
/>
35
35
+
</div>
36
36
+
</div>
37
37
+
38
38
+
<ng-template
39
39
+
#header
40
40
+
let-author="author"
41
41
+
let-reply="reply"
42
42
+
let-record="record"
43
43
+
let-reason="reason"
44
44
+
>
45
45
+
<div
46
46
+
class="flex mt-[0.15rem] w-full min-w-0"
47
47
+
>
48
48
+
<span
49
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
50
+
>{{author | displayName}}</span>
51
51
+
52
52
+
@if (reason | isFeedDefsReasonRepost) {
53
53
+
<div
54
54
+
class="h-0"
55
55
+
>
56
56
+
<span
57
57
+
class="material-icons-outlined -translate-y-1 mr-1 text-repost"
58
58
+
>repeat</span>
59
59
+
</div>
60
60
+
61
61
+
<span
62
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
63
+
>{{reason.by | displayName}}</span>
64
64
+
}
65
65
+
66
66
+
<!-- @if (author.displayName?.length) {-->
67
67
+
<!-- <span-->
68
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
69
+
<!-- >{{'@' + author.handle}}</span>-->
70
70
+
<!-- }-->
71
71
+
72
72
+
@if (record | isFeedPostRecord) {
73
73
+
<a
74
74
+
[href]="post().uri | linkExtractor: author.handle"
75
75
+
target="_blank"
76
76
+
class="text-sm text-primary/50 hover:underline [text-box:trim-both_cap_alphabetic] shrink-0 ml-auto pl-3"
77
77
+
>{{record.createdAt | dateFormatter}}</a>
78
78
+
}
79
79
+
</div>
80
80
+
</ng-template>
81
81
+
82
82
+
<ng-template
83
83
+
#subheader
84
84
+
let-reason="reason"
85
85
+
let-reply="reply"
86
86
+
>
87
87
+
@if (reason || reply) {
88
88
+
<div
89
89
+
class="flex flex-col w-full"
90
90
+
>
91
91
+
@if (reply) {
92
92
+
<div
93
93
+
class="flex w-full min-w-0 mt-2.5"
94
94
+
>
95
95
+
<div
96
96
+
class="h-0"
97
97
+
>
98
98
+
<span
99
99
+
class="material-icons-outlined -translate-y-1.5 mr-1"
100
100
+
>subdirectory_arrow_right</span>
101
101
+
</div>
102
102
+
103
103
+
@if (reply.parent | isFeedDefsPostView) {
104
104
+
<span
105
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
106
+
>{{reply.parent.author | displayName}}</span>
107
107
+
}
108
108
+
</div>
109
109
+
}
110
110
+
</div>
111
111
+
}
112
112
+
</ng-template>
113
113
+
114
114
+
<ng-template
115
115
+
#record
116
116
+
let-record="record"
117
117
+
>
118
118
+
@if ((record | isFeedPostRecord) && record.text?.length) {
119
119
+
<rich-text
120
120
+
[text]="record.text"
121
121
+
[facets]="record.facets"
122
122
+
class="mt-2 text-sm"
123
123
+
/>
124
124
+
}
125
125
+
</ng-template>
126
126
+
127
127
+
<ng-template
128
128
+
#embed
129
129
+
let-embed="embed"
130
130
+
>
131
131
+
@if (embed | isEmbedRecordView) {
132
132
+
<record-embed
133
133
+
[record]="embed.record"
134
134
+
class="mt-2 p-2 hover:bg-primary/2"
135
135
+
/>
136
136
+
}
137
137
+
138
138
+
@if (embed | isEmbedImagesView) {
139
139
+
<images-embed
140
140
+
[images]="embed.images"
141
141
+
class="mb-1"
142
142
+
[class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'"
143
143
+
/>
144
144
+
}
145
145
+
146
146
+
@if (embed | isEmbedVideoView) {
147
147
+
<video-embed
148
148
+
[embed]="embed"
149
149
+
[class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'"
150
150
+
/>
151
151
+
}
152
152
+
153
153
+
@if (embed | isEmbedExternalView) {
154
154
+
<external-embed
155
155
+
[external]="embed.external"
156
156
+
[class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'"
157
157
+
/>
158
158
+
}
159
159
+
160
160
+
@if (embed | isEmbedRecordWithMediaView) {
161
161
+
@if (embed.media | isEmbedImagesView) {
162
162
+
<images-embed
163
163
+
[images]="embed.media.images"
164
164
+
class="mb-1"
165
165
+
[class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'"
166
166
+
/>
167
167
+
}
168
168
+
169
169
+
@if (embed.media | isEmbedVideoView) {
170
170
+
<video-embed
171
171
+
[embed]="embed.media"
172
172
+
[class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'"
173
173
+
/>
174
174
+
}
175
175
+
176
176
+
@if (embed.media | isEmbedExternalView) {
177
177
+
<external-embed
178
178
+
[external]="embed.media.external"
179
179
+
[class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'"
180
180
+
/>
181
181
+
}
182
182
+
183
183
+
<record-embed
184
184
+
[record]="embed.record.record"
185
185
+
class="mt-2 p-2 hover:bg-primary/2"
186
186
+
/>
187
187
+
}
188
188
+
</ng-template>
189
189
+
190
190
+
<ng-template
191
191
+
#info
192
192
+
>
193
193
+
<div
194
194
+
class="flex mt-1 gap-4"
195
195
+
>
196
196
+
<div
197
197
+
class="w-16"
198
198
+
>
199
199
+
<button
200
200
+
class="flex w-fit h-7 p-2 items-center gap-1 hover:bg-primary/3 cursor-pointer"
201
201
+
(click)="replyAction($event)"
202
202
+
>
203
203
+
<span
204
204
+
class="material-icons-outlined !text-[14px]"
205
205
+
>mode_comment</span>
206
206
+
207
207
+
@if (post().replyCount) {
208
208
+
<span
209
209
+
class="[text-box:trim-both_cap_alphabetic]"
210
210
+
>{{post().replyCount | numberFormatter}}</span>
211
211
+
}
212
212
+
</button>
213
213
+
</div>
214
214
+
215
215
+
<div
216
216
+
class="w-16"
217
217
+
>
218
218
+
<button
219
219
+
cdkOverlayOrigin
220
220
+
#trigger="cdkOverlayOrigin"
221
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
222
+
[ngClass]="{'bg-primary/3 !border-primary' : rtMenuVisible}"
223
223
+
(click)="!processingAction ? rtMenuVisible = !rtMenuVisible : undefined"
224
224
+
>
225
225
+
<span
226
226
+
class="material-icons-outlined !text-[17px]"
227
227
+
[class]="post().viewer.repost ? 'text-repost' : undefined"
228
228
+
>repeat</span>
229
229
+
230
230
+
@if (post().repostCount) {
231
231
+
<span
232
232
+
class="[text-box:trim-both_cap_alphabetic]"
233
233
+
>{{post().repostCount | numberFormatter}}</span>
234
234
+
}
235
235
+
</button>
236
236
+
237
237
+
<ng-template
238
238
+
cdkConnectedOverlay
239
239
+
[cdkConnectedOverlayOrigin]="trigger"
240
240
+
[cdkConnectedOverlayOpen]="rtMenuVisible"
241
241
+
(detach)="rtMenuVisible = false"
242
242
+
(overlayOutsideClick)="rtMenuVisible = !rtMenuVisible"
243
243
+
>
244
244
+
<ul role="listbox" class="border border-primary">
245
245
+
<li>
246
246
+
<button
247
247
+
class="btn-dropdown"
248
248
+
(click)="repostAction($event)"
249
249
+
>
250
250
+
{{post().viewer.repost ? 'Undo Repost' : 'Repost'}}
251
251
+
</button>
252
252
+
</li>
253
253
+
254
254
+
@if (post().viewer.repost) {
255
255
+
<li>
256
256
+
<button
257
257
+
class="btn-dropdown"
258
258
+
(click)="refreshRepostAction($event)"
259
259
+
>
260
260
+
Repost again
261
261
+
</button>
262
262
+
</li>
263
263
+
}
264
264
+
265
265
+
<li>
266
266
+
<button
267
267
+
class="btn-dropdown"
268
268
+
>
269
269
+
Quote post
270
270
+
</button>
271
271
+
</li>
272
272
+
</ul>
273
273
+
</ng-template>
274
274
+
</div>
275
275
+
276
276
+
<div
277
277
+
class="w-16"
278
278
+
>
279
279
+
<button
280
280
+
class="flex w-fit h-7 p-2 items-center gap-1 hover:bg-primary/3 cursor-pointer"
281
281
+
(click)="likeAction($event)"
282
282
+
>
283
283
+
<span
284
284
+
class="material-icons-outlined transition"
285
285
+
[class]="post().viewer.like ? 'text-like' : undefined"
286
286
+
>{{post().viewer.like ? 'favorite' : 'favorite_border'}}</span>
287
287
+
288
288
+
@if (post().likeCount) {
289
289
+
<span
290
290
+
class="[text-box:trim-both_cap_alphabetic]"
291
291
+
>{{post().likeCount | numberFormatter}}</span>
292
292
+
}
293
293
+
</button>
294
294
+
</div>
295
295
+
</div>
296
296
+
</ng-template>
+140
src/app/components/cards/post-card/post-card.component.ts
···
1
1
+
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, input, model, OnDestroy, OnInit} from '@angular/core';
2
2
+
import {AppBskyFeedDefs} from '@atproto/api';
3
3
+
import {AvatarComponent} from '@components/shared/avatar/avatar.component';
4
4
+
import {DisplayNamePipe} from '@shared/pipes/display-name.pipe';
5
5
+
import {IsFeedPostRecordPipe} from '@shared/pipes/type-guards/is-feed-post-record';
6
6
+
import {RichTextComponent} from '@components/shared/rich-text/rich-text.component';
7
7
+
import {NgClass, NgTemplateOutlet} from '@angular/common';
8
8
+
import {IsFeedDefsPostViewPipe} from '@shared/pipes/type-guards/is-feed-defs-postview';
9
9
+
import {DateFormatterPipe} from '@shared/pipes/date-formatter.pipe';
10
10
+
import {IsEmbedRecordViewPipe} from '@shared/pipes/type-guards/is-embed-record-view.pipe';
11
11
+
import {RecordEmbedComponent} from '@components/embeds/record-embed/record-embed.component';
12
12
+
import {IsFeedDefsReasonRepostPipe} from '@shared/pipes/type-guards/is-feed-defs-reasonrepost';
13
13
+
import {IsEmbedImagesViewPipe} from '@shared/pipes/type-guards/is-embed-images-view.pipe';
14
14
+
import {ImagesEmbedComponent} from '@components/embeds/images-embed/images-embed.component';
15
15
+
import {LinkExtractorPipe} from '@shared/pipes/link-extractor.pipe';
16
16
+
import {IsEmbedVideoViewPipe} from '@shared/pipes/type-guards/is-embed-video-view.pipe';
17
17
+
import {VideoEmbedComponent} from '@components/embeds/video-embed/video-embed.component';
18
18
+
import {IsEmbedRecordWithMediaViewPipe} from '@shared/pipes/type-guards/is-embed-recordwithmedia-view.pipe';
19
19
+
import {NumberFormatterPipe} from '@shared/pipes/number-formatter.pipe';
20
20
+
import {PostService} from '@services/post.service';
21
21
+
import {OverlayModule} from '@angular/cdk/overlay';
22
22
+
import {ExternalEmbedComponent} from '@components/embeds/external-embed/external-embed.component';
23
23
+
import {IsEmbedExternalViewPipe} from '@shared/pipes/type-guards/is-embed-external-view.pipe';
24
24
+
25
25
+
@Component({
26
26
+
selector: 'post-card',
27
27
+
imports: [
28
28
+
AvatarComponent,
29
29
+
DisplayNamePipe,
30
30
+
IsFeedPostRecordPipe,
31
31
+
RichTextComponent,
32
32
+
NgTemplateOutlet,
33
33
+
IsFeedDefsPostViewPipe,
34
34
+
DateFormatterPipe,
35
35
+
IsEmbedRecordViewPipe,
36
36
+
RecordEmbedComponent,
37
37
+
IsFeedDefsReasonRepostPipe,
38
38
+
IsEmbedImagesViewPipe,
39
39
+
ImagesEmbedComponent,
40
40
+
LinkExtractorPipe,
41
41
+
IsEmbedVideoViewPipe,
42
42
+
VideoEmbedComponent,
43
43
+
IsEmbedRecordWithMediaViewPipe,
44
44
+
NumberFormatterPipe,
45
45
+
OverlayModule,
46
46
+
NgClass,
47
47
+
ExternalEmbedComponent,
48
48
+
IsEmbedExternalViewPipe
49
49
+
],
50
50
+
templateUrl: './post-card.component.html',
51
51
+
changeDetection: ChangeDetectionStrategy.OnPush
52
52
+
})
53
53
+
export class PostCardComponent implements OnInit, OnDestroy {
54
54
+
post = model<AppBskyFeedDefs.PostView>();
55
55
+
reply = input<AppBskyFeedDefs.ReplyRef>();
56
56
+
reason = input<AppBskyFeedDefs.ReasonRepost | AppBskyFeedDefs.ReasonPin | { [k: string]: unknown; $type: string; }>();
57
57
+
58
58
+
refreshInterval: ReturnType<typeof setInterval>;
59
59
+
processingAction = false;
60
60
+
rtMenuVisible = false;
61
61
+
62
62
+
constructor(
63
63
+
private postService: PostService,
64
64
+
private cdRef: ChangeDetectorRef
65
65
+
) {}
66
66
+
67
67
+
ngOnInit() {
68
68
+
this.refreshInterval = setInterval(() => this.cdRef.markForCheck(), 5e3);
69
69
+
}
70
70
+
71
71
+
ngOnDestroy() {
72
72
+
clearInterval(this.refreshInterval);
73
73
+
}
74
74
+
75
75
+
replyAction(event: Event) {
76
76
+
event.stopPropagation();
77
77
+
this.postService.replyPost(this.post().uri);
78
78
+
}
79
79
+
80
80
+
likeAction(event: Event) {
81
81
+
event.stopPropagation();
82
82
+
if (this.processingAction) return;
83
83
+
this.processingAction = true;
84
84
+
let promise: Promise<void>;
85
85
+
86
86
+
if (this.post().viewer.like) {
87
87
+
promise = this.postService.deleteLike(this.post);
88
88
+
} else {
89
89
+
promise = this.postService.like(this.post);
90
90
+
}
91
91
+
92
92
+
promise
93
93
+
.then(() => {
94
94
+
this.cdRef.markForCheck();
95
95
+
})
96
96
+
.catch(err => {
97
97
+
//TODO: MessageService
98
98
+
})
99
99
+
.finally(() => this.processingAction = false);
100
100
+
}
101
101
+
102
102
+
repostAction(event: Event) {
103
103
+
event.stopPropagation();
104
104
+
if (this.processingAction) return;
105
105
+
this.rtMenuVisible = false;
106
106
+
this.processingAction = true;
107
107
+
let promise: Promise<void>;
108
108
+
109
109
+
if (this.post().viewer.repost) {
110
110
+
promise = this.postService.deleteRepost(this.post);
111
111
+
} else {
112
112
+
promise = this.postService.repost(this.post);
113
113
+
}
114
114
+
115
115
+
promise
116
116
+
.then(() => {
117
117
+
this.cdRef.markForCheck();
118
118
+
})
119
119
+
.catch(err => {
120
120
+
//TODO: MessageService
121
121
+
})
122
122
+
.finally(() => this.processingAction = false);
123
123
+
}
124
124
+
125
125
+
refreshRepostAction(event: Event) {
126
126
+
event.stopPropagation();
127
127
+
if (this.processingAction) return;
128
128
+
this.rtMenuVisible = false;
129
129
+
this.processingAction = true;
130
130
+
131
131
+
this.postService.refreshRepost(this.post)
132
132
+
.then(() => {
133
133
+
this.cdRef.markForCheck();
134
134
+
})
135
135
+
.catch(err => {
136
136
+
//TODO: MessageService
137
137
+
})
138
138
+
.finally(() => this.processingAction = false);
139
139
+
}
140
140
+
}
+19
src/app/components/deck-columns/author-deck-column/author-deck-column.component.html
···
1
1
+
<div
2
2
+
class="flex flex-col h-full w-full"
3
3
+
>
4
4
+
<div
5
5
+
class="flex h-9 w-full border-b shrink-0"
6
6
+
>
7
7
+
<span
8
8
+
class="bg-primary text-bg text-xl font-medium flex items-center px-3 lowercase font-mono"
9
9
+
>{{'@'+column().handle}}</span>
10
10
+
</div>
11
11
+
<div
12
12
+
class="flex-1 min-h-0"
13
13
+
>
14
14
+
<author-feed
15
15
+
[did]="column().did"
16
16
+
class="block h-full"
17
17
+
/>
18
18
+
</div>
19
19
+
</div>
+21
src/app/components/deck-columns/author-deck-column/author-deck-column.component.ts
···
1
1
+
import {booleanAttribute, ChangeDetectionStrategy, Component, input, model, output} from '@angular/core';
2
2
+
import {AuthorDeckColumn} from '@models/deck-column';
3
3
+
import {AuthorFeedComponent} from '@components/feeds/author-feed/author-feed.component';
4
4
+
5
5
+
@Component({
6
6
+
selector: 'author-deck-column',
7
7
+
imports: [
8
8
+
AuthorFeedComponent
9
9
+
],
10
10
+
templateUrl: './author-deck-column.component.html',
11
11
+
changeDetection: ChangeDetectionStrategy.OnPush
12
12
+
})
13
13
+
export class AuthorDeckColumnComponent {
14
14
+
column = model.required<AuthorDeckColumn>();
15
15
+
firstIndex = input(false, {transform: booleanAttribute});
16
16
+
lastIndex = input(false, {transform: booleanAttribute});
17
17
+
reorderNext = output();
18
18
+
reorderPrev = output();
19
19
+
delete = output();
20
20
+
widthChange = output<number>();
21
21
+
}
+18
src/app/components/deck-columns/notification-deck-column/notification-deck-column.component.html
···
1
1
+
<div
2
2
+
class="flex flex-col h-full w-full"
3
3
+
>
4
4
+
<div
5
5
+
class="flex h-9 w-full border-b shrink-0"
6
6
+
>
7
7
+
<span
8
8
+
class="bg-primary text-bg text-xl font-medium flex items-center px-3 lowercase font-mono"
9
9
+
>{{column().title}}</span>
10
10
+
</div>
11
11
+
<div
12
12
+
class="flex-1 min-h-0"
13
13
+
>
14
14
+
<notification-feed
15
15
+
class="block h-full"
16
16
+
/>
17
17
+
</div>
18
18
+
</div>
+21
src/app/components/deck-columns/notification-deck-column/notification-deck-column.component.ts
···
1
1
+
import {booleanAttribute, ChangeDetectionStrategy, Component, input, model, output} from '@angular/core';
2
2
+
import {NotificationDeckColumn} from '@models/deck-column';
3
3
+
import {NotificationFeedComponent} from '@components/feeds/notification-feed/notification-feed.component';
4
4
+
5
5
+
@Component({
6
6
+
selector: 'notification-deck-column',
7
7
+
imports: [
8
8
+
NotificationFeedComponent
9
9
+
],
10
10
+
templateUrl: './notification-deck-column.component.html',
11
11
+
changeDetection: ChangeDetectionStrategy.OnPush
12
12
+
})
13
13
+
export class NotificationDeckColumnComponent {
14
14
+
column = model.required<NotificationDeckColumn>();
15
15
+
firstIndex = input(false, {transform: booleanAttribute});
16
16
+
lastIndex = input(false, {transform: booleanAttribute});
17
17
+
reorderNext = output();
18
18
+
reorderPrev = output();
19
19
+
delete = output();
20
20
+
widthChange = output<number>();
21
21
+
}
+18
src/app/components/deck-columns/timeline-deck-column/timeline-deck-column.component.html
···
1
1
+
<div
2
2
+
class="flex flex-col h-full w-full"
3
3
+
>
4
4
+
<div
5
5
+
class="flex h-9 w-full border-b shrink-0"
6
6
+
>
7
7
+
<span
8
8
+
class="bg-primary text-bg text-xl font-medium flex items-center px-3 lowercase font-mono"
9
9
+
>{{column().title}}</span>
10
10
+
</div>
11
11
+
<div
12
12
+
class="flex-1 min-h-0"
13
13
+
>
14
14
+
<timeline-feed
15
15
+
class="block h-full"
16
16
+
/>
17
17
+
</div>
18
18
+
</div>
+21
src/app/components/deck-columns/timeline-deck-column/timeline-deck-column.component.ts
···
1
1
+
import {booleanAttribute, ChangeDetectionStrategy, Component, input, model, output} from '@angular/core';
2
2
+
import {TimelineDeckColumn} from '@models/deck-column';
3
3
+
import {TimelineFeedComponent} from '@components/feeds/timeline-feed/timeline-feed.component';
4
4
+
5
5
+
@Component({
6
6
+
selector: 'timeline-deck-column',
7
7
+
imports: [
8
8
+
TimelineFeedComponent
9
9
+
],
10
10
+
templateUrl: './timeline-deck-column.component.html',
11
11
+
changeDetection: ChangeDetectionStrategy.OnPush
12
12
+
})
13
13
+
export class TimelineDeckColumnComponent {
14
14
+
column = model.required<TimelineDeckColumn>();
15
15
+
firstIndex = input(false, {transform: booleanAttribute});
16
16
+
lastIndex = input(false, {transform: booleanAttribute});
17
17
+
reorderNext = output();
18
18
+
reorderPrev = output();
19
19
+
delete = output();
20
20
+
widthChange = output<number>();
21
21
+
}
+55
src/app/components/embeds/external-embed/external-embed.component.html
···
1
1
+
@if (snippet.type == LinkSnippetType) {
2
2
+
<a
3
3
+
[href]="external().uri"
4
4
+
target="_blank"
5
5
+
(click)="$event.stopPropagation()"
6
6
+
class="flex w-full bg-primary/2"
7
7
+
>
8
8
+
@if (external().thumb) {
9
9
+
<img
10
10
+
[ngSrc]="external().thumb"
11
11
+
alt="thumb"
12
12
+
width="1000"
13
13
+
height="1000"
14
14
+
class="h-16 w-16 object-cover"
15
15
+
/>
16
16
+
}
17
17
+
18
18
+
<div
19
19
+
class="flex flex-col justify-center px-2 hover:bg-primary/2"
20
20
+
>
21
21
+
<span
22
22
+
class="font-semibold line-clamp-2 leading-[1.15]"
23
23
+
>{{ external().title }}</span>
24
24
+
<span
25
25
+
class="text-xs"
26
26
+
>{{ snippet.domain }}</span>
27
27
+
</div>
28
28
+
</a>
29
29
+
}
30
30
+
31
31
+
@if (snippet.type == BlueskyGifSnippetType) {
32
32
+
<video
33
33
+
#target
34
34
+
class="video-js vjs-show-big-play-button-on-pause rounded-md overflow-hidden"
35
35
+
(click)="$event.stopPropagation()"
36
36
+
></video>
37
37
+
}
38
38
+
39
39
+
@if (snippet.type == IframeSnippetType) {
40
40
+
@if (snippet.source == YoutubeSnippetSource) {
41
41
+
<youtube-player
42
42
+
[videoId]="snippet.url"
43
43
+
class="aspect-video"
44
44
+
(click)="$event.stopPropagation()"
45
45
+
/>
46
46
+
} @else {
47
47
+
<iframe
48
48
+
[src]="safeURL"
49
49
+
width="100%"
50
50
+
allow="fullscreen"
51
51
+
class="aspect-video"
52
52
+
(click)="$event.stopPropagation()"
53
53
+
></iframe>
54
54
+
}
55
55
+
}
+102
src/app/components/embeds/external-embed/external-embed.component.ts
···
1
1
+
import {
2
2
+
AfterViewInit,
3
3
+
ChangeDetectionStrategy,
4
4
+
Component,
5
5
+
ElementRef,
6
6
+
input,
7
7
+
OnDestroy,
8
8
+
OnInit,
9
9
+
viewChildren
10
10
+
} from '@angular/core';
11
11
+
import {AppBskyEmbedExternal} from "@atproto/api";
12
12
+
import {DomSanitizer, SafeResourceUrl} from "@angular/platform-browser";
13
13
+
import videojs from "video.js";
14
14
+
import type Player from "video.js/dist/types/player";
15
15
+
import {NgOptimizedImage} from "@angular/common";
16
16
+
import {BlueskyGifSnippet, IframeSnippet, LinkSnippet, SnippetSource, SnippetType} from '@models/snippet';
17
17
+
import {SnippetUtils} from '@shared/utils/snippet-utils';
18
18
+
import {YouTubePlayer} from '@angular/youtube-player';
19
19
+
20
20
+
type Options = typeof videojs.options;
21
21
+
22
22
+
@Component({
23
23
+
selector: 'external-embed',
24
24
+
imports: [
25
25
+
YouTubePlayer,
26
26
+
NgOptimizedImage
27
27
+
],
28
28
+
templateUrl: './external-embed.component.html',
29
29
+
styles: `
30
30
+
:host(::ng-deep youtube-player) {
31
31
+
display: flex;
32
32
+
}
33
33
+
:host(::ng-deep youtube-player) youtube-player-placeholder {
34
34
+
width: 100% !important;
35
35
+
height: unset !important;
36
36
+
}
37
37
+
:host(::ng-deep youtube-player) > div {
38
38
+
width: 100%;
39
39
+
}
40
40
+
:host(::ng-deep youtube-player) > div iframe {
41
41
+
height: unset;
42
42
+
width: 100%;
43
43
+
aspect-ratio: 16 / 9;
44
44
+
}
45
45
+
46
46
+
`,
47
47
+
changeDetection: ChangeDetectionStrategy.OnPush
48
48
+
})
49
49
+
export class ExternalEmbedComponent implements OnInit, OnDestroy, AfterViewInit {
50
50
+
external = input<AppBskyEmbedExternal.ViewExternal>();
51
51
+
target = viewChildren<ElementRef<HTMLVideoElement>>('target');
52
52
+
53
53
+
player: Player;
54
54
+
options: Options;
55
55
+
snippet: LinkSnippet | BlueskyGifSnippet | IframeSnippet;
56
56
+
safeURL: SafeResourceUrl;
57
57
+
58
58
+
protected readonly LinkSnippetType = SnippetType.LINK;
59
59
+
protected readonly BlueskyGifSnippetType = SnippetType.BLUESKY_GIF;
60
60
+
protected readonly IframeSnippetType = SnippetType.IFRAME;
61
61
+
protected readonly YoutubeSnippetSource = SnippetSource.YOUTUBE;
62
62
+
63
63
+
constructor(
64
64
+
private sanitizer: DomSanitizer,
65
65
+
) {}
66
66
+
67
67
+
ngOnInit() {
68
68
+
this.snippet = SnippetUtils.detectSnippet(this.external());
69
69
+
70
70
+
if (this.snippet.type === SnippetType.IFRAME) {
71
71
+
this.safeURL = this.sanitizer.bypassSecurityTrustResourceUrl(this.snippet.url);
72
72
+
}
73
73
+
}
74
74
+
75
75
+
ngAfterViewInit() {
76
76
+
if (this.snippet.type === SnippetType.BLUESKY_GIF) {
77
77
+
this.options = {
78
78
+
fluid: true,
79
79
+
aspectRatio: this.snippet.ratio,
80
80
+
autoplay: true,
81
81
+
loop: true,
82
82
+
sources: {
83
83
+
src: this.snippet.url,
84
84
+
type: 'video/webm'
85
85
+
},
86
86
+
controls: true,
87
87
+
muted: true,
88
88
+
playsinline: true,
89
89
+
preload: 'none',
90
90
+
bigPlayButton: true,
91
91
+
controlBar: false,
92
92
+
};
93
93
+
94
94
+
this.player = videojs(this.target()[0].nativeElement, this.options);
95
95
+
}
96
96
+
}
97
97
+
98
98
+
ngOnDestroy() {
99
99
+
this.player?.dispose();
100
100
+
}
101
101
+
102
102
+
}
+54
src/app/components/embeds/images-embed/images-embed.component.html
···
1
1
+
@switch (images().length) {
2
2
+
@case (1) {
3
3
+
<img
4
4
+
[ngSrc]="images()[0].thumb"
5
5
+
[alt]="images()[0].alt"
6
6
+
width="1000"
7
7
+
height="1000"
8
8
+
class="min-w-full min-h-full max-h-full w-auto h-auto object-cover pointer"
9
9
+
(click)="imgClick(0, $event)"
10
10
+
/>
11
11
+
}
12
12
+
@case (2) {
13
13
+
<div class="grid grid-cols-[repeat(2,_1fr)] grid-rows-[repeat(1,_1fr)] gap-2 aspect-video overflow-hidden">
14
14
+
@for (image of images(); track image.thumb; let i = $index) {
15
15
+
<img
16
16
+
[ngSrc]="images()[i].thumb"
17
17
+
[alt]="images()[i].alt"
18
18
+
width="1000"
19
19
+
height="1000"
20
20
+
class="min-w-full min-h-full max-h-full w-auto h-auto object-cover pointer"
21
21
+
(click)="imgClick(i, $event)"
22
22
+
/>
23
23
+
}
24
24
+
</div>
25
25
+
}
26
26
+
@case (3) {
27
27
+
<div class="grid grid-cols-[repeat(2,_1fr)] grid-rows-[repeat(2,_1fr)] gap-2 aspect-video overflow-hidden">
28
28
+
@for (image of images(); track image.thumb; let i = $index) {
29
29
+
<img
30
30
+
[ngSrc]="images()[i].thumb"
31
31
+
[alt]="images()[i].alt"
32
32
+
width="1000"
33
33
+
height="1000"
34
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
35
+
(click)="imgClick(i, $event)"
36
36
+
/>
37
37
+
}
38
38
+
</div>
39
39
+
}
40
40
+
@case (4) {
41
41
+
<div class="grid grid-cols-[repeat(2,_1fr)] grid-rows-[repeat(2,_1fr)] gap-2 aspect-[4/3] overflow-hidden">
42
42
+
@for (image of images(); track image.thumb; let i = $index) {
43
43
+
<img
44
44
+
[ngSrc]="images()[i].thumb"
45
45
+
[alt]="images()[i].alt"
46
46
+
width="1000"
47
47
+
height="1000"
48
48
+
class="min-w-full min-h-full max-h-full w-auto h-auto object-cover pointer"
49
49
+
(click)="imgClick(i, $event)"
50
50
+
/>
51
51
+
}
52
52
+
</div>
53
53
+
}
54
54
+
}
+20
src/app/components/embeds/images-embed/images-embed.component.ts
···
1
1
+
import {ChangeDetectionStrategy, Component, input, output} from '@angular/core';
2
2
+
import {AppBskyEmbedImages} from '@atproto/api';
3
3
+
import {NgOptimizedImage} from '@angular/common';
4
4
+
5
5
+
@Component({
6
6
+
selector: 'images-embed',
7
7
+
imports: [
8
8
+
NgOptimizedImage
9
9
+
],
10
10
+
templateUrl: './images-embed.component.html',
11
11
+
changeDetection: ChangeDetectionStrategy.OnPush
12
12
+
})
13
13
+
export class ImagesEmbedComponent {
14
14
+
images = input<AppBskyEmbedImages.ViewImage[]>();
15
15
+
onClick = output<number>();
16
16
+
17
17
+
imgClick(index: number, event: Event) {
18
18
+
19
19
+
}
20
20
+
}
+191
src/app/components/embeds/record-embed/record-embed.component.html
···
1
1
+
@let postRecord = record();
2
2
+
3
3
+
<div
4
4
+
class="flex"
5
5
+
>
6
6
+
<div
7
7
+
class="overflow-hidden shrink-0 h-5 w-9 flex items-center justify-center"
8
8
+
>
9
9
+
<span class="material-icons !text-[2.25em]">format_quote</span>
10
10
+
</div>
11
11
+
12
12
+
<div
13
13
+
class="flex flex-col flex-1 min-w-0 mt-1"
14
14
+
>
15
15
+
@if (postRecord | isEmbedRecordViewRecord) {
16
16
+
<ng-container
17
17
+
[ngTemplateOutlet]="viewRecord"
18
18
+
[ngTemplateOutletContext]="{record: postRecord, media: postRecord.embeds ? postRecord.embeds[0] : undefined }"
19
19
+
/>
20
20
+
}
21
21
+
22
22
+
@if (postRecord | isEmbedRecordViewBlocked) {
23
23
+
<span>
24
24
+
Post blocked
25
25
+
</span>
26
26
+
}
27
27
+
28
28
+
@if (postRecord | isEmbedRecordViewNotFound) {
29
29
+
<span>
30
30
+
Post not found
31
31
+
</span>
32
32
+
}
33
33
+
34
34
+
@if (postRecord | isEmbedRecordViewDetached) {
35
35
+
<span>
36
36
+
Post detached
37
37
+
</span>
38
38
+
}
39
39
+
40
40
+
@if (postRecord | isFeedDefsGeneratorView) {
41
41
+
<ng-container
42
42
+
[ngTemplateOutlet]="feed"
43
43
+
[ngTemplateOutletContext]="{feed: postRecord}"
44
44
+
/>
45
45
+
}
46
46
+
47
47
+
@if (postRecord | isGraphDefsListView) {
48
48
+
<ng-container
49
49
+
[ngTemplateOutlet]="userList"
50
50
+
[ngTemplateOutletContext]="{list: postRecord}"
51
51
+
/>
52
52
+
}
53
53
+
54
54
+
<!-- Apparently there's no actual support yet? -->
55
55
+
@if (postRecord | isLabelerDefsLabelerView) {
56
56
+
<span>
57
57
+
Labeler record
58
58
+
</span>
59
59
+
}
60
60
+
61
61
+
@if (postRecord | isGraphDefsStarterPackViewBasic) {
62
62
+
<ng-container
63
63
+
[ngTemplateOutlet]="starterPack"
64
64
+
[ngTemplateOutletContext]="{starterPack: postRecord}"
65
65
+
/>
66
66
+
}
67
67
+
</div>
68
68
+
</div>
69
69
+
70
70
+
<ng-template
71
71
+
#viewRecord
72
72
+
let-record="record"
73
73
+
let-media="media"
74
74
+
>
75
75
+
<span
76
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
77
+
>{{record.author | displayName}}</span>
78
78
+
79
79
+
@if ((record.value | isFeedPostRecord) && record.value.text.length) {
80
80
+
<rich-text
81
81
+
[text]="record.value.text"
82
82
+
class="mt-2"
83
83
+
/>
84
84
+
}
85
85
+
86
86
+
@if (media) {
87
87
+
<ng-container
88
88
+
[ngTemplateOutlet]="mediaEmbeds"
89
89
+
[ngTemplateOutletContext]="{
90
90
+
media: media,
91
91
+
margin: (record.value | isFeedPostRecord) && record.value.text.length ? 'mt-2' : 'mt-3'
92
92
+
}"
93
93
+
/>
94
94
+
}
95
95
+
96
96
+
</ng-template>
97
97
+
98
98
+
<ng-template
99
99
+
#mediaEmbeds
100
100
+
let-media="media"
101
101
+
let-margin="margin"
102
102
+
>
103
103
+
@if (media | isEmbedImagesView) {
104
104
+
<images-embed
105
105
+
[images]="media.images"
106
106
+
[class]="margin"
107
107
+
/>
108
108
+
}
109
109
+
110
110
+
@if (media | isEmbedVideoView) {
111
111
+
<video-embed
112
112
+
[embed]="media"
113
113
+
[class]="margin"
114
114
+
/>
115
115
+
}
116
116
+
117
117
+
@if (media | isEmbedRecordWithMediaView) {
118
118
+
@if (media.media | isEmbedImagesView) {
119
119
+
<images-embed
120
120
+
[images]="media.media.images"
121
121
+
[class]="margin"
122
122
+
/>
123
123
+
}
124
124
+
125
125
+
@if (media.media | isEmbedVideoView) {
126
126
+
<video-embed
127
127
+
[embed]="media.media"
128
128
+
[class]="margin"
129
129
+
/>
130
130
+
}
131
131
+
}
132
132
+
</ng-template>
133
133
+
134
134
+
<ng-template
135
135
+
#feed
136
136
+
let-feed="feed"
137
137
+
>
138
138
+
<span
139
139
+
class="text-bold overflow-hidden whitespace-nowrap text-ellipsis"
140
140
+
>{{feed.displayName}}</span>
141
141
+
142
142
+
<span
143
143
+
class="overflow-hidden whitespace-nowrap text-ellipsis"
144
144
+
>
145
145
+
@switch (feed.contentMode) {
146
146
+
@case (AppBskyFeedDefs.CONTENTMODEVIDEO) {
147
147
+
Video feed by {{ feed.creator | displayName }}
148
148
+
}
149
149
+
@default {
150
150
+
Feed by {{ feed.creator | displayName }}
151
151
+
}
152
152
+
}
153
153
+
</span>
154
154
+
</ng-template>
155
155
+
156
156
+
<ng-template
157
157
+
#userList
158
158
+
let-list="list"
159
159
+
>
160
160
+
<span
161
161
+
class="text-bold overflow-hidden whitespace-nowrap text-ellipsis"
162
162
+
>{{list.name}}</span>
163
163
+
164
164
+
<span
165
165
+
class="overflow-hidden whitespace-nowrap text-ellipsis"
166
166
+
>
167
167
+
@switch (list.purpose) {
168
168
+
@case (AppBskyGraphDefs.MODLIST) {
169
169
+
Mute list by {{ list.creator | displayName }}
170
170
+
}
171
171
+
@default {
172
172
+
List by {{ list.creator | displayName }}
173
173
+
}
174
174
+
}
175
175
+
</span>
176
176
+
</ng-template>
177
177
+
178
178
+
<ng-template
179
179
+
#starterPack
180
180
+
let-starterpack="starterPack"
181
181
+
>
182
182
+
<span
183
183
+
class="text-bold overflow-hidden whitespace-nowrap text-ellipsis"
184
184
+
>{{starterpack.record.name}}</span>
185
185
+
186
186
+
<span
187
187
+
class="overflow-hidden whitespace-nowrap text-ellipsis"
188
188
+
>
189
189
+
Starter pack by {{ starterpack.creator | displayName }}
190
190
+
</span>
191
191
+
</ng-template>
+59
src/app/components/embeds/record-embed/record-embed.component.ts
···
1
1
+
import {ChangeDetectionStrategy, Component, input} from '@angular/core';
2
2
+
import {$Typed, AppBskyEmbedRecord, AppBskyFeedDefs, AppBskyGraphDefs, AppBskyLabelerDefs} from '@atproto/api';
3
3
+
import {DisplayNamePipe} from '@shared/pipes/display-name.pipe';
4
4
+
import {IsEmbedRecordViewRecordPipe} from '@shared/pipes/type-guards/is-embed-record-viewrecord.pipe';
5
5
+
import {NgTemplateOutlet} from '@angular/common';
6
6
+
import {IsFeedPostRecordPipe} from '@shared/pipes/type-guards/is-feed-post-record';
7
7
+
import {RichTextComponent} from '@components/shared/rich-text/rich-text.component';
8
8
+
import {IsEmbedImagesViewPipe} from '@shared/pipes/type-guards/is-embed-images-view.pipe';
9
9
+
import {ImagesEmbedComponent} from '@components/embeds/images-embed/images-embed.component';
10
10
+
import {IsEmbedVideoViewPipe} from '@shared/pipes/type-guards/is-embed-video-view.pipe';
11
11
+
import {VideoEmbedComponent} from '@components/embeds/video-embed/video-embed.component';
12
12
+
import {IsEmbedRecordWithMediaViewPipe} from '@shared/pipes/type-guards/is-embed-recordwithmedia-view.pipe';
13
13
+
import {IsEmbedRecordViewBlockedPipe} from '@shared/pipes/type-guards/is-embed-record-viewblocked.pipe';
14
14
+
import {IsEmbedRecordViewNotFoundPipe} from '@shared/pipes/type-guards/is-embed-record-viewnotfound.pipe';
15
15
+
import {IsEmbedRecordViewDetachedPipe} from '@shared/pipes/type-guards/is-embed-record-viewdetached.pipe';
16
16
+
import {IsFeedDefsGeneratorViewPipe} from '@shared/pipes/type-guards/is-feed-defs-generator-view';
17
17
+
import {IsGraphDefsListViewPipe} from '@shared/pipes/type-guards/is-graph-defs-list-view';
18
18
+
import {IsLabelerDefsLabelerViewPipe} from '@shared/pipes/type-guards/is-labeler-defs-labeler-view';
19
19
+
import {IsGraphDefsStarterPackViewBasicPipe} from '@shared/pipes/type-guards/is-graph-defs-starterpack-viewbasic';
20
20
+
21
21
+
@Component({
22
22
+
selector: 'record-embed',
23
23
+
imports: [
24
24
+
DisplayNamePipe,
25
25
+
IsEmbedRecordViewRecordPipe,
26
26
+
NgTemplateOutlet,
27
27
+
IsFeedPostRecordPipe,
28
28
+
RichTextComponent,
29
29
+
IsEmbedImagesViewPipe,
30
30
+
ImagesEmbedComponent,
31
31
+
IsEmbedVideoViewPipe,
32
32
+
VideoEmbedComponent,
33
33
+
IsEmbedRecordWithMediaViewPipe,
34
34
+
IsEmbedRecordViewBlockedPipe,
35
35
+
IsEmbedRecordViewNotFoundPipe,
36
36
+
IsEmbedRecordViewDetachedPipe,
37
37
+
IsFeedDefsGeneratorViewPipe,
38
38
+
IsGraphDefsListViewPipe,
39
39
+
IsLabelerDefsLabelerViewPipe,
40
40
+
IsGraphDefsStarterPackViewBasicPipe
41
41
+
],
42
42
+
templateUrl: './record-embed.component.html',
43
43
+
changeDetection: ChangeDetectionStrategy.OnPush
44
44
+
})
45
45
+
export class RecordEmbedComponent {
46
46
+
record = input<
47
47
+
| $Typed<AppBskyEmbedRecord.ViewRecord>
48
48
+
| $Typed<AppBskyEmbedRecord.ViewNotFound>
49
49
+
| $Typed<AppBskyEmbedRecord.ViewBlocked>
50
50
+
| $Typed<AppBskyEmbedRecord.ViewDetached>
51
51
+
| $Typed<AppBskyFeedDefs.GeneratorView>
52
52
+
| $Typed<AppBskyGraphDefs.ListView>
53
53
+
| $Typed<AppBskyLabelerDefs.LabelerView>
54
54
+
| $Typed<AppBskyGraphDefs.StarterPackViewBasic>
55
55
+
| { $type: string }
56
56
+
>();
57
57
+
protected readonly AppBskyFeedDefs = AppBskyFeedDefs;
58
58
+
protected readonly AppBskyGraphDefs = AppBskyGraphDefs;
59
59
+
}
+8
src/app/components/embeds/video-embed/video-embed.component.html
···
1
1
+
<video
2
2
+
#target
3
3
+
class="video-js cursor-pointer"
4
4
+
controls
5
5
+
muted
6
6
+
playsinline
7
7
+
preload="none"
8
8
+
></video>
+74
src/app/components/embeds/video-embed/video-embed.component.ts
···
1
1
+
import {ChangeDetectionStrategy, Component, ElementRef, input, OnDestroy, OnInit, viewChild} from '@angular/core';
2
2
+
import {AppBskyEmbedVideo} from "@atproto/api";
3
3
+
import videojs from "video.js";
4
4
+
import type Player from "video.js/dist/types/player";
5
5
+
6
6
+
type Options = typeof videojs.options;
7
7
+
8
8
+
@Component({
9
9
+
selector: 'video-embed',
10
10
+
templateUrl: './video-embed.component.html',
11
11
+
styles:
12
12
+
`
13
13
+
::ng-deep .video-js .vjs-control-bar {
14
14
+
background: linear-gradient(to top, rgba(43, 51, 63, 0.7), transparent);
15
15
+
}
16
16
+
::ng-deep .video-js > .vjs-remaining-time {
17
17
+
height: 0;
18
18
+
position: absolute;
19
19
+
bottom: 0;
20
20
+
right: 0;
21
21
+
font-size: 0.75rem;
22
22
+
font-family: 'Inter', sans-serif;
23
23
+
opacity: 0;
24
24
+
}
25
25
+
::ng-deep .video-js.vjs-user-inactive .vjs-remaining-time {
26
26
+
height: 2rem;
27
27
+
opacity: 1;
28
28
+
transition: 1.5s opacity ease;
29
29
+
}
30
30
+
`,
31
31
+
changeDetection: ChangeDetectionStrategy.OnPush,
32
32
+
})
33
33
+
export class VideoEmbedComponent implements OnInit, OnDestroy {
34
34
+
embed = input<AppBskyEmbedVideo.View>();
35
35
+
target = viewChild('target', {read: ElementRef});
36
36
+
37
37
+
player: Player;
38
38
+
options: Options;
39
39
+
interacted = false;
40
40
+
41
41
+
ngOnInit() {
42
42
+
this.options = {
43
43
+
fluid: true,
44
44
+
aspectRatio: this.embed().aspectRatio ? `${this.embed().aspectRatio.width}:${this.embed().aspectRatio.height}` : '16:9',
45
45
+
autoplay: true,
46
46
+
sources: {
47
47
+
src: this.embed().playlist,
48
48
+
type: 'application/x-mpegURL'
49
49
+
},
50
50
+
playsinline: true,
51
51
+
preload: 'auto',
52
52
+
loop: true,
53
53
+
inactivityTimeout: 1000,
54
54
+
userActions: {
55
55
+
click: () => {
56
56
+
if (this.interacted) {
57
57
+
this.player.paused() ? this.player.play() : this.player.pause();
58
58
+
} else {
59
59
+
this.player.loop(false);
60
60
+
this.player.muted(false);
61
61
+
this.interacted = true;
62
62
+
}
63
63
+
}
64
64
+
}
65
65
+
};
66
66
+
67
67
+
this.player = videojs(this.target().nativeElement, this.options);
68
68
+
this.player.addChild('RemainingTimeDisplay', {});
69
69
+
}
70
70
+
71
71
+
ngOnDestroy() {
72
72
+
this.player?.dispose();
73
73
+
}
74
74
+
}
+33
src/app/components/feeds/author-feed/author-feed.component.html
···
1
1
+
<div
2
2
+
#feed
3
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
4
+
vScroll
5
5
+
(scrollEnding)="nextData(); manageRefresh();"
6
6
+
(scrollTop)="manageRefresh();"
7
7
+
>
8
8
+
@if (posts) {
9
9
+
@for (post of posts; track post.uuid) {
10
10
+
<post-card
11
11
+
[post]="post.post()"
12
12
+
[reply]="post.reply"
13
13
+
[reason]="post.reason"
14
14
+
(postChange)="post.post.set($event)"
15
15
+
class="cursor-pointer hover:bg-primary/2 w-full"
16
16
+
/>
17
17
+
<div
18
18
+
class="border-b border-b-primary/10 w-9/10"
19
19
+
style="mask-image: linear-gradient(to right, transparent, #000000 50%, transparent);"
20
20
+
></div>
21
21
+
}
22
22
+
<!-- } @else {-->
23
23
+
<!-- <div-->
24
24
+
<!-- class="h-full w-full flex items-center justify-center"-->
25
25
+
<!-- >-->
26
26
+
<!-- <p-progress-spinner-->
27
27
+
<!-- class="h-12"-->
28
28
+
<!-- strokeWidth="5"-->
29
29
+
<!-- [style]="{height: '3rem', width: '3rem'}"-->
30
30
+
<!-- />-->
31
31
+
<!-- </div>-->
32
32
+
}
33
33
+
</div>
+166
src/app/components/feeds/author-feed/author-feed.component.ts
···
1
1
+
import {
2
2
+
ChangeDetectionStrategy,
3
3
+
ChangeDetectorRef,
4
4
+
Component,
5
5
+
ElementRef,
6
6
+
input,
7
7
+
OnDestroy,
8
8
+
OnInit,
9
9
+
viewChild,
10
10
+
} from '@angular/core';
11
11
+
import {CommonModule} from "@angular/common";
12
12
+
import {agent} from '@core/bsky.api';
13
13
+
import {ScrollDirective} from '@shared/directives/scroll.directive';
14
14
+
import {$Typed} from '@atproto/api';
15
15
+
import {ReasonRepost} from '@atproto/api/dist/client/types/app/bsky/feed/defs';
16
16
+
import {PostService} from '@services/post.service';
17
17
+
import {PostUtils} from '@shared/utils/post-utils';
18
18
+
import {SignalizedFeedViewPost} from '@models/signalized-feed-view-post';
19
19
+
import {from} from 'rxjs';
20
20
+
import {PostCardComponent} from '@components/cards/post-card/post-card.component';
21
21
+
22
22
+
@Component({
23
23
+
selector: 'author-feed',
24
24
+
imports: [
25
25
+
CommonModule,
26
26
+
ScrollDirective,
27
27
+
PostCardComponent,
28
28
+
],
29
29
+
templateUrl: './author-feed.component.html',
30
30
+
changeDetection: ChangeDetectionStrategy.OnPush
31
31
+
})
32
32
+
export class AuthorFeedComponent implements OnInit, OnDestroy {
33
33
+
feed = viewChild<ElementRef>('feed');
34
34
+
did = input.required<string>();
35
35
+
36
36
+
posts: SignalizedFeedViewPost[];
37
37
+
cursor: string;
38
38
+
loading = true;
39
39
+
reloadReady = false;
40
40
+
reloadTimeout: ReturnType<typeof setTimeout>;
41
41
+
42
42
+
constructor(
43
43
+
private postService: PostService,
44
44
+
// private dialogService: MskyDialogService,
45
45
+
public cdRef: ChangeDetectorRef
46
46
+
) {}
47
47
+
48
48
+
ngOnInit() {
49
49
+
this.initData();
50
50
+
51
51
+
// Listen to new posts to refresh
52
52
+
this.postService.refreshFeeds.subscribe({
53
53
+
next: () => {
54
54
+
if (this.feed().nativeElement.scrollTop == 0) {
55
55
+
this.initData();
56
56
+
} else {
57
57
+
this.reloadReady = true;
58
58
+
}
59
59
+
}
60
60
+
});
61
61
+
}
62
62
+
63
63
+
ngOnDestroy() {
64
64
+
this.postService.refreshFeeds.unsubscribe();
65
65
+
clearTimeout(this.reloadTimeout);
66
66
+
}
67
67
+
68
68
+
initData() {
69
69
+
this.loading = true;
70
70
+
from(agent.getAuthorFeed({
71
71
+
actor: this.did(),
72
72
+
limit: 15
73
73
+
})).subscribe({
74
74
+
next: response => {
75
75
+
this.cursor = response.data.cursor;
76
76
+
this.posts = response.data.feed.map(fvp => PostUtils.parseFeedViewPost(fvp, this.postService));
77
77
+
this.cdRef.markForCheck();
78
78
+
setTimeout(() => {
79
79
+
this.loading = false;
80
80
+
this.manageRefresh();
81
81
+
}, 500);
82
82
+
//TODO: MessageService
83
83
+
}, error: err => console.log(err.message)
84
84
+
});
85
85
+
}
86
86
+
87
87
+
nextData() {
88
88
+
if (this.loading) return;
89
89
+
this.loading = true;
90
90
+
91
91
+
from(agent.getAuthorFeed({
92
92
+
actor: this.did(),
93
93
+
cursor: this.cursor,
94
94
+
limit: 15
95
95
+
})).subscribe({
96
96
+
next: response => {
97
97
+
this.cursor = response.data.cursor;
98
98
+
const newPosts = response.data.feed.map(fvp => PostUtils.parseFeedViewPost(fvp, this.postService));
99
99
+
this.posts = [...this.posts, ...newPosts];
100
100
+
this.cdRef.markForCheck();
101
101
+
setTimeout(() => {
102
102
+
this.loading = false;
103
103
+
}, 500);
104
104
+
//TODO: MessageService
105
105
+
}, error: err => console.log(err.message)
106
106
+
});
107
107
+
}
108
108
+
109
109
+
openPost(uri: string) {
110
110
+
//TODO: OpenPost
111
111
+
// Mute all video players
112
112
+
// this.feed().nativeElement.querySelectorAll('video').forEach((video: HTMLVideoElement) => {
113
113
+
// video.muted = true;
114
114
+
// });
115
115
+
//
116
116
+
// this.dialogService.openThread(uri, this.feed().nativeElement);
117
117
+
}
118
118
+
119
119
+
manageRefresh() {
120
120
+
if (this.loading) return;
121
121
+
122
122
+
if (!this.reloadReady && !this.reloadTimeout) {
123
123
+
this.reloadTimeout = setTimeout(() => {
124
124
+
this.reloadTimeout = undefined;
125
125
+
126
126
+
if (this.feed().nativeElement.scrollTop == 0) {
127
127
+
this.reloadReady = false;
128
128
+
from(agent.getAuthorFeed({
129
129
+
actor: this.did(),
130
130
+
limit: 1
131
131
+
})).subscribe({
132
132
+
next: response => {
133
133
+
const post = response.data.feed[0];
134
134
+
const lastPost = this.posts[0];
135
135
+
let isNewPost = false;
136
136
+
137
137
+
if (post) {
138
138
+
if (post.reason) {
139
139
+
const reason = post.reason as $Typed<ReasonRepost>;
140
140
+
if (!lastPost.reason) isNewPost = true;
141
141
+
if (reason.indexedAt !== (lastPost.reason as $Typed<ReasonRepost>)?.indexedAt) isNewPost = true;
142
142
+
} else {
143
143
+
if (lastPost.reason) isNewPost = true;
144
144
+
if (post.post.indexedAt !== lastPost.post().indexedAt) isNewPost = true;
145
145
+
}
146
146
+
}
147
147
+
148
148
+
if (isNewPost) {
149
149
+
this.initData();
150
150
+
} else {
151
151
+
this.manageRefresh();
152
152
+
}
153
153
+
//TODO: MessageService
154
154
+
}, error: err => console.log(err.message)
155
155
+
});
156
156
+
} else {
157
157
+
this.reloadReady = true;
158
158
+
}
159
159
+
}, 30e3);
160
160
+
// Timer in seconds
161
161
+
} else if (this.reloadReady && this.feed().nativeElement.scrollTop == 0) {
162
162
+
this.reloadReady = false;
163
163
+
this.initData();
164
164
+
}
165
165
+
}
166
166
+
}
+39
src/app/components/feeds/notification-feed/notification-feed.component.html
···
1
1
+
<div
2
2
+
#feed
3
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
4
+
vScroll
5
5
+
(scrollEnding)="nextData(); manageRefresh();"
6
6
+
(scrollTop)="manageRefresh();"
7
7
+
>
8
8
+
@if (notifications) {
9
9
+
@for (notification of notifications; track notification.uuid) {
10
10
+
@if (notification | isPostNotification) {
11
11
+
<post-card
12
12
+
[post]="notification.post()"
13
13
+
(postChange)="notification.post.set($event)"
14
14
+
class="cursor-pointer hover:bg-primary/2 w-full"
15
15
+
/>
16
16
+
} @else {
17
17
+
<notification-card
18
18
+
[notification]="notification"
19
19
+
(onClick)="openNotification($event)"
20
20
+
class="cursor-pointer hover:bg-primary/2 w-full"
21
21
+
/>
22
22
+
}
23
23
+
<div
24
24
+
class="border-b border-b-primary/10 w-9/10"
25
25
+
style="mask-image: linear-gradient(to right, transparent, #000000 50%, transparent);"
26
26
+
></div>
27
27
+
}
28
28
+
<!-- } @else {-->
29
29
+
<!-- <div-->
30
30
+
<!-- class="h-full w-full flex items-center justify-center"-->
31
31
+
<!-- >-->
32
32
+
<!-- <p-progress-spinner-->
33
33
+
<!-- class="h-12"-->
34
34
+
<!-- strokeWidth="5"-->
35
35
+
<!-- [style]="{height: '3rem', width: '3rem'}"-->
36
36
+
<!-- />-->
37
37
+
<!-- </div>-->
38
38
+
}
39
39
+
</div>
+146
src/app/components/feeds/notification-feed/notification-feed.component.ts
···
1
1
+
import {
2
2
+
ChangeDetectionStrategy,
3
3
+
ChangeDetectorRef,
4
4
+
Component,
5
5
+
ElementRef,
6
6
+
OnDestroy,
7
7
+
OnInit,
8
8
+
viewChild,
9
9
+
} from '@angular/core';
10
10
+
import {CommonModule} from "@angular/common";
11
11
+
import {agent} from '@core/bsky.api';
12
12
+
import {ScrollDirective} from '@shared/directives/scroll.directive';
13
13
+
import {PostService} from '@services/post.service';
14
14
+
import {from} from 'rxjs';
15
15
+
import {PostCardComponent} from '@components/cards/post-card/post-card.component';
16
16
+
import NotificationUtils from '@shared/utils/notification-utils';
17
17
+
import {Notification} from '@models/notification';
18
18
+
import {IsNotificationArrayPipe} from '@shared/pipes/type-guards/notifications/is-post-notification';
19
19
+
import {NotificationCardComponent} from '@components/cards/notification-card/notification-card.component';
20
20
+
21
21
+
@Component({
22
22
+
selector: 'notification-feed',
23
23
+
imports: [
24
24
+
CommonModule,
25
25
+
ScrollDirective,
26
26
+
PostCardComponent,
27
27
+
IsNotificationArrayPipe,
28
28
+
NotificationCardComponent,
29
29
+
],
30
30
+
templateUrl: './notification-feed.component.html',
31
31
+
changeDetection: ChangeDetectionStrategy.OnPush
32
32
+
})
33
33
+
export class NotificationFeedComponent implements OnInit, OnDestroy {
34
34
+
feed = viewChild<ElementRef>('feed');
35
35
+
36
36
+
notifications: Notification[];
37
37
+
cursor: string;
38
38
+
loading = true;
39
39
+
reloadReady = false;
40
40
+
reloadTimeout: ReturnType<typeof setTimeout>;
41
41
+
42
42
+
constructor(
43
43
+
private postService: PostService,
44
44
+
public cdRef: ChangeDetectorRef
45
45
+
) {}
46
46
+
47
47
+
ngOnInit() {
48
48
+
this.initData();
49
49
+
}
50
50
+
51
51
+
ngOnDestroy() {
52
52
+
clearTimeout(this.reloadTimeout);
53
53
+
}
54
54
+
55
55
+
initData() {
56
56
+
this.loading = true;
57
57
+
from(agent.listNotifications({
58
58
+
limit: 15
59
59
+
})).subscribe({
60
60
+
next: response => {
61
61
+
this.cursor = response.data.cursor;
62
62
+
NotificationUtils.parseNotifications(response.data.notifications, this.postService)
63
63
+
.then(notifications => {
64
64
+
this.notifications = notifications;
65
65
+
this.cdRef.markForCheck();
66
66
+
setTimeout(() => {
67
67
+
this.loading = false;
68
68
+
this.manageRefresh();
69
69
+
}, 500);
70
70
+
});
71
71
+
//TODO: MessageService
72
72
+
}, error: err => console.log(err.message)
73
73
+
});
74
74
+
}
75
75
+
76
76
+
nextData() {
77
77
+
if (this.loading) return;
78
78
+
this.loading = true;
79
79
+
80
80
+
from(agent.listNotifications({
81
81
+
cursor: this.cursor,
82
82
+
limit: 15
83
83
+
})).subscribe({
84
84
+
next: response => {
85
85
+
this.cursor = response.data.cursor;
86
86
+
NotificationUtils.parseNotifications(response.data.notifications, this.postService)
87
87
+
.then(notifications => {
88
88
+
this.notifications = [...this.notifications, ...notifications];
89
89
+
this.cdRef.markForCheck();
90
90
+
setTimeout(() => {
91
91
+
this.loading = false;
92
92
+
this.manageRefresh();
93
93
+
}, 500);
94
94
+
});
95
95
+
setTimeout(() => {
96
96
+
this.loading = false;
97
97
+
}, 500);
98
98
+
//TODO: MessageService
99
99
+
}, error: err => console.log(err.message)
100
100
+
});
101
101
+
}
102
102
+
103
103
+
openNotification(notification: Notification) {
104
104
+
//TODO: OpenNotification
105
105
+
// Mute all video players
106
106
+
// this.feed().nativeElement.querySelectorAll('video').forEach((video: HTMLVideoElement) => {
107
107
+
// video.muted = true;
108
108
+
// });
109
109
+
//
110
110
+
// this.dialogService.openThread(uri, this.feed().nativeElement);
111
111
+
}
112
112
+
113
113
+
manageRefresh() {
114
114
+
if (this.loading) return;
115
115
+
116
116
+
if (!this.reloadReady && !this.reloadTimeout) {
117
117
+
this.reloadTimeout = setTimeout(() => {
118
118
+
this.reloadTimeout = undefined;
119
119
+
120
120
+
if (this.feed().nativeElement.scrollTop == 0) {
121
121
+
this.reloadReady = false;
122
122
+
from(agent.listNotifications({
123
123
+
limit: 1
124
124
+
})).subscribe({
125
125
+
next: response => {
126
126
+
const notification = response.data.notifications[0];
127
127
+
const lastNotification = this.notifications[0];
128
128
+
129
129
+
if (notification?.indexedAt !== lastNotification?.notification.indexedAt) {
130
130
+
this.initData();
131
131
+
} else {
132
132
+
this.manageRefresh();
133
133
+
}
134
134
+
}
135
135
+
});
136
136
+
} else {
137
137
+
this.reloadReady = true;
138
138
+
}
139
139
+
}, 30e3);
140
140
+
// Timer in seconds
141
141
+
} else if (this.reloadReady && this.feed().nativeElement.scrollTop == 0) {
142
142
+
this.reloadReady = false;
143
143
+
this.initData();
144
144
+
}
145
145
+
}
146
146
+
}
+33
src/app/components/feeds/timeline-feed/timeline-feed.component.html
···
1
1
+
<div
2
2
+
#feed
3
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
4
+
vScroll
5
5
+
(scrollEnding)="nextData(); manageRefresh();"
6
6
+
(scrollTop)="manageRefresh();"
7
7
+
>
8
8
+
@if (posts) {
9
9
+
@for (post of posts; track post.uuid) {
10
10
+
<post-card
11
11
+
[post]="post.post()"
12
12
+
[reply]="post.reply"
13
13
+
[reason]="post.reason"
14
14
+
(postChange)="post.post.set($event)"
15
15
+
class="cursor-pointer hover:bg-primary/2 w-full"
16
16
+
/>
17
17
+
<div
18
18
+
class="border-b border-b-primary/10 w-9/10"
19
19
+
style="mask-image: linear-gradient(to right, transparent, #000000 50%, transparent);"
20
20
+
></div>
21
21
+
}
22
22
+
<!-- } @else {-->
23
23
+
<!-- <div-->
24
24
+
<!-- class="h-full w-full flex items-center justify-center"-->
25
25
+
<!-- >-->
26
26
+
<!-- <p-progress-spinner-->
27
27
+
<!-- class="h-12"-->
28
28
+
<!-- strokeWidth="5"-->
29
29
+
<!-- [style]="{height: '3rem', width: '3rem'}"-->
30
30
+
<!-- />-->
31
31
+
<!-- </div>-->
32
32
+
}
33
33
+
</div>
+161
src/app/components/feeds/timeline-feed/timeline-feed.component.ts
···
1
1
+
import {
2
2
+
ChangeDetectionStrategy,
3
3
+
ChangeDetectorRef,
4
4
+
Component,
5
5
+
ElementRef,
6
6
+
OnDestroy,
7
7
+
OnInit,
8
8
+
viewChild,
9
9
+
} from '@angular/core';
10
10
+
import {CommonModule} from "@angular/common";
11
11
+
import {agent} from '@core/bsky.api';
12
12
+
import {ScrollDirective} from '@shared/directives/scroll.directive';
13
13
+
import {$Typed} from '@atproto/api';
14
14
+
import {ReasonRepost} from '@atproto/api/dist/client/types/app/bsky/feed/defs';
15
15
+
import {PostService} from '@services/post.service';
16
16
+
import {PostUtils} from '@shared/utils/post-utils';
17
17
+
import {SignalizedFeedViewPost} from '@models/signalized-feed-view-post';
18
18
+
import {from} from 'rxjs';
19
19
+
import {PostCardComponent} from '@components/cards/post-card/post-card.component';
20
20
+
21
21
+
@Component({
22
22
+
selector: 'timeline-feed',
23
23
+
imports: [
24
24
+
CommonModule,
25
25
+
ScrollDirective,
26
26
+
PostCardComponent,
27
27
+
],
28
28
+
templateUrl: './timeline-feed.component.html',
29
29
+
changeDetection: ChangeDetectionStrategy.OnPush
30
30
+
})
31
31
+
export class TimelineFeedComponent implements OnInit, OnDestroy {
32
32
+
feed = viewChild<ElementRef>('feed');
33
33
+
34
34
+
posts: SignalizedFeedViewPost[];
35
35
+
cursor: string;
36
36
+
loading = true;
37
37
+
reloadReady = false;
38
38
+
reloadTimeout: ReturnType<typeof setTimeout>;
39
39
+
40
40
+
constructor(
41
41
+
private postService: PostService,
42
42
+
// private dialogService: MskyDialogService,
43
43
+
public cdRef: ChangeDetectorRef
44
44
+
) {}
45
45
+
46
46
+
ngOnInit() {
47
47
+
this.initData();
48
48
+
49
49
+
// Listen to new posts to refresh
50
50
+
this.postService.refreshFeeds.subscribe({
51
51
+
next: () => {
52
52
+
if (this.feed().nativeElement.scrollTop == 0) {
53
53
+
this.initData();
54
54
+
} else {
55
55
+
this.reloadReady = true;
56
56
+
}
57
57
+
}
58
58
+
});
59
59
+
}
60
60
+
61
61
+
ngOnDestroy() {
62
62
+
this.postService.refreshFeeds.unsubscribe();
63
63
+
clearTimeout(this.reloadTimeout);
64
64
+
}
65
65
+
66
66
+
initData() {
67
67
+
this.loading = true;
68
68
+
from(agent.getTimeline({
69
69
+
limit: 15
70
70
+
})).subscribe({
71
71
+
next: response => {
72
72
+
this.cursor = response.data.cursor;
73
73
+
this.posts = response.data.feed.map(fvp => PostUtils.parseFeedViewPost(fvp, this.postService));
74
74
+
this.cdRef.markForCheck();
75
75
+
setTimeout(() => {
76
76
+
this.loading = false;
77
77
+
this.manageRefresh();
78
78
+
}, 500);
79
79
+
//TODO: MessageService
80
80
+
}, error: err => console.log(err.message)
81
81
+
});
82
82
+
}
83
83
+
84
84
+
nextData() {
85
85
+
if (this.loading) return;
86
86
+
this.loading = true;
87
87
+
88
88
+
from(agent.getTimeline({
89
89
+
cursor: this.cursor,
90
90
+
limit: 15
91
91
+
})).subscribe({
92
92
+
next: response => {
93
93
+
this.cursor = response.data.cursor;
94
94
+
const newPosts = response.data.feed.map(fvp => PostUtils.parseFeedViewPost(fvp, this.postService));
95
95
+
this.posts = [...this.posts, ...newPosts];
96
96
+
this.cdRef.markForCheck();
97
97
+
setTimeout(() => {
98
98
+
this.loading = false;
99
99
+
}, 500);
100
100
+
//TODO: MessageService
101
101
+
}, error: err => console.log(err.message)
102
102
+
});
103
103
+
}
104
104
+
105
105
+
openPost(uri: string) {
106
106
+
//TODO: OpenPost
107
107
+
// Mute all video players
108
108
+
// this.feed().nativeElement.querySelectorAll('video').forEach((video: HTMLVideoElement) => {
109
109
+
// video.muted = true;
110
110
+
// });
111
111
+
//
112
112
+
// this.dialogService.openThread(uri, this.feed().nativeElement);
113
113
+
}
114
114
+
115
115
+
manageRefresh() {
116
116
+
if (this.loading) return;
117
117
+
118
118
+
if (!this.reloadReady && !this.reloadTimeout) {
119
119
+
this.reloadTimeout = setTimeout(() => {
120
120
+
this.reloadTimeout = undefined;
121
121
+
122
122
+
if (this.feed().nativeElement.scrollTop == 0) {
123
123
+
this.reloadReady = false;
124
124
+
from(agent.getTimeline({
125
125
+
limit: 1
126
126
+
})).subscribe({
127
127
+
next: response => {
128
128
+
const post = response.data.feed[0];
129
129
+
const lastPost = this.posts[0];
130
130
+
let isNewPost = false;
131
131
+
132
132
+
if (post) {
133
133
+
if (post.reason) {
134
134
+
const reason = post.reason as $Typed<ReasonRepost>;
135
135
+
if (!lastPost.reason) isNewPost = true;
136
136
+
if (reason.indexedAt !== (lastPost.reason as $Typed<ReasonRepost>)?.indexedAt) isNewPost = true;
137
137
+
} else {
138
138
+
if (lastPost.reason) isNewPost = true;
139
139
+
if (post.post.indexedAt !== lastPost.post().indexedAt) isNewPost = true;
140
140
+
}
141
141
+
}
142
142
+
143
143
+
if (isNewPost) {
144
144
+
this.initData();
145
145
+
} else {
146
146
+
this.manageRefresh();
147
147
+
}
148
148
+
//TODO: MessageService
149
149
+
}, error: err => console.log(err.message)
150
150
+
});
151
151
+
} else {
152
152
+
this.reloadReady = true;
153
153
+
}
154
154
+
}, 30e3);
155
155
+
// Timer in seconds
156
156
+
} else if (this.reloadReady && this.feed().nativeElement.scrollTop == 0) {
157
157
+
this.reloadReady = false;
158
158
+
this.initData();
159
159
+
}
160
160
+
}
161
161
+
}
+16
src/app/components/navigation/auxbar/auxbar.component.html
···
1
1
+
<div
2
2
+
class="h-full w-xs flex flex-col"
3
3
+
>
4
4
+
<div
5
5
+
class="flex h-9 w-full shrink-0"
6
6
+
>
7
7
+
<!-- <span-->
8
8
+
<!-- class="bg-primary text-bg text-xl font-bold flex items-center px-3"-->
9
9
+
<!-- >//consolesky.</span>-->
10
10
+
</div>
11
11
+
<div
12
12
+
class="flex flex-col flex-1 border-l border-primary"
13
13
+
>
14
14
+
15
15
+
</div>
16
16
+
</div>
+15
src/app/components/navigation/auxbar/auxbar.component.ts
···
1
1
+
import {ChangeDetectionStrategy, Component} from '@angular/core';
2
2
+
import {PostService} from '@services/post.service';
3
3
+
4
4
+
@Component({
5
5
+
selector: 'auxbar',
6
6
+
imports: [],
7
7
+
templateUrl: './auxbar.component.html',
8
8
+
changeDetection: ChangeDetectionStrategy.OnPush
9
9
+
})
10
10
+
export class AuxbarComponent {
11
11
+
constructor(
12
12
+
protected postService: PostService
13
13
+
) {}
14
14
+
15
15
+
}
+28
src/app/components/navigation/deck/deck.component.html
···
1
1
+
<div
2
2
+
class="flex grow h-full min-w-0 overflow-x-auto ![scrollbar-gutter:auto]"
3
3
+
>
4
4
+
5
5
+
@for (column of columns(); track column.uuid) {
6
6
+
@if (column | isDeckColumnTimeline) {
7
7
+
<timeline-deck-column
8
8
+
[column]="column"
9
9
+
class="flex grow shrink-0 basis-md max-w-md not-first:border-l border-primary"
10
10
+
/>
11
11
+
}
12
12
+
@else if (column | isDeckColumnNotification) {
13
13
+
<notification-deck-column
14
14
+
[column]="column"
15
15
+
class="flex grow shrink-0 basis-md max-w-md not-first:border-l border-primary"
16
16
+
/>
17
17
+
}
18
18
+
@else if (column | isDeckColumnAuthor) {
19
19
+
<author-deck-column
20
20
+
[column]="column"
21
21
+
class="flex grow shrink-0 basis-md max-w-md not-first:border-l border-primary"
22
22
+
/>
23
23
+
}
24
24
+
}
25
25
+
<div
26
26
+
class="flex-1 mt-[calc(2.25rem_-_1px)] border-t border-primary"
27
27
+
></div>
28
28
+
</div>
+37
src/app/components/navigation/deck/deck.component.ts
···
1
1
+
import {ChangeDetectionStrategy, Component, WritableSignal} from '@angular/core';
2
2
+
import {DeckColumn} from '@models/deck-column';
3
3
+
import {ColumnService} from '@services/column.service';
4
4
+
import {IsDeckColumnTimelinePipe} from '@shared/pipes/type-guards/is-deckcolumn-timeline';
5
5
+
import {
6
6
+
TimelineDeckColumnComponent
7
7
+
} from '@components/deck-columns/timeline-deck-column/timeline-deck-column.component';
8
8
+
import {IsDeckColumnNotificationPipe} from '@shared/pipes/type-guards/is-deckcolumn-notifications';
9
9
+
import {
10
10
+
NotificationDeckColumnComponent
11
11
+
} from '@components/deck-columns/notification-deck-column/notification-deck-column.component';
12
12
+
import {IsDeckColumnAuthorPipe} from '@shared/pipes/type-guards/is-deckcolumn-author';
13
13
+
import {AuthorDeckColumnComponent} from '@components/deck-columns/author-deck-column/author-deck-column.component';
14
14
+
15
15
+
@Component({
16
16
+
selector: 'deck',
17
17
+
imports: [
18
18
+
IsDeckColumnTimelinePipe,
19
19
+
TimelineDeckColumnComponent,
20
20
+
IsDeckColumnNotificationPipe,
21
21
+
NotificationDeckColumnComponent,
22
22
+
IsDeckColumnAuthorPipe,
23
23
+
AuthorDeckColumnComponent
24
24
+
],
25
25
+
templateUrl: './deck.component.html',
26
26
+
changeDetection: ChangeDetectionStrategy.OnPush
27
27
+
})
28
28
+
export class DeckComponent {
29
29
+
columns: WritableSignal<Partial<DeckColumn>[]>;
30
30
+
31
31
+
constructor(
32
32
+
// protected postService: PostService,
33
33
+
private columnService: ColumnService,
34
34
+
) {
35
35
+
this.columns = columnService.getColumns();
36
36
+
}
37
37
+
}
+85
src/app/components/navigation/post-composer/post-composer.component.html
···
1
1
+
<div
2
2
+
class="flex w-full border-b border-primary box-border"
3
3
+
>
4
4
+
<div
5
5
+
class="relative flex-1"
6
6
+
>
7
7
+
<div
8
8
+
#text autofocus
9
9
+
contenteditable="plaintext-only"
10
10
+
spellcheck="false"
11
11
+
class="absolute top-0 left-0 z-1 w-full h-full p-2 bg-transparent text-transparent outline-0 caret-black"
12
12
+
(input)="formatText($event)"
13
13
+
(paste)="postService.attachMedia($any($event.clipboardData.files))"
14
14
+
(keydown.control.enter)="postBtn.click()"
15
15
+
[mention]="mentionItems"
16
16
+
[mentionConfig]="{
17
17
+
triggerChar: '@',
18
18
+
labelKey: 'value',
19
19
+
disableSearch: true,
20
20
+
dropUp: true
21
21
+
}"
22
22
+
(searchTerm)="searchMentions($event)"
23
23
+
></div>
24
24
+
<div
25
25
+
[innerHTML]="text.textContent.length ? formattedText : undefined"
26
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
27
+
></div>
28
28
+
</div>
29
29
+
30
30
+
<div
31
31
+
class="flex items-end shrink-0 p-[0.35rem]"
32
32
+
>
33
33
+
<button
34
34
+
class="btn-secondary h-22 w-22 flex flex-col justify-center items-center"
35
35
+
>
36
36
+
<div
37
37
+
class="flex items-center justify-center h-10"
38
38
+
>
39
39
+
<span
40
40
+
class="material-icons !text-6xl"
41
41
+
>format_quote</span>
42
42
+
</div>
43
43
+
44
44
+
Quote
45
45
+
</button>
46
46
+
</div>
47
47
+
48
48
+
<div
49
49
+
class="w-28 flex flex-col shrink-0 justify-end"
50
50
+
>
51
51
+
<div
52
52
+
class="flex h-fit w-full border-primary"
53
53
+
[class.border-t]="text | postComposerHeight"
54
54
+
>
55
55
+
<button
56
56
+
class="btn-secondary h-9 flex-1 border-r-0 border-t-0 p-0 flex items-center justify-center"
57
57
+
>
58
58
+
<span
59
59
+
class="material-icons-outlined"
60
60
+
>mode_comment</span>
61
61
+
</button>
62
62
+
63
63
+
<button
64
64
+
class="btn-secondary h-9 flex-1 border-r-0 border-t-0 p-0 flex items-center justify-center"
65
65
+
>
66
66
+
<span
67
67
+
class="material-icons-outlined"
68
68
+
>mode_comment</span>
69
69
+
</button>
70
70
+
71
71
+
<button
72
72
+
class="btn-secondary h-9 flex-1 border-r-0 border-t-0 p-0 flex items-center justify-center"
73
73
+
>
74
74
+
<span
75
75
+
class="material-icons-outlined"
76
76
+
>mode_comment</span>
77
77
+
</button>
78
78
+
</div>
79
79
+
<button
80
80
+
#postBtn
81
81
+
class="btn-primary h-16 w-full border-r-0 border-b-0"
82
82
+
(click)="publishPost()"
83
83
+
>Post</button>
84
84
+
</div>
85
85
+
</div>
+190
src/app/components/navigation/post-composer/post-composer.component.ts
···
1
1
+
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, signal, WritableSignal} from '@angular/core';
2
2
+
import {$Typed, AppBskyFeedDefs, AppBskyGraphDefs, RichText} from '@atproto/api';
3
3
+
import {ExternalEmbed, ImageEmbed, RecordEmbed} from '@models/embed';
4
4
+
import {EmbedUtils} from '@shared/utils/embed-utils';
5
5
+
import {PostService} from '@services/post.service';
6
6
+
import {EmbedService} from '@services/embed.service';
7
7
+
import {agent} from '@core/bsky.api';
8
8
+
import {from} from 'rxjs';
9
9
+
import {SnippetType} from '@models/snippet';
10
10
+
import {SnippetUtils} from '@shared/utils/snippet-utils';
11
11
+
import {MentionModule} from 'angular-mentions';
12
12
+
import {PostComposerHeightPipe} from '@shared/pipes/post-composer-height.pipe';
13
13
+
14
14
+
@Component({
15
15
+
selector: 'post-composer',
16
16
+
imports: [
17
17
+
MentionModule,
18
18
+
PostComposerHeightPipe
19
19
+
],
20
20
+
templateUrl: './post-composer.component.html',
21
21
+
styles: `
22
22
+
:host(::ng-deep mention-list) {
23
23
+
transform: translateY(-1.25rem);
24
24
+
}
25
25
+
::ng-deep mention-list ul {
26
26
+
margin: 0 !important;
27
27
+
padding: 0 !important;
28
28
+
overflow: unset !important;
29
29
+
border-radius: 0 !important;
30
30
+
border: 1px solid var(--color-primary) !important;
31
31
+
}
32
32
+
::ng-deep mention-list .mention-active a {
33
33
+
background-color: var(--color-primary) !important;
34
34
+
color: var(--color-bg) !important;
35
35
+
}
36
36
+
::ng-deep mention-list .mention-item a {
37
37
+
text-box: trim-both cap alphabetic !important;
38
38
+
padding: 0.75em !important;
39
39
+
}
40
40
+
`,
41
41
+
changeDetection: ChangeDetectionStrategy.OnPush
42
42
+
})
43
43
+
export class PostComposerComponent {
44
44
+
formattedText = '';
45
45
+
rt: RichText;
46
46
+
mentionItems = [];
47
47
+
loading = false;
48
48
+
embedSuggestions = signal<Array<RecordEmbed | ExternalEmbed>>([]);
49
49
+
50
50
+
constructor(
51
51
+
protected postService: PostService,
52
52
+
private embedService: EmbedService,
53
53
+
private cdRef: ChangeDetectorRef
54
54
+
) {}
55
55
+
56
56
+
formatText(event: Event) {
57
57
+
const text = (event.target as HTMLDivElement).innerText;
58
58
+
this.postService.postCompose().post().text = text;
59
59
+
60
60
+
this.rt = new RichText({text: text});
61
61
+
this.rt.detectFacetsWithoutResolution();
62
62
+
const segments = [...this.rt.segments()];
63
63
+
64
64
+
this.embedSuggestions.set(EmbedUtils.findEmbedSuggestions(segments.filter(s => s.facet).map(s => s.text).join(' ')));
65
65
+
66
66
+
let htmlText = '';
67
67
+
68
68
+
segments.forEach(segment => {
69
69
+
if (segment.isMention()) {
70
70
+
htmlText += `<span class="text-blue-800">${segment.text}</span>`;
71
71
+
} else if (segment.isTag()) {
72
72
+
htmlText += `<span class="text-purple-800">${segment.text}</span>`;
73
73
+
} else if (segment.isLink()) {
74
74
+
htmlText += `<span class="text-red-800">${segment.text}</span>`;
75
75
+
} else {
76
76
+
htmlText += `<span>${segment.text.replace('<','<').replace('>','>')}</span>`;
77
77
+
}
78
78
+
});
79
79
+
80
80
+
this.formattedText = htmlText;
81
81
+
this.cdRef.markForCheck();
82
82
+
}
83
83
+
84
84
+
searchMentions(searchTerm: string) {
85
85
+
from(agent.searchActorsTypeahead({q: searchTerm, limit: 5})).subscribe({
86
86
+
next: response => {
87
87
+
this.mentionItems = response.data.actors.map(actor => {
88
88
+
return {
89
89
+
id: actor.did,
90
90
+
value: actor.handle
91
91
+
}
92
92
+
});
93
93
+
this.cdRef.markForCheck();
94
94
+
}
95
95
+
});
96
96
+
}
97
97
+
98
98
+
removeImage(index: number) {
99
99
+
const imageEmbed = this.postService.postCompose().mediaEmbed as WritableSignal<ImageEmbed>;
100
100
+
101
101
+
if (imageEmbed().images.length == 1) {
102
102
+
imageEmbed.set(undefined);
103
103
+
} else {
104
104
+
imageEmbed.update(embed => {
105
105
+
embed.images.splice(index, 1);
106
106
+
return embed;
107
107
+
});
108
108
+
}
109
109
+
}
110
110
+
111
111
+
embedLink() {
112
112
+
const embed = this.embedSuggestions()[0] as ExternalEmbed;
113
113
+
embed.snippet = SnippetUtils.detectSnippet({uri: embed.url, description: ''});
114
114
+
115
115
+
if (embed.snippet.type !== SnippetType.BLUESKY_GIF) {
116
116
+
this.embedService.getUrlMetadata(embed.url).subscribe({
117
117
+
next: metadata => {
118
118
+
embed.metadata = metadata;
119
119
+
this.postService.postCompose().mediaEmbed.set(embed);
120
120
+
},
121
121
+
//TODO: MessageService
122
122
+
error: err => console.log(err.message)
123
123
+
});
124
124
+
}
125
125
+
}
126
126
+
127
127
+
embedQuote() {
128
128
+
const embed = this.embedSuggestions()[0] as RecordEmbed;
129
129
+
agent.resolveHandle({
130
130
+
handle: embed.author
131
131
+
}).then(response => {
132
132
+
this.postService.quotePost('at://' + response.data.did + '/app.bsky.feed.post/' + embed.rkey);
133
133
+
});
134
134
+
}
135
135
+
136
136
+
embedFeed() {
137
137
+
const embed = this.embedSuggestions()[0] as RecordEmbed;
138
138
+
139
139
+
agent.resolveHandle({
140
140
+
handle: embed.author
141
141
+
}).then(response => agent.app.bsky.feed.getFeedGenerator({
142
142
+
feed: 'at://' + response.data.did + '/app.bsky.feed.generator/' + embed.rkey
143
143
+
})).then(response => {
144
144
+
let feed = response.data.view;
145
145
+
feed['$type'] = 'app.bsky.feed.defs#generatorView';
146
146
+
this.postService.postCompose().recordEmbed.set(feed as $Typed<AppBskyFeedDefs.GeneratorView>);
147
147
+
});
148
148
+
}
149
149
+
150
150
+
embedList() {
151
151
+
const embed = this.embedSuggestions()[0] as RecordEmbed;
152
152
+
153
153
+
agent.resolveHandle({
154
154
+
handle: embed.author
155
155
+
}).then(response => agent.app.bsky.graph.getList({
156
156
+
list: 'at://' + response.data.did + '/app.bsky.graph.list/' + embed.rkey
157
157
+
})).then(response => {
158
158
+
let list = response.data.list;
159
159
+
list['$type'] = 'app.bsky.graph.defs#listView';
160
160
+
this.postService.postCompose().recordEmbed.set(list as $Typed<AppBskyGraphDefs.ListView>);
161
161
+
});
162
162
+
}
163
163
+
164
164
+
embedStarterPack() {
165
165
+
const embed = this.embedSuggestions()[0] as RecordEmbed;
166
166
+
167
167
+
agent.resolveHandle({
168
168
+
handle: embed.author
169
169
+
}).then(response => agent.app.bsky.graph.getStarterPack({
170
170
+
starterPack: 'at://' + response.data.did + '/app.bsky.graph.starterpack/' + embed.rkey
171
171
+
})).then(response => {
172
172
+
let starterPack = response.data.starterPack;
173
173
+
starterPack['$type'] = 'app.bsky.graph.defs#starterPackView';
174
174
+
this.postService.postCompose().recordEmbed.set(starterPack as $Typed<AppBskyGraphDefs.StarterPackView>);
175
175
+
});
176
176
+
}
177
177
+
178
178
+
publishPost() {
179
179
+
this.loading = true;
180
180
+
181
181
+
this.postService.publishPost().then(
182
182
+
() => {
183
183
+
//TODO: MessageService
184
184
+
// this.messageService.info('Your post has been successfully published');
185
185
+
},
186
186
+
//TODO: MessageService
187
187
+
err => console.log(err.message)
188
188
+
).finally(() => this.loading = false);
189
189
+
}
190
190
+
}
+28
src/app/components/navigation/sidebar/sidebar.component.html
···
1
1
+
<div
2
2
+
class="h-full w-xs flex flex-col"
3
3
+
>
4
4
+
<div
5
5
+
class="flex h-9 w-full border-b border-primary shrink-0"
6
6
+
>
7
7
+
<span
8
8
+
class="bg-primary text-bg text-xl font-bold flex items-center px-3"
9
9
+
>//consolesky.</span>
10
10
+
</div>
11
11
+
<div
12
12
+
class="flex flex-col flex-1 border-r border-primary"
13
13
+
>
14
14
+
15
15
+
</div>
16
16
+
17
17
+
<button
18
18
+
class="btn-primary relative w-full font-semibold shrink-0 group"
19
19
+
(click)="postService.postCompose() ? postService.postCompose.set(undefined) : postService.createPost()"
20
20
+
>
21
21
+
<span
22
22
+
class="absolute left-2 font-black text-bg group-hover:text-primary"
23
23
+
>
24
24
+
{{ postService.postCompose() ? 'X' : '>_' }}
25
25
+
</span>
26
26
+
{{ postService.postCompose() ? 'Cancel post' : 'Write post' }}
27
27
+
</button>
28
28
+
</div>
+15
src/app/components/navigation/sidebar/sidebar.component.ts
···
1
1
+
import {ChangeDetectionStrategy, Component} from '@angular/core';
2
2
+
import {PostService} from '@services/post.service';
3
3
+
4
4
+
@Component({
5
5
+
selector: 'sidebar',
6
6
+
imports: [],
7
7
+
templateUrl: './sidebar.component.html',
8
8
+
changeDetection: ChangeDetectionStrategy.OnPush
9
9
+
})
10
10
+
export class SidebarComponent {
11
11
+
constructor(
12
12
+
protected postService: PostService
13
13
+
) {}
14
14
+
15
15
+
}
+11
src/app/components/shared/avatar/avatar.component.html
···
1
1
+
@if (src()) {
2
2
+
<img
3
3
+
[src]="src()"
4
4
+
[alt]="alt()"
5
5
+
class="h-full w-full"
6
6
+
/>
7
7
+
} @else {
8
8
+
<div
9
9
+
class="h-full w-full bg-primary/50"
10
10
+
></div>
11
11
+
}
+12
src/app/components/shared/avatar/avatar.component.ts
···
1
1
+
import {ChangeDetectionStrategy, Component, input} from '@angular/core';
2
2
+
3
3
+
@Component({
4
4
+
selector: 'avatar',
5
5
+
imports: [],
6
6
+
templateUrl: './avatar.component.html',
7
7
+
changeDetection: ChangeDetectionStrategy.OnPush
8
8
+
})
9
9
+
export class AvatarComponent {
10
10
+
src = input<string>();
11
11
+
alt = input<string>();
12
12
+
}
+30
src/app/components/shared/rich-text/rich-text.component.html
···
1
1
+
<span
2
2
+
class="whitespace-pre-line block [overflow-wrap:break-word]"
3
3
+
>
4
4
+
@for (segment of segments; track $index) {
5
5
+
@if (segment.isLink()) {
6
6
+
<a
7
7
+
[href]="segment.link?.uri"
8
8
+
target="_blank"
9
9
+
class="hover:underline text-blue-500 visited:text-purple-500 break-all"
10
10
+
(click)="$event.stopPropagation()"
11
11
+
>
12
12
+
{{segment.text}}
13
13
+
</a>
14
14
+
} @else if (segment.isMention()) {
15
15
+
<a
16
16
+
href="https://bsky.app/profile/{{segment.mention?.did}}"
17
17
+
class="hover:underline text-blue-500"
18
18
+
(click)="openAuthor($event, segment.mention?.did)"
19
19
+
>{{segment.text}}</a>
20
20
+
} @else if (segment.isTag()) {
21
21
+
<a
22
22
+
href="https://bsky.app/hashtag/{{segment.tag?.tag}}"
23
23
+
class="hover:underline text-blue-500 visited:text-purple-500"
24
24
+
(click)="$event.stopPropagation(); $event.preventDefault()"
25
25
+
>{{segment.text}}</a>
26
26
+
} @else {
27
27
+
<span class="break-words">{{segment.text}}</span>
28
28
+
}
29
29
+
}
30
30
+
</span>
+55
src/app/components/shared/rich-text/rich-text.component.ts
···
1
1
+
import {
2
2
+
ChangeDetectionStrategy,
3
3
+
ChangeDetectorRef,
4
4
+
Component,
5
5
+
EventEmitter,
6
6
+
Input,
7
7
+
OnInit,
8
8
+
Output,
9
9
+
} from '@angular/core';
10
10
+
import {Facet, RichText, RichTextSegment} from "@atproto/api";
11
11
+
// import {MskyDialogService} from '@services/msky-dialog.service';
12
12
+
import {agent} from '@core/bsky.api';
13
13
+
14
14
+
@Component({
15
15
+
selector: 'rich-text',
16
16
+
templateUrl: './rich-text.component.html',
17
17
+
changeDetection: ChangeDetectionStrategy.OnPush
18
18
+
})
19
19
+
export class RichTextComponent implements OnInit {
20
20
+
@Input() text: string;
21
21
+
@Input() facets: Facet[];
22
22
+
@Output() onMentionClick: EventEmitter<any>
23
23
+
@Output() onTagClick: EventEmitter<any>
24
24
+
segments: RichTextSegment[] = [];
25
25
+
26
26
+
constructor(
27
27
+
private cdRef: ChangeDetectorRef,
28
28
+
// private dialogService: MskyDialogService
29
29
+
) {}
30
30
+
31
31
+
ngOnInit() {
32
32
+
const rt = new RichText(
33
33
+
{
34
34
+
text: this.text,
35
35
+
facets: this.facets
36
36
+
}
37
37
+
);
38
38
+
39
39
+
if (!this.facets) {
40
40
+
rt.detectFacets(agent).then(() => {
41
41
+
this.segments = [...rt.segments()];
42
42
+
this.cdRef.markForCheck();
43
43
+
});
44
44
+
} else {
45
45
+
this.segments = [...rt.segments()];
46
46
+
}
47
47
+
}
48
48
+
49
49
+
openAuthor(event: MouseEvent, did: string) {
50
50
+
event.preventDefault();
51
51
+
event.stopPropagation();
52
52
+
53
53
+
//TODO: OpenAuthor
54
54
+
}
55
55
+
}
+21
src/app/core/auth/auth.guard.ts
···
1
1
+
import {Injectable} from '@angular/core';
2
2
+
import {CanActivate, Router} from '@angular/router';
3
3
+
import {AuthService} from './auth.service';
4
4
+
5
5
+
@Injectable({
6
6
+
providedIn: 'root'
7
7
+
})
8
8
+
export class AuthGuard implements CanActivate {
9
9
+
10
10
+
constructor(private auth: AuthService,
11
11
+
private router: Router) {}
12
12
+
13
13
+
canActivate(): boolean {
14
14
+
if (this.auth.isAuthenticated()) {
15
15
+
return true;
16
16
+
} else {
17
17
+
this.router.navigate(['login']);
18
18
+
return false;
19
19
+
}
20
20
+
}
21
21
+
}
+83
src/app/core/auth/auth.service.ts
···
1
1
+
import {Injectable, signal} from '@angular/core';
2
2
+
import {BehaviorSubject, from} from 'rxjs';
3
3
+
import {AppBskyActorDefs, AtpAgentLoginOpts} from "@atproto/api";
4
4
+
import {Router} from "@angular/router";
5
5
+
import {HttpErrorResponse} from "@angular/common/http";
6
6
+
import {StorageKeys} from '@core/storage-keys';
7
7
+
import {agent} from '@core/bsky.api';
8
8
+
import {StorageService} from '@services/storage.service';
9
9
+
// import {MskyMessageService} from '@services/msky-message.service';
10
10
+
import {ColumnService} from '@services/column.service';
11
11
+
12
12
+
@Injectable({
13
13
+
providedIn: 'root',
14
14
+
})
15
15
+
export class AuthService {
16
16
+
authenticationState = new BehaviorSubject(false);
17
17
+
loggedUser = signal<AppBskyActorDefs.ProfileViewDetailed>(undefined);
18
18
+
19
19
+
constructor(
20
20
+
private router: Router,
21
21
+
private storageService: StorageService,
22
22
+
// private messageService: MskyMessageService,
23
23
+
private columnService: ColumnService
24
24
+
) {
25
25
+
this.checkToken();
26
26
+
}
27
27
+
28
28
+
checkToken() {
29
29
+
const sessionData = this.storageService.get(StorageKeys.TOKEN_KEY);
30
30
+
if (sessionData) {
31
31
+
agent.resumeSession(JSON.parse(sessionData)).then(
32
32
+
() => {
33
33
+
this.storageService.set(StorageKeys.TOKEN_KEY, JSON.stringify(agent.session));
34
34
+
35
35
+
agent.getProfile({actor: agent.session.did}).then(response => {
36
36
+
this.loggedUser.set(response.data);
37
37
+
this.columnService.checkColumns();
38
38
+
39
39
+
this.authenticationState.next(true);
40
40
+
});
41
41
+
}
42
42
+
);
43
43
+
}
44
44
+
}
45
45
+
46
46
+
login(credentials: AtpAgentLoginOpts) {
47
47
+
from(agent.login(credentials)).subscribe({
48
48
+
next: () => {
49
49
+
this.storageService.set(StorageKeys.TOKEN_KEY, JSON.stringify(agent.session));
50
50
+
51
51
+
from(agent.getProfile({
52
52
+
actor: agent.session.did
53
53
+
})).subscribe({
54
54
+
next: response => {
55
55
+
this.loggedUser.set(response.data);
56
56
+
this.columnService.checkColumns();
57
57
+
58
58
+
this.authenticationState.next(true);
59
59
+
this.router.navigate(['']);
60
60
+
//TODO: MessageService
61
61
+
}, error: err => console.log(err.message)
62
62
+
});
63
63
+
},
64
64
+
error: (err: HttpErrorResponse) => {
65
65
+
//TODO: MessageService
66
66
+
// this.messageService.warn(err.message, 'Oops!');
67
67
+
}
68
68
+
});
69
69
+
}
70
70
+
71
71
+
logout() {
72
72
+
agent.logout().then(
73
73
+
() => {
74
74
+
this.storageService.remove(StorageKeys.TOKEN_KEY);
75
75
+
this.authenticationState.next(false);
76
76
+
}
77
77
+
);
78
78
+
}
79
79
+
80
80
+
isAuthenticated() {
81
81
+
return this.authenticationState.value;
82
82
+
}
83
83
+
}
+5
src/app/core/bsky.api.ts
···
1
1
+
import {AtpAgent} from "@atproto/api";
2
2
+
3
3
+
export const agent = new AtpAgent({
4
4
+
service: 'https://bsky.social'
5
5
+
});
+5
src/app/core/storage-keys.ts
···
1
1
+
export enum StorageKeys {
2
2
+
TOKEN_KEY = 'session',
3
3
+
LOGGED_USER = 'logged_user',
4
4
+
DECK_COLUMNS = 'columns'
5
5
+
}
+57
src/app/models/deck-column.ts
···
1
1
+
import * as uuid from "uuid";
2
2
+
3
3
+
export class DeckColumn {
4
4
+
uuid: string = uuid.v4();
5
5
+
title: string = '';
6
6
+
width: number = 400;
7
7
+
index: number;
8
8
+
}
9
9
+
10
10
+
export class TimelineDeckColumn extends DeckColumn {
11
11
+
type: DeckColumnType.TIMELINE = DeckColumnType.TIMELINE;
12
12
+
}
13
13
+
14
14
+
export class NotificationDeckColumn extends DeckColumn {
15
15
+
type: DeckColumnType.NOTIFICATION = DeckColumnType.NOTIFICATION;
16
16
+
}
17
17
+
18
18
+
export class AuthorDeckColumn extends DeckColumn {
19
19
+
type: DeckColumnType.AUTHOR = DeckColumnType.AUTHOR;
20
20
+
did: string;
21
21
+
handle: string;
22
22
+
displayName: string;
23
23
+
mode: AuthorDeckColumnMode = AuthorDeckColumnMode.POSTS;
24
24
+
}
25
25
+
26
26
+
export class ListDeckColumn extends DeckColumn {
27
27
+
type: DeckColumnType.LIST = DeckColumnType.LIST;
28
28
+
did: string;
29
29
+
}
30
30
+
31
31
+
export class GeneratorDeckColumn extends DeckColumn {
32
32
+
type: DeckColumnType.GENERATOR = DeckColumnType.GENERATOR;
33
33
+
uri: string;
34
34
+
avatar: string;
35
35
+
}
36
36
+
37
37
+
export class SearchDeckColumn extends DeckColumn {
38
38
+
type: DeckColumnType.SEARCH = DeckColumnType.SEARCH;
39
39
+
query: string;
40
40
+
sort: 'top' | 'latest';
41
41
+
}
42
42
+
43
43
+
export enum DeckColumnType {
44
44
+
TIMELINE = 'TIMELINE',
45
45
+
NOTIFICATION = 'NOTIFICATION',
46
46
+
AUTHOR = 'AUTHOR',
47
47
+
LIST = 'LIST',
48
48
+
GENERATOR = 'GENERATOR',
49
49
+
SEARCH = 'SEARCH'
50
50
+
}
51
51
+
52
52
+
export enum AuthorDeckColumnMode {
53
53
+
POSTS = 'posts_no_replies',
54
54
+
REPLIES = 'posts_with_replies',
55
55
+
MEDIA = 'posts_with_media',
56
56
+
VIDEO = 'posts_with_video'
57
57
+
}
+67
src/app/models/embed.ts
···
1
1
+
import {BlueskyGifSnippet, IframeSnippet, LinkSnippet} from '@models/snippet';
2
2
+
import {UrlMetadata} from '@models/url-metadata';
3
3
+
4
4
+
5
5
+
export const enum EmbedType {
6
6
+
IMAGE = 'IMAGE',
7
7
+
VIDEO = 'VIDEO',
8
8
+
EXTERNAL = 'EXTERNAL',
9
9
+
RECORD = 'RECORD',
10
10
+
}
11
11
+
export const enum RecordEmbedType {
12
12
+
POST = 'POST',
13
13
+
FEED = 'FEED',
14
14
+
LIST = 'LIST',
15
15
+
STARTER_PACK = 'STARTER_PACK',
16
16
+
}
17
17
+
18
18
+
interface Embed {
19
19
+
type: EmbedType.IMAGE | EmbedType.VIDEO | EmbedType.EXTERNAL | EmbedType.RECORD;
20
20
+
}
21
21
+
22
22
+
export class ImageEmbed implements Embed {
23
23
+
type: EmbedType.IMAGE = EmbedType.IMAGE;
24
24
+
/** Image array */
25
25
+
images: {data: string, alt: string}[] = [];
26
26
+
}
27
27
+
28
28
+
export class VideoEmbed implements Embed {
29
29
+
type: EmbedType.VIDEO = EmbedType.VIDEO;
30
30
+
/** Video file */
31
31
+
file: File;
32
32
+
thumbnail: string;
33
33
+
34
34
+
constructor(file: File, thumbnail: string) {
35
35
+
this.file = file;
36
36
+
this.thumbnail = thumbnail;
37
37
+
}
38
38
+
}
39
39
+
40
40
+
export class ExternalEmbed implements Embed {
41
41
+
type: EmbedType.EXTERNAL = EmbedType.EXTERNAL;
42
42
+
/** Url */
43
43
+
url: string;
44
44
+
/** Extended info */
45
45
+
metadata: UrlMetadata;
46
46
+
/** Extended info */
47
47
+
snippet: LinkSnippet | BlueskyGifSnippet | IframeSnippet;
48
48
+
49
49
+
constructor(url: string) {
50
50
+
this.url = url;
51
51
+
}
52
52
+
}
53
53
+
54
54
+
export class RecordEmbed implements Embed {
55
55
+
type: EmbedType.RECORD = EmbedType.RECORD;
56
56
+
/** Record type */
57
57
+
recordType: RecordEmbedType;
58
58
+
/** Record info */
59
59
+
author: string;
60
60
+
rkey: string;
61
61
+
62
62
+
constructor(recordType: RecordEmbedType, author: string, rkey: string) {
63
63
+
this.recordType = recordType;
64
64
+
this.author = author;
65
65
+
this.rkey = rkey;
66
66
+
}
67
67
+
}
+18
src/app/models/notification.ts
···
1
1
+
import {AppBskyActorDefs, AppBskyFeedDefs, AppBskyNotificationListNotifications} from "@atproto/api";
2
2
+
import * as uuid from "uuid";
3
3
+
import {WritableSignal} from '@angular/core';
4
4
+
5
5
+
export class Notification {
6
6
+
/** Notification list object */
7
7
+
notification: AppBskyNotificationListNotifications.Notification;
8
8
+
/** Notification reason */
9
9
+
reason: "like" | "repost" | "follow" | "mention" | "reply" | "quote" | "starterpack-joined" | string;
10
10
+
/** Authors' profile */
11
11
+
authors: AppBskyActorDefs.ProfileView[] = [];
12
12
+
/** Record URI */
13
13
+
uri?: string;
14
14
+
/** Record */
15
15
+
post?: WritableSignal<AppBskyFeedDefs.PostView>;
16
16
+
/** Uuid */
17
17
+
uuid: string = uuid.v4();
18
18
+
}
+23
src/app/models/post-compose.ts
···
1
1
+
import {signal, WritableSignal} from "@angular/core";
2
2
+
import {$Typed, AppBskyEmbedRecord, AppBskyFeedDefs, AppBskyFeedPost, AppBskyGraphDefs} from "@atproto/api";
3
3
+
import {ExternalEmbed, ImageEmbed, VideoEmbed} from "@models/embed";
4
4
+
5
5
+
type Record = AppBskyFeedPost.Record;
6
6
+
7
7
+
export class PostCompose {
8
8
+
post: WritableSignal<AppBskyFeedPost.Record> = signal(undefined);
9
9
+
reply: WritableSignal<AppBskyFeedDefs.PostView> = signal(undefined);
10
10
+
recordEmbed: WritableSignal<$Typed<AppBskyEmbedRecord.ViewRecord> | $Typed<AppBskyFeedDefs.GeneratorView> | $Typed<AppBskyGraphDefs.ListView> | $Typed<AppBskyGraphDefs.StarterPackView>> = signal(undefined);
11
11
+
mediaEmbed: WritableSignal<ImageEmbed | VideoEmbed | ExternalEmbed> = signal(undefined);
12
12
+
13
13
+
constructor() {
14
14
+
this.post.set({
15
15
+
$type: 'app.bsky.feed.post',
16
16
+
text: '',
17
17
+
facets: [],
18
18
+
createdAt: '',
19
19
+
langs: [],
20
20
+
tags: [],
21
21
+
} as Record);
22
22
+
}
23
23
+
}
+12
src/app/models/signalized-feed-view-post.ts
···
1
1
+
import {AppBskyFeedDefs} from "@atproto/api";
2
2
+
import {WritableSignal} from "@angular/core";
3
3
+
import * as uuid from "uuid";
4
4
+
5
5
+
export class SignalizedFeedViewPost {
6
6
+
[k: string]: unknown;
7
7
+
post: WritableSignal<AppBskyFeedDefs.PostView>;
8
8
+
reply?: AppBskyFeedDefs.ReplyRef | undefined;
9
9
+
reason?: AppBskyFeedDefs.ReasonRepost | AppBskyFeedDefs.ReasonPin | { [k: string]: unknown; $type: string; } | undefined;
10
10
+
feedContext?: string | undefined;
11
11
+
uuid: string = uuid.v4();
12
12
+
}
+72
src/app/models/snippet.ts
···
1
1
+
export const enum SnippetType {
2
2
+
LINK= 'LINK',
3
3
+
BLUESKY_GIF = 'BLUESKY_GIF',
4
4
+
IFRAME = 'IFRAME',
5
5
+
}
6
6
+
7
7
+
export const enum SnippetSource {
8
8
+
YOUTUBE = 'YOUTUBE',
9
9
+
SOUNDCLOUD = 'SOUNDCLOUD',
10
10
+
}
11
11
+
12
12
+
interface Snippet {
13
13
+
type: SnippetType.LINK | SnippetType.BLUESKY_GIF | SnippetType.IFRAME;
14
14
+
/** Domain name */
15
15
+
domain: string;
16
16
+
/** URL */
17
17
+
url: string;
18
18
+
}
19
19
+
20
20
+
export class LinkSnippet implements Snippet {
21
21
+
type: SnippetType.LINK = SnippetType.LINK;
22
22
+
/** Domain name */
23
23
+
domain: string;
24
24
+
/** URL */
25
25
+
url: string;
26
26
+
27
27
+
constructor(domain: string, url: string) {
28
28
+
this.domain = domain;
29
29
+
this.url = url;
30
30
+
}
31
31
+
}
32
32
+
33
33
+
export class BlueskyGifSnippet implements Snippet {
34
34
+
type: SnippetType.BLUESKY_GIF = SnippetType.BLUESKY_GIF;
35
35
+
/** Domain name */
36
36
+
domain: string;
37
37
+
/** Video URL */
38
38
+
url: string;
39
39
+
/** Aspect ratio */
40
40
+
ratio: string;
41
41
+
/** Alt text description */
42
42
+
description: string;
43
43
+
44
44
+
constructor(domain: string, url: string, ratio: string, description: string) {
45
45
+
this.domain = domain;
46
46
+
this.url = url;
47
47
+
this.ratio = ratio;
48
48
+
this.description = description;
49
49
+
}
50
50
+
}
51
51
+
52
52
+
export class IframeSnippet implements Snippet {
53
53
+
type: SnippetType.IFRAME = SnippetType.IFRAME;
54
54
+
/** Source domain */
55
55
+
domain: string;
56
56
+
/** Iframe URL or Youtube ID */
57
57
+
url: string;
58
58
+
/** Aspect ratio */
59
59
+
ratio: string;
60
60
+
/** Source type */
61
61
+
source: SnippetSource;
62
62
+
/** The second is supposed to start at in a Youtube video */
63
63
+
seek: number;
64
64
+
65
65
+
constructor(domain: string, url: string, ratio: string, source: SnippetSource, seek?: number) {
66
66
+
this.domain = domain;
67
67
+
this.url = url;
68
68
+
this.ratio = ratio;
69
69
+
this.source = source;
70
70
+
this.seek = seek;
71
71
+
}
72
72
+
}
+14
src/app/models/thread-reply.ts
···
1
1
+
import {SignalizedFeedViewPost} from "@models/signalized-feed-view-post";
2
2
+
import * as uuid from "uuid";
3
3
+
import {AppBskyFeedDefs} from "@atproto/api";
4
4
+
5
5
+
export class ThreadReply {
6
6
+
post: SignalizedFeedViewPost;
7
7
+
replies: Array<ThreadReply | AppBskyFeedDefs.NotFoundPost | AppBskyFeedDefs.BlockedPost>;
8
8
+
/** Uuid */
9
9
+
uuid: string = uuid.v4();
10
10
+
11
11
+
constructor(post: SignalizedFeedViewPost) {
12
12
+
this.post = post;
13
13
+
}
14
14
+
}
+8
src/app/models/url-metadata.ts
···
1
1
+
export class UrlMetadata {
2
2
+
url: string;
3
3
+
title: string;
4
4
+
description: string;
5
5
+
imageUrl: string;
6
6
+
likelyType: string;
7
7
+
error: string;
8
8
+
}
+111
src/app/services/column.service.ts
···
1
1
+
import {StorageKeys} from "@core/storage-keys";
2
2
+
import {
3
3
+
AuthorDeckColumn,
4
4
+
DeckColumn,
5
5
+
GeneratorDeckColumn,
6
6
+
NotificationDeckColumn,
7
7
+
SearchDeckColumn,
8
8
+
TimelineDeckColumn
9
9
+
} from "@models/deck-column";
10
10
+
import {Injectable, signal, WritableSignal} from "@angular/core";
11
11
+
import * as uuid from "uuid";
12
12
+
13
13
+
const columns: WritableSignal<Partial<DeckColumn>[]> = signal([]);
14
14
+
15
15
+
@Injectable({
16
16
+
providedIn: 'root'
17
17
+
})
18
18
+
export class ColumnService {
19
19
+
public addColumn(column: Partial<DeckColumn>) {
20
20
+
columns.update(columns => [...columns, column]);
21
21
+
this.saveColumns();
22
22
+
}
23
23
+
24
24
+
public updateColumn(column: Partial<DeckColumn>) {
25
25
+
columns.update(columns => {
26
26
+
columns[columns.findIndex(col => col.uuid == column.uuid)] = column;
27
27
+
return columns;
28
28
+
});
29
29
+
this.saveColumns();
30
30
+
}
31
31
+
32
32
+
public deleteColumn(uuid: string) {
33
33
+
columns.update(columns => {
34
34
+
columns.splice(columns.findIndex(col => col.uuid == uuid), 1);
35
35
+
columns.forEach((column, index) => column.index = index);
36
36
+
return columns;
37
37
+
});
38
38
+
this.saveColumns();
39
39
+
}
40
40
+
41
41
+
public getColumns(): WritableSignal<Partial<DeckColumn>[]> {
42
42
+
return columns;
43
43
+
}
44
44
+
45
45
+
public saveColumns() {
46
46
+
localStorage.setItem(StorageKeys.DECK_COLUMNS, JSON.stringify(columns()));
47
47
+
}
48
48
+
49
49
+
public checkColumns() {
50
50
+
let storageColumns: Partial<DeckColumn>[] = JSON.parse(localStorage.getItem(StorageKeys.DECK_COLUMNS));
51
51
+
52
52
+
if (!storageColumns || !storageColumns.length) {
53
53
+
this.initColumns();
54
54
+
return;
55
55
+
}
56
56
+
57
57
+
storageColumns.forEach((column: any) => {
58
58
+
if (!column.width) column.width = 400;
59
59
+
if (!column.uuid) column.uuid = uuid.v4();
60
60
+
});
61
61
+
62
62
+
localStorage.setItem(StorageKeys.DECK_COLUMNS, JSON.stringify(storageColumns));
63
63
+
columns.set(storageColumns);
64
64
+
}
65
65
+
66
66
+
public initColumns() {
67
67
+
this.createTimelineColumn();
68
68
+
this.createNotificationsColumn();
69
69
+
this.createAuthorColumn(JSON.parse(localStorage.getItem(StorageKeys.LOGGED_USER)));
70
70
+
}
71
71
+
72
72
+
public createTimelineColumn() {
73
73
+
let column = new TimelineDeckColumn();
74
74
+
column.title = 'Home';
75
75
+
column.index = columns().length;
76
76
+
this.addColumn(column);
77
77
+
}
78
78
+
79
79
+
public createNotificationsColumn() {
80
80
+
let column = new NotificationDeckColumn();
81
81
+
column.title = 'Notifications';
82
82
+
column.index = columns().length;
83
83
+
this.addColumn(column);
84
84
+
}
85
85
+
86
86
+
public createAuthorColumn(author: Partial<{did: string, handle: string, displayName: string}>) {
87
87
+
let column = new AuthorDeckColumn();
88
88
+
column.did = author.did;
89
89
+
column.handle = author.handle;
90
90
+
column.displayName = author.displayName;
91
91
+
column.index = columns().length;
92
92
+
this.addColumn(column);
93
93
+
}
94
94
+
95
95
+
public createGeneratorColumn(generator: Partial<{uri: string, avatar: string, displayName: string}>) {
96
96
+
let column = new GeneratorDeckColumn();
97
97
+
column.title = generator.displayName;
98
98
+
column.uri = generator.uri;
99
99
+
column.avatar = generator.avatar;
100
100
+
column.index = columns().length;
101
101
+
this.addColumn(column);
102
102
+
}
103
103
+
104
104
+
public createSearchColumn(query: string, sort: 'top' | 'latest') {
105
105
+
let column = new SearchDeckColumn();
106
106
+
column.query = query;
107
107
+
column.sort = sort;
108
108
+
column.index = columns().length;
109
109
+
this.addColumn(column);
110
110
+
}
111
111
+
}
+33
src/app/services/embed.service.ts
···
1
1
+
import {Injectable} from '@angular/core';
2
2
+
import {HttpClient} from "@angular/common/http";
3
3
+
import {map, Observable} from "rxjs";
4
4
+
import {UrlMetadata} from "@models/url-metadata";
5
5
+
6
6
+
@Injectable({
7
7
+
providedIn: 'root'
8
8
+
})
9
9
+
export class EmbedService {
10
10
+
constructor(
11
11
+
private httpClient: HttpClient
12
12
+
) {}
13
13
+
14
14
+
getUrlMetadata(url: string): Observable<UrlMetadata> {
15
15
+
if (!url.startsWith('https://') && !url.startsWith('http://')) {
16
16
+
url = 'https://' + url;
17
17
+
}
18
18
+
19
19
+
return this.httpClient.get(`https://cardyb.bsky.app/v1/extract?url=${url}`)
20
20
+
.pipe(
21
21
+
map((res: any) => {
22
22
+
const metadata = new UrlMetadata();
23
23
+
metadata.url = res.url;
24
24
+
metadata.title = res.title;
25
25
+
metadata.description = res.description;
26
26
+
metadata.imageUrl = res.image;
27
27
+
metadata.likelyType = res.likely_type;
28
28
+
metadata.error = res.error;
29
29
+
return metadata;
30
30
+
})
31
31
+
);
32
32
+
}
33
33
+
}
+440
src/app/services/post.service.ts
···
1
1
+
import {Injectable, signal, WritableSignal} from "@angular/core";
2
2
+
import {
3
3
+
$Typed,
4
4
+
AppBskyEmbedExternal,
5
5
+
AppBskyEmbedImages,
6
6
+
AppBskyEmbedRecord,
7
7
+
AppBskyEmbedRecordWithMedia,
8
8
+
AppBskyEmbedVideo,
9
9
+
AppBskyFeedDefs,
10
10
+
RichText
11
11
+
} from "@atproto/api";
12
12
+
import {from, Subject} from "rxjs";
13
13
+
import {EmbedType, ExternalEmbed, ImageEmbed, VideoEmbed} from "@models/embed";
14
14
+
import {DOC_ORIENTATION, NgxImageCompressService} from "ngx-image-compress";
15
15
+
import {HttpErrorResponse} from "@angular/common/http";
16
16
+
import {PostCompose} from '@models/post-compose';
17
17
+
import {agent} from '@core/bsky.api';
18
18
+
19
19
+
export const posts: Map<string, WritableSignal<AppBskyFeedDefs.PostView>> =
20
20
+
new Map<string, WritableSignal<AppBskyFeedDefs.PostView>>();
21
21
+
22
22
+
@Injectable({
23
23
+
providedIn: 'root'
24
24
+
})
25
25
+
export class PostService {
26
26
+
public postCompose: WritableSignal<PostCompose> = signal(undefined);
27
27
+
public refreshFeeds: Subject<void> = new Subject<void>();
28
28
+
29
29
+
constructor(
30
30
+
// private dialogService: MskyDialogService,
31
31
+
private imageCompressService: NgxImageCompressService
32
32
+
) {}
33
33
+
34
34
+
setPost(post: AppBskyFeedDefs.PostView): WritableSignal<AppBskyFeedDefs.PostView> {
35
35
+
const existingPost = posts.get(post.uri);
36
36
+
if (existingPost) {
37
37
+
existingPost.set(post);
38
38
+
return existingPost;
39
39
+
} else {
40
40
+
const newPost = signal(post);
41
41
+
posts.set(post.uri, newPost);
42
42
+
return newPost;
43
43
+
}
44
44
+
}
45
45
+
46
46
+
getPost(uri: string): WritableSignal<AppBskyFeedDefs.PostView> | undefined {
47
47
+
return posts.get(uri);
48
48
+
}
49
49
+
50
50
+
createPost() {
51
51
+
if (this.postCompose()) return;
52
52
+
53
53
+
this.postCompose.set(new PostCompose());
54
54
+
// this.dialogService.openPostComposer(this.postCompose);
55
55
+
}
56
56
+
57
57
+
like(post: WritableSignal<AppBskyFeedDefs.PostView>): Promise<void> {
58
58
+
return new Promise<void>((resolve, reject) => {
59
59
+
// Update UI
60
60
+
post.update(post => {
61
61
+
post.viewer.like = 'placeholder';
62
62
+
return post;
63
63
+
});
64
64
+
65
65
+
// API call (delayed to not step over placeholder change)
66
66
+
from(agent.like(post().uri, post().cid)).subscribe({
67
67
+
next: () => {
68
68
+
setTimeout(() => {
69
69
+
from(agent.getPosts({
70
70
+
uris: [post().uri]
71
71
+
})).subscribe({
72
72
+
next: response => {
73
73
+
post.set(response.data.posts[0]);
74
74
+
resolve();
75
75
+
},
76
76
+
error: err => reject(err)
77
77
+
});
78
78
+
}, 100);
79
79
+
},
80
80
+
error: err => {
81
81
+
post.update(post => {
82
82
+
post.viewer.like = undefined;
83
83
+
return post;
84
84
+
});
85
85
+
reject(err);
86
86
+
}
87
87
+
});
88
88
+
});
89
89
+
}
90
90
+
91
91
+
deleteLike(post: WritableSignal<AppBskyFeedDefs.PostView>): Promise<void> {
92
92
+
return new Promise<void>((resolve, reject) => {
93
93
+
// Update UI
94
94
+
const likeRef = post().viewer.like;
95
95
+
post.update(post => {
96
96
+
post.viewer.like = undefined;
97
97
+
return post;
98
98
+
});
99
99
+
100
100
+
// API call (delayed to not step over placeholder change)
101
101
+
from(agent.deleteLike(likeRef)).subscribe({
102
102
+
next: () => {
103
103
+
setTimeout(() => {
104
104
+
from(agent.getPosts({
105
105
+
uris: [post().uri]
106
106
+
})).subscribe({
107
107
+
next: response => {
108
108
+
post.set(response.data.posts[0]);
109
109
+
resolve();
110
110
+
},
111
111
+
error: err => reject(err)
112
112
+
});
113
113
+
}, 200);
114
114
+
},
115
115
+
error: err => {
116
116
+
post.update(post => {
117
117
+
post.viewer.like = likeRef;
118
118
+
return post;
119
119
+
});
120
120
+
reject(err);
121
121
+
}
122
122
+
});
123
123
+
});
124
124
+
}
125
125
+
126
126
+
repost(post: WritableSignal<AppBskyFeedDefs.PostView>): Promise<void> {
127
127
+
return new Promise<void>((resolve, reject) => {
128
128
+
// Update UI
129
129
+
post.update(post => {
130
130
+
post.viewer.repost = 'placeholder';
131
131
+
return post;
132
132
+
});
133
133
+
134
134
+
// API call (delayed to not step over placeholder change)
135
135
+
from(agent.repost(post().uri, post().cid)).subscribe({
136
136
+
next: () => {
137
137
+
setTimeout(() => {
138
138
+
from(agent.getPosts({
139
139
+
uris: [post().uri]
140
140
+
})).subscribe({
141
141
+
next: response => {
142
142
+
post.set(response.data.posts[0]);
143
143
+
resolve();
144
144
+
},
145
145
+
error: err => reject(err)
146
146
+
});
147
147
+
}, 100);
148
148
+
},
149
149
+
error: err => {
150
150
+
post.update(post => {
151
151
+
post.viewer.repost = undefined;
152
152
+
return post;
153
153
+
});
154
154
+
reject(err);
155
155
+
}
156
156
+
});
157
157
+
});
158
158
+
}
159
159
+
160
160
+
deleteRepost(post: WritableSignal<AppBskyFeedDefs.PostView>): Promise<void> {
161
161
+
return new Promise<void>((resolve, reject) => {
162
162
+
// Update UI
163
163
+
const rtRef = post().viewer.repost;
164
164
+
post.update(post => {
165
165
+
post.viewer.repost = undefined;
166
166
+
return post;
167
167
+
});
168
168
+
169
169
+
// API call (delayed to not step over placeholder change)
170
170
+
from(agent.deleteRepost(rtRef)).subscribe({
171
171
+
next: () => {
172
172
+
setTimeout(() => {
173
173
+
from(agent.getPosts({
174
174
+
uris: [post().uri]
175
175
+
})).subscribe({
176
176
+
next: response => {
177
177
+
post.set(response.data.posts[0]);
178
178
+
resolve();
179
179
+
},
180
180
+
error: err => reject(err)
181
181
+
});
182
182
+
}, 200);
183
183
+
},
184
184
+
error: err => {
185
185
+
post.update(post => {
186
186
+
post.viewer.repost = rtRef;
187
187
+
return post;
188
188
+
});
189
189
+
reject(err);
190
190
+
}
191
191
+
});
192
192
+
});
193
193
+
}
194
194
+
195
195
+
refreshRepost(post: WritableSignal<AppBskyFeedDefs.PostView>): Promise<void> {
196
196
+
return new Promise<void>((resolve, reject) => {
197
197
+
from(agent.deleteRepost(post().viewer.repost)).subscribe({
198
198
+
next: () => this.repost(post).then(() => resolve()).catch(err => reject(err)),
199
199
+
error: err => reject(err)
200
200
+
});
201
201
+
});
202
202
+
}
203
203
+
204
204
+
replyPost(uri: string) {
205
205
+
if (!this.postCompose()) this.createPost();
206
206
+
207
207
+
agent.getPostThread({
208
208
+
uri: uri
209
209
+
}).then(response => {
210
210
+
if (!AppBskyFeedDefs.isThreadViewPost(response.data.thread)) return;
211
211
+
this.setPost(response.data.thread.post as AppBskyFeedDefs.PostView);
212
212
+
213
213
+
let root;
214
214
+
if (AppBskyFeedDefs.isThreadViewPost(response.data.thread.parent)) {
215
215
+
root = response.data.thread.parent;
216
216
+
217
217
+
while (AppBskyFeedDefs.isThreadViewPost(root.parent)) {
218
218
+
root = root.parent;
219
219
+
}
220
220
+
221
221
+
root = root.post;
222
222
+
} else {
223
223
+
root = response.data.thread.post;
224
224
+
}
225
225
+
226
226
+
let replyRef = {
227
227
+
parent: {
228
228
+
uri: (response.data.thread.post).uri,
229
229
+
cid: (response.data.thread.post).cid
230
230
+
},
231
231
+
root: {
232
232
+
uri: root.uri,
233
233
+
cid: root.cid
234
234
+
},
235
235
+
};
236
236
+
237
237
+
this.postCompose().post.update(post => {
238
238
+
post.reply = replyRef;
239
239
+
return post;
240
240
+
});
241
241
+
242
242
+
this.postCompose().reply.set(response.data.thread.post);
243
243
+
});
244
244
+
}
245
245
+
246
246
+
quotePost(uri:string) {
247
247
+
if (!this.postCompose()) this.createPost();
248
248
+
249
249
+
agent.getPosts({
250
250
+
uris: [uri]
251
251
+
}).then(response => {
252
252
+
if (!response.data.posts[0]) return;
253
253
+
const quotedPost = this.setPost(response.data.posts[0]);
254
254
+
255
255
+
if (!this.postCompose()) this.createPost();
256
256
+
this.postCompose().recordEmbed.set({
257
257
+
$type: 'app.bsky.embed.record#viewRecord',
258
258
+
uri: quotedPost().uri,
259
259
+
cid: quotedPost().cid,
260
260
+
author: quotedPost().author,
261
261
+
indexedAt: quotedPost().indexedAt,
262
262
+
value: quotedPost().record,
263
263
+
embeds: [quotedPost().embed]
264
264
+
} as $Typed<AppBskyEmbedRecord.ViewRecord>);
265
265
+
});
266
266
+
}
267
267
+
268
268
+
attachMedia(files: File[]) {
269
269
+
if (!files.length) return;
270
270
+
if (!this.postCompose()) this.createPost();
271
271
+
272
272
+
//Fix array methods because it comes as FileList
273
273
+
files = Array.from(files);
274
274
+
275
275
+
if (files.some(f => f.type.includes('image'))) {
276
276
+
//Filelist has images
277
277
+
if (!this.postCompose().mediaEmbed()) {
278
278
+
this.postCompose().mediaEmbed.set(new ImageEmbed());
279
279
+
}
280
280
+
if (this.postCompose().mediaEmbed().type == EmbedType.IMAGE) {
281
281
+
const imageEmbed = this.postCompose().mediaEmbed as WritableSignal<ImageEmbed>;
282
282
+
283
283
+
//Our embed list is for images
284
284
+
files.forEach(file => {
285
285
+
if (file.type.includes('image') && imageEmbed().images.length < 4) {
286
286
+
const reader = new FileReader();
287
287
+
reader.onload = (event: any) => {
288
288
+
const newEmbed = new ImageEmbed();
289
289
+
newEmbed.images = [...imageEmbed().images, {data: event.srcElement.result, alt: ''}];
290
290
+
imageEmbed.set(newEmbed);
291
291
+
};
292
292
+
reader.readAsDataURL(file);
293
293
+
}
294
294
+
})
295
295
+
}
296
296
+
} else if (files.some(f => f.type.includes('video'))) {
297
297
+
const videoEmbed = this.postCompose().mediaEmbed as WritableSignal<VideoEmbed>;
298
298
+
299
299
+
//Filelist has video
300
300
+
while (!videoEmbed()) {
301
301
+
files.forEach(file => {
302
302
+
if (file.type.includes('video')) {
303
303
+
videoEmbed.set(new VideoEmbed(file, undefined));
304
304
+
}
305
305
+
});
306
306
+
}
307
307
+
}
308
308
+
}
309
309
+
310
310
+
publishPost(): Promise<void> {
311
311
+
return new Promise((resolve, reject) => {
312
312
+
const rt = new RichText({
313
313
+
text: this.postCompose().post().text
314
314
+
});
315
315
+
316
316
+
Promise.all([
317
317
+
this.prepareRecord(),
318
318
+
this.prepareMedia(),
319
319
+
rt.detectFacets(agent)
320
320
+
]).then(([record, media]) => {
321
321
+
if (record && media) {
322
322
+
this.postCompose().post().embed = {
323
323
+
$type: 'app.bsky.embed.recordWithMedia',
324
324
+
record: record,
325
325
+
media: media
326
326
+
} as $Typed<AppBskyEmbedRecordWithMedia.Main>;
327
327
+
} else {
328
328
+
this.postCompose().post().embed = record ?? media;
329
329
+
}
330
330
+
this.postCompose().post.update(post => {
331
331
+
post.text = rt.text;
332
332
+
post.facets = rt.facets;
333
333
+
post.createdAt = new Date().toISOString();
334
334
+
return post;
335
335
+
});
336
336
+
337
337
+
from(agent.post(this.postCompose().post())).subscribe({
338
338
+
next: () => {
339
339
+
this.postCompose.set(undefined);
340
340
+
341
341
+
setTimeout(() => {
342
342
+
this.refreshFeeds.next();
343
343
+
}, 1e3);
344
344
+
345
345
+
resolve();
346
346
+
},
347
347
+
error: (err: HttpErrorResponse) => {
348
348
+
reject(err);
349
349
+
}
350
350
+
});
351
351
+
});
352
352
+
});
353
353
+
}
354
354
+
355
355
+
private prepareRecord(): Promise<$Typed<AppBskyEmbedRecord.Main>> {
356
356
+
return new Promise(resolve => {
357
357
+
if (!this.postCompose().recordEmbed()) {
358
358
+
resolve(undefined)
359
359
+
} else {
360
360
+
resolve({
361
361
+
$type: 'app.bsky.embed.record',
362
362
+
record: {
363
363
+
uri: this.postCompose().recordEmbed().uri,
364
364
+
cid: this.postCompose().recordEmbed().cid
365
365
+
}
366
366
+
});
367
367
+
}
368
368
+
});
369
369
+
}
370
370
+
371
371
+
private prepareMedia(): Promise<$Typed<AppBskyEmbedImages.Main> | $Typed<AppBskyEmbedVideo.Main> | $Typed<AppBskyEmbedExternal.Main>> {
372
372
+
return new Promise((resolve, reject) => {
373
373
+
if (!this.postCompose().mediaEmbed()) resolve(undefined);
374
374
+
375
375
+
if (this.postCompose().mediaEmbed()?.type == EmbedType.IMAGE) {
376
376
+
const imageEmbed = this.postCompose().mediaEmbed as WritableSignal<ImageEmbed>;
377
377
+
378
378
+
from(Promise.all(
379
379
+
imageEmbed().images.map(i => {
380
380
+
return this.imageCompressService.compressFile(i.data, DOC_ORIENTATION.Default, undefined, undefined, 2000, 2000);
381
381
+
})
382
382
+
)).subscribe({
383
383
+
next: images64 => {
384
384
+
from(
385
385
+
Promise.all(images64.map(image => fetch(image).then(res => res.blob())))
386
386
+
).subscribe({
387
387
+
next: blobs => {
388
388
+
from(
389
389
+
Promise.all(blobs.map(b => agent.uploadBlob(b)))
390
390
+
).subscribe({
391
391
+
next: upload => {
392
392
+
resolve({
393
393
+
$type: 'app.bsky.embed.images',
394
394
+
images: upload.map(response => {
395
395
+
return {
396
396
+
alt: '',
397
397
+
image: response.data.blob
398
398
+
}
399
399
+
})
400
400
+
} as $Typed<AppBskyEmbedImages.Main>);
401
401
+
},
402
402
+
error: err => reject(err)
403
403
+
})
404
404
+
},
405
405
+
error: err => reject(err)
406
406
+
})
407
407
+
},
408
408
+
error: err => reject(err)
409
409
+
});
410
410
+
}
411
411
+
412
412
+
if (this.postCompose().mediaEmbed()?.type == EmbedType.VIDEO) {
413
413
+
// const videoEmbed = this.postCompose().mediaEmbed as WritableSignal<VideoEmbed>;
414
414
+
resolve(undefined);
415
415
+
}
416
416
+
417
417
+
if (this.postCompose().mediaEmbed().type == EmbedType.EXTERNAL) {
418
418
+
const externalEmbed = this.postCompose().mediaEmbed as WritableSignal<ExternalEmbed>;
419
419
+
420
420
+
from(
421
421
+
fetch(externalEmbed().metadata.imageUrl)
422
422
+
.then(res => res.blob())
423
423
+
.then(blob => agent.uploadBlob(blob))
424
424
+
).subscribe({
425
425
+
next: response => {
426
426
+
resolve({
427
427
+
$type: 'app.bsky.embed.external',
428
428
+
external: {
429
429
+
uri: externalEmbed().metadata.url,
430
430
+
title: externalEmbed().metadata.title,
431
431
+
description: externalEmbed().metadata.description,
432
432
+
thumb: response.data.blob
433
433
+
}
434
434
+
} as $Typed<AppBskyEmbedExternal.Main>)
435
435
+
}, error: err => reject(err)
436
436
+
})
437
437
+
}
438
438
+
});
439
439
+
}
440
440
+
}
+29
src/app/services/storage.service.ts
···
1
1
+
import {inject, Injectable, PLATFORM_ID} from '@angular/core';
2
2
+
import {isPlatformBrowser} from '@angular/common';
3
3
+
4
4
+
@Injectable({
5
5
+
providedIn: 'root'
6
6
+
})
7
7
+
export class StorageService {
8
8
+
private readonly platformId = inject(PLATFORM_ID);
9
9
+
10
10
+
set(id: string, item: string) {
11
11
+
if (isPlatformBrowser(this.platformId)) {
12
12
+
localStorage.setItem(id, item);
13
13
+
}
14
14
+
}
15
15
+
16
16
+
get(id: string): string | null {
17
17
+
if (isPlatformBrowser(this.platformId)) {
18
18
+
return localStorage.getItem(id);
19
19
+
} else {
20
20
+
return null;
21
21
+
}
22
22
+
}
23
23
+
24
24
+
remove(id: string) {
25
25
+
if (isPlatformBrowser(this.platformId)) {
26
26
+
localStorage.removeItem(id);
27
27
+
}
28
28
+
}
29
29
+
}
+28
src/app/shared/directives/scroll.directive.ts
···
1
1
+
import {Directive, ElementRef, HostListener, output} from '@angular/core';
2
2
+
3
3
+
@Directive({
4
4
+
selector: '[vScroll]'
5
5
+
})
6
6
+
export class ScrollDirective {
7
7
+
scrollEnding = output();
8
8
+
scrollTop = output();
9
9
+
emitted = false;
10
10
+
11
11
+
constructor(
12
12
+
private elemRef: ElementRef
13
13
+
) {}
14
14
+
15
15
+
@HostListener('scroll', [])
16
16
+
onScroll() {
17
17
+
if (!this.elemRef.nativeElement.scrollTop) {
18
18
+
this.emitted = true;
19
19
+
this.scrollTop.emit();
20
20
+
} else if (this.elemRef.nativeElement.scrollTop + this.elemRef.nativeElement.offsetHeight + 300 >= this.elemRef.nativeElement.scrollHeight && !this.emitted) {
21
21
+
this.emitted = true;
22
22
+
this.scrollEnding.emit();
23
23
+
} else if (this.elemRef.nativeElement.scrollTop + this.elemRef.nativeElement.offsetHeight + 300 < this.elemRef.nativeElement.scrollHeight) {
24
24
+
this.emitted = false;
25
25
+
}
26
26
+
}
27
27
+
28
28
+
}
+38
src/app/shared/pipes/date-formatter.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {formatDistanceToNowStrict} from "date-fns";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'dateFormatter',
6
6
+
pure: false
7
7
+
})
8
8
+
export class DateFormatterPipe implements PipeTransform {
9
9
+
transform(value: string | Date): string {
10
10
+
return formatDistanceToNowStrict(value, {
11
11
+
locale: {
12
12
+
formatDistance: (unit, count) => {
13
13
+
switch (true) {
14
14
+
case unit === 'xSeconds':
15
15
+
return `${count}s`;
16
16
+
17
17
+
case unit === 'xMinutes':
18
18
+
return `${count}m`;
19
19
+
20
20
+
case unit === 'xHours':
21
21
+
return `${count}h`;
22
22
+
23
23
+
case unit === 'xDays':
24
24
+
return `${count}d`;
25
25
+
26
26
+
case unit === 'xMonths':
27
27
+
return `${count}mon`;
28
28
+
29
29
+
case unit === 'xYears':
30
30
+
return `${count}y`;
31
31
+
}
32
32
+
33
33
+
return '%d hours';
34
34
+
}
35
35
+
}
36
36
+
});
37
37
+
}
38
38
+
}
+10
src/app/shared/pipes/display-name.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
3
3
+
@Pipe({
4
4
+
name: 'displayName'
5
5
+
})
6
6
+
export class DisplayNamePipe implements PipeTransform {
7
7
+
transform(author: Partial<{displayName: string, handle: string}>): string {
8
8
+
return author.displayName?.trim().length ? author.displayName : `@${author.handle}`;
9
9
+
}
10
10
+
}
+11
src/app/shared/pipes/is-logged-user.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyActorDefs} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isLoggedUser'
6
6
+
})
7
7
+
export class IsLoggedUserPipe implements PipeTransform {
8
8
+
transform(did: string, loggedUser: AppBskyActorDefs.ProfileViewDetailed): boolean {
9
9
+
return did == loggedUser.did;
10
10
+
}
11
11
+
}
+12
src/app/shared/pipes/is-post-reposted.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyFeedDefs} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isPostReposted',
6
6
+
standalone: true
7
7
+
})
8
8
+
export class IsPostRepostedPipe implements PipeTransform {
9
9
+
transform(post: AppBskyFeedDefs.PostView): boolean {
10
10
+
return !!post.viewer.repost;
11
11
+
}
12
12
+
}
+34
src/app/shared/pipes/link-extractor-starterpack.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
3
3
+
@Pipe({
4
4
+
name: 'linkExtractorStarterPack',
5
5
+
})
6
6
+
export class LinkExtractorStarterPackPipe implements PipeTransform {
7
7
+
transform(uri: string, handle?: string): string {
8
8
+
let url = 'https://bsky.app/starter-pack/';
9
9
+
10
10
+
if (uri.includes('at://')) {
11
11
+
uri = uri.substring(5);
12
12
+
}
13
13
+
14
14
+
if (!uri.includes('/')) {
15
15
+
return url + uri;
16
16
+
} else {
17
17
+
let authority;
18
18
+
19
19
+
if (handle) {
20
20
+
authority = handle;
21
21
+
} else {
22
22
+
authority = uri.substring(0, uri.indexOf('/'))
23
23
+
}
24
24
+
25
25
+
// Remove slash
26
26
+
uri = uri.substring(uri.indexOf('/') + 1);
27
27
+
28
28
+
// Remove slash
29
29
+
const rkey = uri.substring(uri.indexOf('/') + 1);
30
30
+
31
31
+
return url + authority + '/' + rkey;
32
32
+
}
33
33
+
}
34
34
+
}
+51
src/app/shared/pipes/link-extractor.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
3
3
+
@Pipe({
4
4
+
name: 'linkExtractor',
5
5
+
})
6
6
+
export class LinkExtractorPipe implements PipeTransform {
7
7
+
transform(uri: string, handle?: string): string {
8
8
+
let url = 'https://bsky.app/profile/';
9
9
+
10
10
+
if (!uri && !handle) return undefined;
11
11
+
if (!uri && handle) return url + handle;
12
12
+
13
13
+
if (uri.includes('at://')) {
14
14
+
uri = uri.substring(5);
15
15
+
}
16
16
+
17
17
+
if (!uri.includes('/')) {
18
18
+
return url + uri;
19
19
+
} else {
20
20
+
let authority;
21
21
+
22
22
+
if (handle) {
23
23
+
authority = handle;
24
24
+
} else {
25
25
+
authority = uri.substring(0, uri.indexOf('/'))
26
26
+
}
27
27
+
28
28
+
// Remove slash
29
29
+
uri = uri.substring(uri.indexOf('/') + 1);
30
30
+
31
31
+
let collection = uri.substring(0, uri.indexOf('/'));
32
32
+
33
33
+
switch (collection) {
34
34
+
case 'app.bsky.feed.post':
35
35
+
collection = 'post';
36
36
+
break;
37
37
+
case 'app.bsky.feed.generator':
38
38
+
collection = 'feed';
39
39
+
break;
40
40
+
case 'app.bsky.graph.list':
41
41
+
collection = 'lists';
42
42
+
break;
43
43
+
}
44
44
+
45
45
+
// Remove slash
46
46
+
const rkey = uri.substring(uri.indexOf('/') + 1);
47
47
+
48
48
+
return url + authority + '/' + collection + '/' + rkey;
49
49
+
}
50
50
+
}
51
51
+
}
+12
src/app/shared/pipes/number-formatter.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
3
3
+
@Pipe({
4
4
+
name: 'numberFormatter'
5
5
+
})
6
6
+
export class NumberFormatterPipe implements PipeTransform {
7
7
+
transform(value: number): string {
8
8
+
if (value >= 1e5) return (value / 1e3).toFixed() + 'K';
9
9
+
if (value >= 1e3) return (value / 1e3).toFixed(1) + 'K';
10
10
+
return value.toString();
11
11
+
}
12
12
+
}
+11
src/app/shared/pipes/post-composer-height.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
3
3
+
@Pipe({
4
4
+
name: 'postComposerHeight',
5
5
+
pure: false
6
6
+
})
7
7
+
export class PostComposerHeightPipe implements PipeTransform {
8
8
+
transform(textElement: HTMLDivElement): boolean {
9
9
+
return textElement.offsetHeight > parseFloat(window.getComputedStyle(textElement).fontSize) * 7;
10
10
+
}
11
11
+
}
+12
src/app/shared/pipes/type-guards/is-actor-defs-profileviewbasic.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyActorDefs} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isActorDefsProfileViewBasic',
6
6
+
standalone: true
7
7
+
})
8
8
+
export class IsActorDefsProfileViewBasicPipe implements PipeTransform {
9
9
+
transform(value: unknown): value is AppBskyActorDefs.ProfileViewBasic {
10
10
+
return AppBskyActorDefs.isProfileViewBasic(value);
11
11
+
}
12
12
+
}
+12
src/app/shared/pipes/type-guards/is-deckcolumn-author.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AuthorDeckColumn, DeckColumnType} from "@models/deck-column";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isDeckColumnAuthor'
6
6
+
})
7
7
+
export class IsDeckColumnAuthorPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AuthorDeckColumn {
9
9
+
const typedValue = value as AuthorDeckColumn;
10
10
+
return typedValue && typedValue.type && typedValue.type == DeckColumnType.AUTHOR;
11
11
+
}
12
12
+
}
+12
src/app/shared/pipes/type-guards/is-deckcolumn-generator.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {DeckColumnType, GeneratorDeckColumn} from "@models/deck-column";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isDeckColumnGenerator'
6
6
+
})
7
7
+
export class IsDeckColumnGeneratorPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is GeneratorDeckColumn {
9
9
+
const typedValue = value as GeneratorDeckColumn;
10
10
+
return typedValue && typedValue.type && typedValue.type == DeckColumnType.GENERATOR;
11
11
+
}
12
12
+
}
+12
src/app/shared/pipes/type-guards/is-deckcolumn-list.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {DeckColumnType, ListDeckColumn} from "@models/deck-column";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isDeckColumnList'
6
6
+
})
7
7
+
export class IsDeckColumnListPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is ListDeckColumn {
9
9
+
const typedValue = value as ListDeckColumn;
10
10
+
return typedValue && typedValue.type && typedValue.type == DeckColumnType.LIST;
11
11
+
}
12
12
+
}
+12
src/app/shared/pipes/type-guards/is-deckcolumn-notifications.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {DeckColumnType, NotificationDeckColumn} from "@models/deck-column";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isDeckColumnNotification'
6
6
+
})
7
7
+
export class IsDeckColumnNotificationPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is NotificationDeckColumn {
9
9
+
const typedValue = value as NotificationDeckColumn;
10
10
+
return typedValue && typedValue.type && typedValue.type == DeckColumnType.NOTIFICATION;
11
11
+
}
12
12
+
}
+12
src/app/shared/pipes/type-guards/is-deckcolumn-search.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {DeckColumnType, SearchDeckColumn} from "@models/deck-column";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isDeckColumnSearch'
6
6
+
})
7
7
+
export class IsDeckColumnSearchPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is SearchDeckColumn {
9
9
+
const typedValue = value as SearchDeckColumn;
10
10
+
return typedValue && typedValue.type && typedValue.type == DeckColumnType.SEARCH;
11
11
+
}
12
12
+
}
+12
src/app/shared/pipes/type-guards/is-deckcolumn-timeline.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {DeckColumnType, TimelineDeckColumn} from "@models/deck-column";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isDeckColumnTimeline'
6
6
+
})
7
7
+
export class IsDeckColumnTimelinePipe implements PipeTransform {
8
8
+
transform(value: unknown): value is TimelineDeckColumn {
9
9
+
const typedValue = value as TimelineDeckColumn;
10
10
+
return typedValue && typedValue.type && typedValue.type == DeckColumnType.TIMELINE;
11
11
+
}
12
12
+
}
+11
src/app/shared/pipes/type-guards/is-embed-external-view.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyEmbedExternal} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isEmbedExternalView'
6
6
+
})
7
7
+
export class IsEmbedExternalViewPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyEmbedExternal.View {
9
9
+
return AppBskyEmbedExternal.isView(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-embed-images-view.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyEmbedImages} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isEmbedImagesView'
6
6
+
})
7
7
+
export class IsEmbedImagesViewPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyEmbedImages.View {
9
9
+
return AppBskyEmbedImages.isView(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-embed-record-view.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyEmbedRecord} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isEmbedRecordView'
6
6
+
})
7
7
+
export class IsEmbedRecordViewPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyEmbedRecord.View {
9
9
+
return AppBskyEmbedRecord.isView(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-embed-record-viewblocked.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyEmbedRecord} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isEmbedRecordViewBlocked'
6
6
+
})
7
7
+
export class IsEmbedRecordViewBlockedPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyEmbedRecord.ViewBlocked {
9
9
+
return AppBskyEmbedRecord.isViewBlocked(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-embed-record-viewdetached.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyEmbedRecord} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isEmbedRecordViewDetached'
6
6
+
})
7
7
+
export class IsEmbedRecordViewDetachedPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyEmbedRecord.ViewDetached {
9
9
+
return AppBskyEmbedRecord.isViewDetached(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-embed-record-viewnotfound.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyEmbedRecord} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isEmbedRecordViewNotFound'
6
6
+
})
7
7
+
export class IsEmbedRecordViewNotFoundPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyEmbedRecord.ViewNotFound {
9
9
+
return AppBskyEmbedRecord.isViewNotFound(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-embed-record-viewrecord.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyEmbedRecord} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isEmbedRecordViewRecord'
6
6
+
})
7
7
+
export class IsEmbedRecordViewRecordPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyEmbedRecord.ViewRecord {
9
9
+
return AppBskyEmbedRecord.isViewRecord(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-embed-recordwithmedia-view.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyEmbedRecordWithMedia} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isEmbedRecordWithMediaView'
6
6
+
})
7
7
+
export class IsEmbedRecordWithMediaViewPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyEmbedRecordWithMedia.View {
9
9
+
return AppBskyEmbedRecordWithMedia.isView(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-embed-video-view.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyEmbedVideo} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isEmbedVideoView'
6
6
+
})
7
7
+
export class IsEmbedVideoViewPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyEmbedVideo.View {
9
9
+
return AppBskyEmbedVideo.isView(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-feed-defs-blockedpost.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyFeedDefs} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isFeedDefsBlockedPost'
6
6
+
})
7
7
+
export class IsFeedDefsBlockedPostPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyFeedDefs.BlockedPost {
9
9
+
return AppBskyFeedDefs.isBlockedPost(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-feed-defs-feedviewpost-array.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyFeedDefs} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isFeedDefsFeedViewPostArray',
6
6
+
})
7
7
+
export class IsFeedDefsFeedViewPostArrayPipe implements PipeTransform {
8
8
+
transform(value: unknown[]): value is AppBskyFeedDefs.FeedViewPost[] {
9
9
+
return value && value.length && !!(value[0] as AppBskyFeedDefs.FeedViewPost).post;
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-feed-defs-generator-view.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyFeedDefs} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isFeedDefsGeneratorView'
6
6
+
})
7
7
+
export class IsFeedDefsGeneratorViewPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyFeedDefs.GeneratorView {
9
9
+
return AppBskyFeedDefs.isGeneratorView(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-feed-defs-notfoundpost.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyFeedDefs} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isFeedDefsNotFoundPost'
6
6
+
})
7
7
+
export class IsFeedDefsNotFoundPostPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyFeedDefs.NotFoundPost {
9
9
+
return AppBskyFeedDefs.isNotFoundPost(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-feed-defs-postview.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyFeedDefs} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isFeedDefsPostView'
6
6
+
})
7
7
+
export class IsFeedDefsPostViewPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyFeedDefs.PostView {
9
9
+
return AppBskyFeedDefs.isPostView(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-feed-defs-reasonpin.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyFeedDefs} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isFeedDefsReasonPin'
6
6
+
})
7
7
+
export class IsFeedDefsReasonPinPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyFeedDefs.ReasonPin {
9
9
+
return AppBskyFeedDefs.isReasonPin(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-feed-defs-reasonrepost.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyFeedDefs} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isFeedDefsReasonRepost'
6
6
+
})
7
7
+
export class IsFeedDefsReasonRepostPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyFeedDefs.ReasonRepost {
9
9
+
return AppBskyFeedDefs.isReasonRepost(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-feed-defs-replyref.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyFeedDefs} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isFeedDefsReplyRef'
6
6
+
})
7
7
+
export class IsFeedDefsReplyRefPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyFeedDefs.ReplyRef {
9
9
+
return AppBskyFeedDefs.isReplyRef(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-feed-post-record.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyFeedPost} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isFeedPostRecord'
6
6
+
})
7
7
+
export class IsFeedPostRecordPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyFeedPost.Record {
9
9
+
return AppBskyFeedPost.isRecord(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-feed-post-replyref.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyFeedPost} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isFeedPostReplyRef'
6
6
+
})
7
7
+
export class IsFeedPostReplyRefPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyFeedPost.ReplyRef {
9
9
+
return AppBskyFeedPost.isReplyRef(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-graph-defs-list-view.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyGraphDefs} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isGraphDefsListView'
6
6
+
})
7
7
+
export class IsGraphDefsListViewPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyGraphDefs.ListView {
9
9
+
return AppBskyGraphDefs.isListView(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-graph-defs-starterpack-view.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyGraphDefs} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isGraphDefsStarterPackView'
6
6
+
})
7
7
+
export class IsGraphDefsStarterPackViewPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyGraphDefs.StarterPackView {
9
9
+
return AppBskyGraphDefs.isStarterPackView(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-graph-defs-starterpack-viewbasic.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyGraphDefs} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isGraphDefsStarterPackViewBasic'
6
6
+
})
7
7
+
export class IsGraphDefsStarterPackViewBasicPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyGraphDefs.StarterPackViewBasic {
9
9
+
return AppBskyGraphDefs.isStarterPackViewBasic(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-labeler-defs-labeler-view.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {AppBskyLabelerDefs} from "@atproto/api";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isLabelerDefsLabelerView'
6
6
+
})
7
7
+
export class IsLabelerDefsLabelerViewPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is AppBskyLabelerDefs.LabelerView {
9
9
+
return AppBskyLabelerDefs.isLabelerView(value);
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-media-embed-external.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {EmbedType, ExternalEmbed} from "@models/embed";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isMediaEmbedExternal'
6
6
+
})
7
7
+
export class IsMediaEmbedExternalPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is ExternalEmbed {
9
9
+
return (value as ExternalEmbed)?.type == EmbedType.EXTERNAL;
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-media-embed-image.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {EmbedType, ImageEmbed} from '@models/embed';
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isMediaEmbedImage'
6
6
+
})
7
7
+
export class IsMediaEmbedImagePipe implements PipeTransform {
8
8
+
transform(value: unknown): value is ImageEmbed {
9
9
+
return (value as ImageEmbed)?.type == EmbedType.IMAGE;
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/is-media-embed-video.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {EmbedType, VideoEmbed} from "@models/embed";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isMediaEmbedVideo'
6
6
+
})
7
7
+
export class IsMediaEmbedVideoPipe implements PipeTransform {
8
8
+
transform(value: unknown): value is VideoEmbed {
9
9
+
return (value as VideoEmbed)?.type == EmbedType.VIDEO;
10
10
+
}
11
11
+
}
+15
src/app/shared/pipes/type-guards/is-signalized-feedviewpost.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {SignalizedFeedViewPost} from "@models/signalized-feed-view-post";
3
3
+
import {AppBskyFeedDefs} from "@atproto/api";
4
4
+
5
5
+
@Pipe({
6
6
+
name: 'isSignalizedFeedViewPost'
7
7
+
})
8
8
+
export class IsSignalizedFeedViewPostPipe implements PipeTransform {
9
9
+
transform(value: unknown): value is SignalizedFeedViewPost {
10
10
+
return value &&
11
11
+
(value as SignalizedFeedViewPost).post &&
12
12
+
(value as SignalizedFeedViewPost).post() &&
13
13
+
AppBskyFeedDefs.isFeedViewPost((value as SignalizedFeedViewPost).post());
14
14
+
}
15
15
+
}
+11
src/app/shared/pipes/type-guards/notifications/is-follow-notification.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {Notification} from "@models/notification";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isFollowNotification'
6
6
+
})
7
7
+
export class IsFollowNotificationPipe implements PipeTransform {
8
8
+
transform(value: Notification): boolean {
9
9
+
return value.reason == "follow";
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/notifications/is-like-notification.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {Notification} from "@models/notification";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isLikeNotification'
6
6
+
})
7
7
+
export class IsLikeNotificationPipe implements PipeTransform {
8
8
+
transform(value: Notification): boolean {
9
9
+
return value.reason == "like";
10
10
+
}
11
11
+
}
+15
src/app/shared/pipes/type-guards/notifications/is-post-notification.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {Notification} from "@models/notification";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isPostNotification'
6
6
+
})
7
7
+
export class IsNotificationArrayPipe implements PipeTransform {
8
8
+
transform(value: Notification): boolean {
9
9
+
return value.post && (
10
10
+
value.reason == 'reply' ||
11
11
+
value.reason == 'quote' ||
12
12
+
value.reason == 'mention'
13
13
+
);
14
14
+
}
15
15
+
}
+11
src/app/shared/pipes/type-guards/notifications/is-repost-notification.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {Notification} from "@models/notification";
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isRepostNotification'
6
6
+
})
7
7
+
export class IsRepostNotificationPipe implements PipeTransform {
8
8
+
transform(value: Notification): boolean {
9
9
+
return value.reason == "repost";
10
10
+
}
11
11
+
}
+11
src/app/shared/pipes/type-guards/notifications/is-starterpack-notification.pipe.ts
···
1
1
+
import {Pipe, PipeTransform} from '@angular/core';
2
2
+
import {Notification} from '@models/notification';
3
3
+
4
4
+
@Pipe({
5
5
+
name: 'isStarterPackNotification'
6
6
+
})
7
7
+
export class IsStarterPackNotificationPipe implements PipeTransform {
8
8
+
transform(value: Notification): boolean {
9
9
+
return value.reason == "starterpack-joined";
10
10
+
}
11
11
+
}
+44
src/app/shared/utils/embed-utils.ts
···
1
1
+
import {ExternalEmbed, RecordEmbed, RecordEmbedType} from "@models/embed";
2
2
+
import {URL_REGEX} from "@atproto/api";
3
3
+
4
4
+
export const BSKY_PROFILE_URL_RE = /\/profile\/([^\/]+)$/;
5
5
+
export const BSKY_POST_URL_RE = /\/profile\/([^\/]+)\/post\/([^\/]+)$/;
6
6
+
export const BSKY_FEED_URL_RE = /\/profile\/([^\/]+)\/feed\/([^\/]+)$/;
7
7
+
export const BSKY_LIST_URL_RE = /\/profile\/([^\/]+)\/lists\/([^\/]+)$/;
8
8
+
export const BSKY_STARTER_PACK_URL_RE = /\/starter-pack\/([^\/]+)\/([^\/]+)$/;
9
9
+
10
10
+
export class EmbedUtils {
11
11
+
public static findEmbedSuggestions(text: string): Array<ExternalEmbed | RecordEmbed> {
12
12
+
const embeds: Array<ExternalEmbed | RecordEmbed> = [];
13
13
+
const matches = text.match(URL_REGEX) ?? [];
14
14
+
let split: RegExpExecArray | null;
15
15
+
16
16
+
matches.forEach(match => {
17
17
+
if (match.includes('bsky.app')) {
18
18
+
if ((split = BSKY_POST_URL_RE.exec(match))) {
19
19
+
embeds.push(new RecordEmbed(RecordEmbedType.POST, split[1], split[2]));
20
20
+
return;
21
21
+
}
22
22
+
if ((split = BSKY_FEED_URL_RE.exec(match))) {
23
23
+
embeds.push(new RecordEmbed(RecordEmbedType.FEED, split[1], split[2]));
24
24
+
return;
25
25
+
}
26
26
+
if ((split = BSKY_LIST_URL_RE.exec(match))) {
27
27
+
embeds.push(new RecordEmbed(RecordEmbedType.LIST, split[1], split[2]));
28
28
+
return;
29
29
+
}
30
30
+
if ((split = BSKY_STARTER_PACK_URL_RE.exec(match))) {
31
31
+
embeds.push(new RecordEmbed(RecordEmbedType.STARTER_PACK, split[1], split[2]));
32
32
+
return;
33
33
+
}
34
34
+
35
35
+
embeds.push(new ExternalEmbed(match));
36
36
+
return;
37
37
+
}
38
38
+
39
39
+
embeds.push(new ExternalEmbed(match));
40
40
+
});
41
41
+
42
42
+
return embeds;
43
43
+
}
44
44
+
}
+67
src/app/shared/utils/notification-utils.ts
···
1
1
+
import {AppBskyFeedGetPosts, AppBskyNotificationListNotifications} from '@atproto/api';
2
2
+
import {Notification} from '@models/notification'
3
3
+
import {agent} from '@core/bsky.api';
4
4
+
import {PostService} from '@services/post.service';
5
5
+
6
6
+
export default class NotificationUtils {
7
7
+
public static parseNotifications(notifications: AppBskyNotificationListNotifications.Notification[], postService: PostService): Promise<Notification[]> {
8
8
+
return new Promise<Notification[]>((resolve) => {
9
9
+
const target: Notification[] = [];
10
10
+
while (notifications.length) {
11
11
+
const temp = new Notification();
12
12
+
if (notifications[0].reason === 'reply' || notifications[0].reason === 'quote' || notifications[0].reason === 'mention') {
13
13
+
temp.authors = [notifications[0].author];
14
14
+
temp.reason = notifications[0].reason;
15
15
+
temp.notification = notifications[0];
16
16
+
temp.uri = notifications[0].uri;
17
17
+
18
18
+
target.push(temp);
19
19
+
notifications.shift();
20
20
+
} else if (notifications[0].reason) {
21
21
+
const slice = notifications.filter(n =>
22
22
+
(n.reasonSubject === notifications[0].reasonSubject && n.reason === notifications[0].reason)
23
23
+
);
24
24
+
25
25
+
temp.authors = slice.map(s => s.author);
26
26
+
temp.reason = notifications[0].reason;
27
27
+
temp.notification = notifications[0];
28
28
+
temp.uri = notifications[0].reasonSubject;
29
29
+
30
30
+
target.push(temp);
31
31
+
32
32
+
notifications = notifications.filter(n =>
33
33
+
!(n.reasonSubject === notifications[0].reasonSubject && n.reason === notifications[0].reason)
34
34
+
);
35
35
+
}
36
36
+
}
37
37
+
38
38
+
const chunkFn = (arr: Notification[], size: number) =>
39
39
+
Array.from({ length: Math.ceil(arr.length / size) }, (v, i) =>
40
40
+
arr.slice(i * size, i * size + size)
41
41
+
);
42
42
+
43
43
+
const promises: Promise<AppBskyFeedGetPosts.Response>[] = [];
44
44
+
const tempChunks = chunkFn(target, 25);
45
45
+
46
46
+
tempChunks.forEach(array => {
47
47
+
promises.push(
48
48
+
agent.getPosts({
49
49
+
uris: array.map(n => n.uri).filter(n => n)
50
50
+
})
51
51
+
)
52
52
+
});
53
53
+
54
54
+
Promise.all(promises).then(chunk => {
55
55
+
56
56
+
chunk.forEach(response => {
57
57
+
target.forEach(notification => {
58
58
+
const postView = response.data.posts.find(post => post.uri === notification.uri);
59
59
+
if (postView) {
60
60
+
notification.post = postService.setPost(postView);
61
61
+
}
62
62
+
})
63
63
+
})
64
64
+
}, error => console.error(error)).then(() => resolve(target));
65
65
+
});
66
66
+
}
67
67
+
}
+16
src/app/shared/utils/post-utils.ts
···
1
1
+
import {AppBskyFeedDefs, AppBskyFeedPost} from "@atproto/api";
2
2
+
import {PostService} from '@services/post.service';
3
3
+
import {SignalizedFeedViewPost} from '@models/signalized-feed-view-post';
4
4
+
5
5
+
export class PostUtils {
6
6
+
public static parseFeedViewPost(feedViewPost: AppBskyFeedDefs.FeedViewPost, postService: PostService): SignalizedFeedViewPost {
7
7
+
const signalizedFeedViewPost = new SignalizedFeedViewPost();
8
8
+
feedViewPost.post.record = feedViewPost.post.record as AppBskyFeedPost.Record;
9
9
+
signalizedFeedViewPost.post = postService.setPost(feedViewPost.post);
10
10
+
signalizedFeedViewPost.reply = feedViewPost.reply;
11
11
+
signalizedFeedViewPost.reason = feedViewPost.reason;
12
12
+
signalizedFeedViewPost.feedContext = feedViewPost.feedContext;
13
13
+
14
14
+
return signalizedFeedViewPost;
15
15
+
}
16
16
+
}
+86
src/app/shared/utils/snippet-utils.ts
···
1
1
+
import {AppBskyEmbedExternal} from "@atproto/api";
2
2
+
import {BlueskyGifSnippet, IframeSnippet, LinkSnippet, SnippetSource, SnippetType} from "@models/snippet";
3
3
+
4
4
+
export class SnippetUtils {
5
5
+
public static detectSnippet(link: Pick<AppBskyEmbedExternal.ViewExternal, 'uri' | 'description'>): LinkSnippet | BlueskyGifSnippet | IframeSnippet {
6
6
+
const url = link.uri;
7
7
+
8
8
+
let u: URL;
9
9
+
let m: RegExpExecArray | null | undefined;
10
10
+
11
11
+
try {
12
12
+
u = new URL(url);
13
13
+
14
14
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
15
15
+
return { type: SnippetType.LINK } as LinkSnippet;
16
16
+
}
17
17
+
} catch {
18
18
+
return { type: SnippetType.LINK } as LinkSnippet;
19
19
+
}
20
20
+
21
21
+
const h = u.host;
22
22
+
const p = u.pathname;
23
23
+
const q = u.searchParams;
24
24
+
25
25
+
const d = h.startsWith('www.') ? h.slice(4) : h;
26
26
+
27
27
+
if (d === 'media.tenor.com') {
28
28
+
// Bluesky GIFs
29
29
+
if ((m = /\/([^/]+?AAAAC)\/([^/]+?)\?hh=(\d+?)&ww=(\d+?)$/.exec(url))) {
30
30
+
const id = m[1].replace(/AAAAC$/, 'AAAP3');
31
31
+
const file = m[2].replace(/\.gif$/, '.webm');
32
32
+
33
33
+
const width = m[4];
34
34
+
const height = m[3];
35
35
+
36
36
+
return new BlueskyGifSnippet(
37
37
+
d,
38
38
+
`https://t.gifs.bsky.app/${id}/${file}`,
39
39
+
`${width}:${height}`,
40
40
+
link.description.replace(/^ALT: /, '')
41
41
+
);
42
42
+
}
43
43
+
} else if (d === 'youtube.com' || d === 'm.youtube.com' || d === 'music.youtube.com') {
44
44
+
// YouTube iframe
45
45
+
if (p === '/watch') {
46
46
+
const videoId = q.get('v');
47
47
+
const seek = q.get('t') || 0;
48
48
+
49
49
+
return new IframeSnippet(
50
50
+
d,
51
51
+
videoId,
52
52
+
d !== 'music.youtube.com' ? `16:9` : `1:1`,
53
53
+
SnippetSource.YOUTUBE,
54
54
+
Number.parseInt(seek.toString())
55
55
+
);
56
56
+
}
57
57
+
} else if (d === 'youtu.be') {
58
58
+
// YouTube iframe
59
59
+
if ((m = /^\/([^/]+?)$/.exec(p))) {
60
60
+
const videoId = m[1];
61
61
+
const seek = q.get('t') || 0;
62
62
+
63
63
+
return new IframeSnippet(
64
64
+
d,
65
65
+
videoId,
66
66
+
`16:9`,
67
67
+
SnippetSource.YOUTUBE,
68
68
+
Number.parseInt(seek.toString())
69
69
+
);
70
70
+
}
71
71
+
} else if (d === 'soundcloud.com' || d === 'www.soundcloud.com') {
72
72
+
// SoundCloud embed
73
73
+
if ((m = /^\/([^/]+?)(?:\/(?!reposts$)([^/]+?)|\/sets\/([^/]+?))?$/.exec(p))) {
74
74
+
return new IframeSnippet(
75
75
+
d,
76
76
+
`https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&auto_play=false&visual=false&hide_related=true`,
77
77
+
m[3] ? `1:1` : `16:9`,
78
78
+
SnippetSource.SOUNDCLOUD
79
79
+
);
80
80
+
}
81
81
+
}
82
82
+
83
83
+
// Link snippet, always matches
84
84
+
return new LinkSnippet(d, u.toString());
85
85
+
}
86
86
+
}
+33
src/app/views/dashboard/dashboard.component.html
···
1
1
+
<div
2
2
+
class="flex h-full w-full p-4 overflow-hidden"
3
3
+
>
4
4
+
<sidebar/>
5
5
+
<!-- <sidebar-->
6
6
+
<!-- (onProfile)="dialogService.openAuthor($event)"-->
7
7
+
<!-- (onSearch)="dialogService.openSearch()"-->
8
8
+
<!-- (onFeeds)="dialogService.openLikedFeeds()"-->
9
9
+
<!-- (onPost)="postService.createPost()"-->
10
10
+
<!-- />-->
11
11
+
12
12
+
<!-- <div-->
13
13
+
<!-- class="relative flex-1 overflow-hidden"-->
14
14
+
<!-- >-->
15
15
+
<!-- <deck-->
16
16
+
<!-- class="relative flex w-full h-full max-h-full min-h-0 p-4 overflow-x-auto"-->
17
17
+
<!-- >-->
18
18
+
<!-- </deck>-->
19
19
+
<!-- </div>-->
20
20
+
<div
21
21
+
class="flex flex-col flex-1 min-w-0"
22
22
+
>
23
23
+
<deck
24
24
+
class="flex-1 min-h-0 min-w-0 border-b border-primary"
25
25
+
/>
26
26
+
@if (postService.postCompose()) {
27
27
+
<post-composer
28
28
+
class="shrink-0"
29
29
+
/>
30
30
+
}
31
31
+
</div>
32
32
+
<auxbar/>
33
33
+
</div>
+26
src/app/views/dashboard/dashboard.component.ts
···
1
1
+
import {ChangeDetectionStrategy, Component} from '@angular/core';
2
2
+
import {SidebarComponent} from '@components/navigation/sidebar/sidebar.component';
3
3
+
import {DeckComponent} from '@components/navigation/deck/deck.component';
4
4
+
import {PostComposerComponent} from '@components/navigation/post-composer/post-composer.component';
5
5
+
import {PostService} from '@services/post.service';
6
6
+
import {AuxbarComponent} from '@components/navigation/auxbar/auxbar.component';
7
7
+
// import {MskyDialogService} from '@services/msky-dialog.service';
8
8
+
// import {PostService} from '@services/post.service';
9
9
+
10
10
+
@Component({
11
11
+
selector: 'app-dashboard',
12
12
+
imports: [
13
13
+
SidebarComponent,
14
14
+
DeckComponent,
15
15
+
PostComposerComponent,
16
16
+
AuxbarComponent
17
17
+
],
18
18
+
templateUrl: './dashboard.component.html',
19
19
+
changeDetection: ChangeDetectionStrategy.OnPush
20
20
+
})
21
21
+
export class DashboardComponent {
22
22
+
constructor(
23
23
+
// protected dialogService: MskyDialogService,
24
24
+
protected postService: PostService
25
25
+
) {}
26
26
+
}
+46
src/app/views/login/login.component.html
···
1
1
+
<div
2
2
+
class="h-full w-full flex flex-col items-center justify-center"
3
3
+
>
4
4
+
<div
5
5
+
class="w-80 border flex-col"
6
6
+
>
7
7
+
<div
8
8
+
class="h-12 bg-primary flex items-center justify-center"
9
9
+
>
10
10
+
<span
11
11
+
class="text-bg font-bold text-2xl"
12
12
+
>//consolesky.</span>
13
13
+
</div>
14
14
+
15
15
+
<div
16
16
+
class="p-2 flex flex-col font-mono"
17
17
+
>
18
18
+
<span
19
19
+
class="text-sm"
20
20
+
>handle</span>
21
21
+
<input
22
22
+
[(ngModel)]="credentials().identifier"
23
23
+
(keydown.enter)="loginBtn.click()"
24
24
+
name="handle"
25
25
+
class="border outline-none px-1"
26
26
+
/>
27
27
+
28
28
+
<span
29
29
+
class="text-sm mt-2"
30
30
+
>password</span>
31
31
+
<input
32
32
+
[(ngModel)]="credentials().password"
33
33
+
(keydown.enter)="loginBtn.click()"
34
34
+
name="password"
35
35
+
type="password"
36
36
+
class="border outline-none px-1"
37
37
+
/>
38
38
+
39
39
+
<button
40
40
+
#loginBtn
41
41
+
(click)="onLogin()"
42
42
+
class="btn-primary mt-2 ml-auto px-2 py-0.5"
43
43
+
>Login</button>
44
44
+
</div>
45
45
+
</div>
46
46
+
</div>
+35
src/app/views/login/login.component.ts
···
1
1
+
import {Component, signal} from '@angular/core';
2
2
+
import {AuthService} from '@core/auth/auth.service';
3
3
+
// import {MskyMessageService} from '@services/msky-message.service';
4
4
+
import {FormsModule} from '@angular/forms';
5
5
+
6
6
+
@Component({
7
7
+
selector: 'app-login',
8
8
+
imports: [
9
9
+
FormsModule
10
10
+
],
11
11
+
templateUrl: './login.component.html'
12
12
+
})
13
13
+
export class LoginComponent {
14
14
+
credentials = signal({
15
15
+
identifier: '',
16
16
+
password: ''
17
17
+
});
18
18
+
19
19
+
constructor(
20
20
+
private authService: AuthService,
21
21
+
// private messageService: MskyMessageService
22
22
+
) {}
23
23
+
24
24
+
onLogin() {
25
25
+
this.credentials().identifier = this.credentials().identifier.trim();
26
26
+
27
27
+
if (
28
28
+
this.credentials().identifier.length
29
29
+
) {
30
30
+
this.authService.login(this.credentials());
31
31
+
} else {
32
32
+
// this.messageService.warn('Please check your credentials.', 'Oops!');
33
33
+
}
34
34
+
}
35
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
9
+
<link rel="preconnect" href="https://fonts.googleapis.com">
10
10
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
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
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
13
+
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
14
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
1
-
import { bootstrapApplication } from '@angular/platform-browser';
2
2
-
import { appConfig } from './app/app.config';
3
3
-
import { AppComponent } from './app/app.component';
1
1
+
import {bootstrapApplication} from '@angular/platform-browser';
2
2
+
import {appConfig} from './app/app.config';
3
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
1
-
/* You can add global styles to this file, and also import other style files */
1
1
+
@import "tailwindcss";
2
2
+
@import '@angular/cdk/overlay-prebuilt.css';
3
3
+
@custom-variant theme-dark (&:where([data-theme="dark"] *));
4
4
+
@custom-variant theme-hacker (&:where([data-theme="hacker"] *));
5
5
+
6
6
+
html, body, .app-container {
7
7
+
font-size: 14px;
8
8
+
height: 100%;
9
9
+
text-align: left;
10
10
+
line-height: 1.3;
11
11
+
letter-spacing: -0.025em;
12
12
+
13
13
+
background-color: var(--color-bg);
14
14
+
color: var(--color-primary);
15
15
+
}
16
16
+
17
17
+
* {
18
18
+
box-sizing: border-box;
19
19
+
scrollbar-width: thin;
20
20
+
scrollbar-gutter: stable;
21
21
+
}
22
22
+
23
23
+
.material-icons {
24
24
+
font-family: 'Material Icons';
25
25
+
font-weight: normal;
26
26
+
font-style: normal;
27
27
+
font-size: 16px;
28
28
+
display: inline-block;
29
29
+
line-height: 1;
30
30
+
text-transform: none;
31
31
+
letter-spacing: normal;
32
32
+
word-wrap: normal;
33
33
+
white-space: nowrap;
34
34
+
direction: ltr;
35
35
+
36
36
+
/* Support for all WebKit browsers. */
37
37
+
-webkit-font-smoothing: antialiased;
38
38
+
/* Support for Safari and Chrome. */
39
39
+
text-rendering: optimizeLegibility;
40
40
+
41
41
+
/* Support for Firefox. */
42
42
+
-moz-osx-font-smoothing: grayscale;
43
43
+
44
44
+
/* Support for IE. */
45
45
+
font-feature-settings: 'liga';
46
46
+
}
47
47
+
48
48
+
.material-icons-outlined {
49
49
+
font-family: 'Material Icons Outlined';
50
50
+
font-weight: normal;
51
51
+
font-style: normal;
52
52
+
font-size: 16px;
53
53
+
display: inline-block;
54
54
+
line-height: 1;
55
55
+
text-transform: none;
56
56
+
letter-spacing: normal;
57
57
+
word-wrap: normal;
58
58
+
white-space: nowrap;
59
59
+
direction: ltr;
60
60
+
61
61
+
/* Support for all WebKit browsers. */
62
62
+
-webkit-font-smoothing: antialiased;
63
63
+
/* Support for Safari and Chrome. */
64
64
+
text-rendering: optimizeLegibility;
65
65
+
66
66
+
/* Support for Firefox. */
67
67
+
-moz-osx-font-smoothing: grayscale;
68
68
+
69
69
+
/* Support for IE. */
70
70
+
font-feature-settings: 'liga';
71
71
+
}
72
72
+
73
73
+
/* VIDEO.JS */
74
74
+
75
75
+
::ng-deep .video-js .vjs-control-bar {
76
76
+
background: linear-gradient(to top, rgba(43, 51, 63, 0.7), transparent);
77
77
+
}
78
78
+
::ng-deep .video-js > .vjs-remaining-time {
79
79
+
height: 0;
80
80
+
position: absolute;
81
81
+
bottom: 0;
82
82
+
right: 0;
83
83
+
font-size: 0.75rem;
84
84
+
font-family: 'Inter', sans-serif;
85
85
+
opacity: 0;
86
86
+
}
87
87
+
::ng-deep .video-js.vjs-user-inactive .vjs-remaining-time {
88
88
+
height: 2rem;
89
89
+
opacity: 1;
90
90
+
transition: 1.5s opacity ease;
91
91
+
}
92
92
+
93
93
+
@theme {
94
94
+
--font-mono: "Source Code Pro", monospace;
95
95
+
--font-sans: "Source Code Pro", sans-serif;
96
96
+
97
97
+
--text-sm: 0.9285rem;
98
98
+
99
99
+
--color-bg: #FFF;
100
100
+
--color-primary: #000;
101
101
+
--color-secondary: #E1E1E1;
102
102
+
--color-repost: #00aa00;
103
103
+
--color-like: #F00000;
104
104
+
--color-selection-bg: rgba(0, 0, 0, 0.99);
105
105
+
--color-selection-text: #FFF;
106
106
+
--color-background: #FFF;
107
107
+
--color-text: var(--color-base);
108
108
+
--color-placeholder: var(--color-base);
109
109
+
--color-link: var(--color-base);
110
110
+
--color-code-1: #aaaaaa;
111
111
+
--color-code-2: #ffffcc;
112
112
+
--color-code-3: #F00000;
113
113
+
--color-code-4: #F0A0A0;
114
114
+
--color-code-5: #0000aa;
115
115
+
--color-code-6: #4c8317;
116
116
+
--color-code-7: #aa0000;
117
117
+
--color-code-8: #000080;
118
118
+
--color-code-9: #00aa00;
119
119
+
--color-code-10: #888888;
120
120
+
--color-code-11: #555555;
121
121
+
--color-code-12: #800080;
122
122
+
--color-code-13: #00aaaa;
123
123
+
--color-code-14: #009999;
124
124
+
--color-code-15: #aa5500;
125
125
+
--color-code-16: #1e90ff;
126
126
+
--color-code-17: #800000;
127
127
+
--color-code-18: #bbbbbb;
128
128
+
}
129
129
+
130
130
+
@custom-variant midnight {
131
131
+
&:where([data-theme="midnight"] *) {
132
132
+
--color-base: #DBDBDB;
133
133
+
--border: dashed 1px rgba(219, 219, 219, 0.9);
134
134
+
--color-selection-bg: rgba(219, 219, 219, 0.99);
135
135
+
--color-selection-text: #000;
136
136
+
--color-background: #000;
137
137
+
--color-text: var(--color-base);
138
138
+
--color-placeholder: var(--color-base);
139
139
+
--color-link: var(--color-base);
140
140
+
--color-code-1: #aaaaaa;
141
141
+
--color-code-2: #ffffcc;
142
142
+
--color-code-3: #F00000;
143
143
+
--color-code-4: #F0A0A0;
144
144
+
--color-code-5: #b38aff;
145
145
+
--color-code-6: #5ba711;
146
146
+
--color-code-7: #e4e477;
147
147
+
--color-code-8: #000080;
148
148
+
--color-code-9: #05ca05;
149
149
+
--color-code-10: #888888;
150
150
+
--color-code-11: #555555;
151
151
+
--color-code-12: #800080;
152
152
+
--color-code-13: #00d4d4;
153
153
+
--color-code-14: #00c1c1;
154
154
+
--color-code-15: #ed9d13;
155
155
+
--color-code-16: #1e90ff;
156
156
+
--color-code-17: #800000;
157
157
+
--color-code-18: #bbbbbb;
158
158
+
}
159
159
+
}
160
160
+
161
161
+
@custom-variant hacker {
162
162
+
&:where([data-theme="hacker"] *) {
163
163
+
--color-base: #00ff00;
164
164
+
--border: dashed 1px rgba(0, 255, 0, 0.9);
165
165
+
--color-selection-bg: rgba(0, 255, 0, 0.99);
166
166
+
--color-selection-text: #000;
167
167
+
--color-background: #000;
168
168
+
--color-text: var(--color-base);
169
169
+
--color-placeholder: var(--color-base);
170
170
+
--color-link: var(--color-base);
171
171
+
--color-code-1: #aaaaaa;
172
172
+
--color-code-2: #ffffcc;
173
173
+
--color-code-3: #F00000;
174
174
+
--color-code-4: #F0A0A0;
175
175
+
--color-code-5: #b38aff;
176
176
+
--color-code-6: #5ba711;
177
177
+
--color-code-7: #e4e477;
178
178
+
--color-code-8: #000080;
179
179
+
--color-code-9: #05ca05;
180
180
+
--color-code-10: #888888;
181
181
+
--color-code-11: #555555;
182
182
+
--color-code-12: #800080;
183
183
+
--color-code-13: #00d4d4;
184
184
+
--color-code-14: #00c1c1;
185
185
+
--color-code-15: #ed9d13;
186
186
+
--color-code-16: #1e90ff;
187
187
+
--color-code-17: #800000;
188
188
+
--color-code-18: #bbbbbb;
189
189
+
}
190
190
+
}
191
191
+
192
192
+
@layer components {
193
193
+
.btn-primary {
194
194
+
box-sizing: border-box;
195
195
+
border: 1px solid var(--color-primary);
196
196
+
background-color: var(--color-primary);
197
197
+
color: var(--color-bg);
198
198
+
width: fit-content;
199
199
+
text-box: trim-both cap alphabetic;
200
200
+
padding: 0.5em 0.75em;
201
201
+
cursor: pointer;
202
202
+
min-height: 2em;
203
203
+
204
204
+
&:hover {
205
205
+
background-color: var(--color-bg);
206
206
+
color: var(--color-primary);
207
207
+
}
208
208
+
}
209
209
+
210
210
+
.btn-secondary {
211
211
+
box-sizing: border-box;
212
212
+
border: 1px solid var(--color-primary);
213
213
+
background-color: var(--color-bg);
214
214
+
color: var(--color-primary);
215
215
+
width: fit-content;
216
216
+
text-box: trim-both cap alphabetic;
217
217
+
padding: 0.5em 0.75em;
218
218
+
cursor: pointer;
219
219
+
min-height: 2em;
220
220
+
221
221
+
&:hover {
222
222
+
background-color: color-mix(in oklab, var(--color-primary) /* #000 = #000000 */ 15%, transparent);
223
223
+
color: var(--color-primary);
224
224
+
}
225
225
+
}
226
226
+
227
227
+
.btn-dropdown {
228
228
+
text-box: trim-both cap alphabetic;
229
229
+
box-sizing: border-box;
230
230
+
background-color: var(--color-bg);
231
231
+
color: var(--color-primary);
232
232
+
width: 100%;
233
233
+
text-align: left;
234
234
+
font-family: var(--font-mono);
235
235
+
padding: 0.5em 0.75em;
236
236
+
cursor: pointer;
237
237
+
238
238
+
&:hover {
239
239
+
background-color: var(--color-primary);
240
240
+
color: var(--color-bg);
241
241
+
}
242
242
+
}
243
243
+
}
244
244
+
+15
-2
tsconfig.json
···
3
3
{
4
4
"compileOnSave": false,
5
5
"compilerOptions": {
6
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
18
-
"target": "ES2022",
19
19
-
"module": "ES2022"
19
19
+
"target": "ES2023",
20
20
+
"module": "ESNext",
21
21
+
"paths": {
22
22
+
"@components/*": ["app/components/*"],
23
23
+
"@core/*": ["app/core/*"],
24
24
+
"@models/*": ["app/models/*"],
25
25
+
"@services/*": ["app/services/*"],
26
26
+
"@shared/*": ["app/shared/*"],
27
27
+
"@views/*": ["app/views/*"]
28
28
+
},
29
29
+
"strictPropertyInitialization": false,
30
30
+
"verbatimModuleSyntax": false,
31
31
+
"strictNullChecks": false,
32
32
+
"noImplicitAny": false
20
33
},
21
34
"angularCompilerOptions": {
22
35
"enableI18nLegacyMessageIdFormat": false,