+38
src/app/components/aux-panes/thread-view/thread-view.component.html
+38
src/app/components/aux-panes/thread-view/thread-view.component.html
···
1
+
@if (loadReady()) {
2
+
<div
3
+
#scroll
4
+
class="flex flex-col h-full overflow-y-auto"
5
+
>
6
+
@for (parent of parents(); track $index) {
7
+
@if ((parent | isFeedDefsNotFoundPost) || (parent | isFeedDefsBlockedPost)) {
8
+
<ng-container
9
+
[ngTemplateOutlet]="notPostView"
10
+
[ngTemplateOutletContext]="{post: parent}"
11
+
/>
12
+
} @else {
13
+
<post-card
14
+
[post]="parent()"
15
+
(postChange)="parent.set($event)"
16
+
(click)="dialogService.openThread(parent().uri)"
17
+
(onEmbedRecord)="dialogService.openRecord($event)"
18
+
class="cursor-pointer hover:bg-primary/2 w-full px-3 pt-3 pb-1"
19
+
parent
20
+
/>
21
+
}
22
+
}
23
+
24
+
<post-card-detail
25
+
#mainCard
26
+
[post]="post()"
27
+
class="block p-3"
28
+
/>
29
+
30
+
<divider/>
31
+
</div>
32
+
}
33
+
34
+
<ng-template
35
+
#notPostView
36
+
>
37
+
38
+
</ng-template>
+94
src/app/components/aux-panes/thread-view/thread-view.component.ts
+94
src/app/components/aux-panes/thread-view/thread-view.component.ts
···
1
+
import {
2
+
ChangeDetectionStrategy,
3
+
ChangeDetectorRef,
4
+
Component,
5
+
ElementRef,
6
+
input,
7
+
OnInit,
8
+
signal,
9
+
viewChild,
10
+
WritableSignal
11
+
} from '@angular/core';
12
+
import {PostService} from '@services/post.service';
13
+
import {from} from 'rxjs';
14
+
import {agent} from '@core/bsky.api';
15
+
import {MessageService} from '@services/message.service';
16
+
import {$Typed, AppBskyFeedDefs} from '@atproto/api';
17
+
import {PostCardDetailComponent} from '@components/cards/post-card-detail/post-card-detail.component';
18
+
import {IsFeedDefsNotFoundPostPipe} from '@shared/pipes/type-guards/is-feed-defs-notfoundpost';
19
+
import {IsFeedDefsBlockedPostPipe} from '@shared/pipes/type-guards/is-feed-defs-blockedpost';
20
+
import {PostCardComponent} from '@components/cards/post-card/post-card.component';
21
+
import {DialogService} from '@services/dialog.service';
22
+
import {NgTemplateOutlet} from '@angular/common';
23
+
import {DividerComponent} from '@components/shared/divider/divider.component';
24
+
25
+
@Component({
26
+
selector: 'thread-view',
27
+
imports: [
28
+
PostCardDetailComponent,
29
+
IsFeedDefsNotFoundPostPipe,
30
+
IsFeedDefsBlockedPostPipe,
31
+
PostCardComponent,
32
+
NgTemplateOutlet,
33
+
DividerComponent
34
+
],
35
+
templateUrl: './thread-view.component.html',
36
+
changeDetection: ChangeDetectionStrategy.OnPush
37
+
})
38
+
export class ThreadViewComponent implements OnInit {
39
+
uri = input.required<string>();
40
+
post = signal<AppBskyFeedDefs.PostView>(undefined);
41
+
parents = signal<WritableSignal<AppBskyFeedDefs.PostView>[]>([]);
42
+
43
+
loadReady = signal(false);
44
+
mainCard = viewChild('mainCard', {read: ElementRef});
45
+
scroll = viewChild('scroll', {read: ElementRef});
46
+
47
+
constructor(
48
+
private postService: PostService,
49
+
protected dialogService: DialogService,
50
+
private messageService: MessageService,
51
+
private cdRef: ChangeDetectorRef
52
+
) {}
53
+
54
+
ngOnInit() {
55
+
from(agent.getPostThread({
56
+
uri: this.uri(),
57
+
depth: 3
58
+
})).subscribe({
59
+
next: response => {
60
+
if (AppBskyFeedDefs.isThreadViewPost(response.data.thread)) {
61
+
const thread = response.data.thread as AppBskyFeedDefs.ThreadViewPost;
62
+
63
+
//Set main post
64
+
this.post = this.postService.setPost(response.data.thread.post);
65
+
66
+
//Set parents
67
+
if (thread.parent && AppBskyFeedDefs.isThreadViewPost(thread.parent)) {
68
+
const parents: WritableSignal<AppBskyFeedDefs.PostView>[] = [];
69
+
let parent: $Typed<AppBskyFeedDefs.ThreadViewPost> | $Typed<AppBskyFeedDefs.NotFoundPost> | $Typed<AppBskyFeedDefs.BlockedPost> = thread.parent as $Typed<AppBskyFeedDefs.ThreadViewPost>;
70
+
parents.unshift(this.postService.setPost(parent.post));
71
+
72
+
while (AppBskyFeedDefs.isThreadViewPost(parent.parent)) {
73
+
parent = parent.parent;
74
+
parents.unshift(this.postService.setPost(parent.post));
75
+
}
76
+
this.parents.set(parents);
77
+
}
78
+
79
+
this.loadReady.set(true);
80
+
this.cdRef.markForCheck();
81
+
82
+
if (thread.parent) {
83
+
setTimeout(() => {
84
+
this.scroll().nativeElement.scrollTo({
85
+
top: this.mainCard().nativeElement.offsetTop,
86
+
behavior: 'smooth'
87
+
});
88
+
}, 50);
89
+
}
90
+
}
91
+
}, error: err => this.messageService.error(err.message)
92
+
});
93
+
}
94
+
}
+2
-2
src/app/components/cards/notification-card/notification-card.component.html
+2
-2
src/app/components/cards/notification-card/notification-card.component.html
+2
-10
src/app/components/cards/notification-card/notification-card.component.ts
+2
-10
src/app/components/cards/notification-card/notification-card.component.ts
···
1
-
import {
2
-
ChangeDetectionStrategy,
3
-
ChangeDetectorRef,
4
-
Component,
5
-
input,
6
-
OnInit,
7
-
output,
8
-
WritableSignal
9
-
} from '@angular/core';
1
+
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, input, OnInit, WritableSignal} from '@angular/core';
10
2
import {Notification} from '@models/notification';
11
3
import {AvatarComponent} from '@components/shared/avatar/avatar.component';
12
4
import {IsLikeNotificationPipe} from '@shared/pipes/type-guards/notifications/is-like-notification.pipe';
···
41
33
})
42
34
export class NotificationCardComponent implements OnInit {
43
35
notification = input<Notification>();
44
-
onClick = output<Notification>();
45
36
post: WritableSignal<AppBskyFeedDefs.PostView>;
46
37
47
38
constructor(
···
56
47
57
48
openAuthor(event: Event, did: string) {
58
49
//TODO: OpenAuthor
50
+
event.stopPropagation();
59
51
}
60
52
61
53
openImage(event: Event, images: AppBskyEmbedImages.ViewImage[], index: number) {
+368
src/app/components/cards/post-card-detail/post-card-detail.component.html
+368
src/app/components/cards/post-card-detail/post-card-detail.component.html
···
1
+
<div
2
+
class="flex flex-col w-full min-w-0"
3
+
>
4
+
<ng-container
5
+
[ngTemplateOutlet]="header"
6
+
[ngTemplateOutletContext]="{author: post().author, reply: reply(), record: post().record, reason: reason()}"
7
+
/>
8
+
9
+
<ng-container
10
+
[ngTemplateOutlet]="record"
11
+
[ngTemplateOutletContext]="{record: post().record}"
12
+
/>
13
+
14
+
<ng-container
15
+
[ngTemplateOutlet]="embed"
16
+
[ngTemplateOutletContext]="{embed: post().embed}"
17
+
/>
18
+
19
+
<ng-container
20
+
[ngTemplateOutlet]="info"
21
+
/>
22
+
23
+
@if (!hideButtons()) {
24
+
<ng-container
25
+
[ngTemplateOutlet]="buttons"
26
+
/>
27
+
}
28
+
</div>
29
+
30
+
<ng-template
31
+
#header
32
+
let-author="author"
33
+
>
34
+
35
+
<div
36
+
class="flex w-full min-w-0 gap-2"
37
+
>
38
+
39
+
<avatar
40
+
[src]="author.avatar"
41
+
(click)="$event.stopPropagation()"
42
+
class="h-12 w-12 shrink-0"
43
+
/>
44
+
45
+
<div
46
+
class="flex flex-col flex-1 justify-center"
47
+
>
48
+
<span
49
+
class="text-lg font-bold [text-box:trim-both_cap_alphabetic] min-w-0 overflow-y-visible overflow-x-clip overflow-ellipsis whitespace-nowrap"
50
+
>{{author | displayName}}</span>
51
+
52
+
@if (author.displayName?.trim().length) {
53
+
<span
54
+
class="text-sm text-primary/50 [text-box:trim-both_cap_alphabetic] mt-3 min-w-0 overflow-y-visible overflow-x-clip whitespace-nowrap text-ellipsis"
55
+
>{{'@' + author.handle}}</span>
56
+
}
57
+
</div>
58
+
</div>
59
+
</ng-template>
60
+
61
+
<ng-template
62
+
#record
63
+
let-record="record"
64
+
>
65
+
@if ((record | isFeedPostRecord) && record.text?.length) {
66
+
<rich-text
67
+
[text]="record.text"
68
+
[facets]="record.facets"
69
+
class="mt-3"
70
+
/>
71
+
}
72
+
</ng-template>
73
+
74
+
<ng-template
75
+
#embed
76
+
let-embed="embed"
77
+
>
78
+
@if (embed | isEmbedRecordView) {
79
+
<record-embed
80
+
[record]="embed.record"
81
+
class="mt-3 p-3 hover:bg-primary/2"
82
+
/>
83
+
}
84
+
85
+
@if (embed | isEmbedImagesView) {
86
+
<images-embed
87
+
[images]="embed.images"
88
+
class="mt-4 cursor-pointer"
89
+
/>
90
+
}
91
+
92
+
@if (embed | isEmbedVideoView) {
93
+
<video-embed
94
+
[embed]="embed"
95
+
class="mt-4"
96
+
/>
97
+
}
98
+
99
+
@if (embed | isEmbedExternalView) {
100
+
<external-embed
101
+
[external]="embed.external"
102
+
class="mt-4"
103
+
/>
104
+
}
105
+
106
+
@if (embed | isEmbedRecordWithMediaView) {
107
+
@if (embed.media | isEmbedImagesView) {
108
+
<images-embed
109
+
[images]="embed.media.images"
110
+
class="mb-1 cursor-pointer"
111
+
[class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'"
112
+
/>
113
+
}
114
+
115
+
@if (embed.media | isEmbedVideoView) {
116
+
<video-embed
117
+
[embed]="embed.media"
118
+
[class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'"
119
+
/>
120
+
}
121
+
122
+
@if (embed.media | isEmbedExternalView) {
123
+
<external-embed
124
+
[external]="embed.media.external"
125
+
[class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'"
126
+
/>
127
+
}
128
+
129
+
<record-embed
130
+
[record]="embed.record.record"
131
+
class="mt-2 p-2 hover:bg-primary/2"
132
+
/>
133
+
}
134
+
</ng-template>
135
+
136
+
<ng-template
137
+
#info
138
+
>
139
+
140
+
@if (post().replyCount || post().repostCount || post().quoteCount || post().likeCount) {
141
+
<div
142
+
class="flex gap-6 mt-4 -mb-2 font-semibold"
143
+
>
144
+
@if (post().replyCount) {
145
+
<span
146
+
class="cursor-pointer hover:underline"
147
+
>{{post().replyCount}} replies</span>
148
+
}
149
+
@if (post().repostCount) {
150
+
<span
151
+
class="cursor-pointer hover:underline"
152
+
>{{post().repostCount}} reposts</span>
153
+
}
154
+
@if (post().quoteCount) {
155
+
<span
156
+
class="cursor-pointer hover:underline"
157
+
>{{post().quoteCount}} quotes</span>
158
+
}
159
+
@if (post().likeCount) {
160
+
<span
161
+
class="cursor-pointer hover:underline"
162
+
>{{post().likeCount}} likes</span>
163
+
}
164
+
</div>
165
+
}
166
+
167
+
<a
168
+
(click)="$event.stopPropagation()"
169
+
[href]="post().uri | linkExtractor: post().author.handle"
170
+
target="_blank"
171
+
class="text-sm mt-4 text-primary/50 hover:underline"
172
+
>{{ $any(post()).record.createdAt | date: 'medium' }}</a>
173
+
</ng-template>
174
+
175
+
<ng-template
176
+
#buttons
177
+
>
178
+
<div
179
+
class="flex mt-2 justify-between text-lg"
180
+
>
181
+
<button
182
+
class="flex w-fit p-2 items-center gap-1 hover:bg-primary/3 cursor-pointer"
183
+
(click)="replyAction($event)"
184
+
>
185
+
<span
186
+
class="material-icons-outlined"
187
+
>mode_comment</span>
188
+
189
+
@if (post().replyCount) {
190
+
<span
191
+
class="[text-box:trim-both_cap_alphabetic]"
192
+
>{{post().replyCount | numberFormatter}}</span>
193
+
}
194
+
</button>
195
+
196
+
<button
197
+
cdkOverlayOrigin
198
+
#rtMenu="cdkOverlayOrigin"
199
+
class="flex w-fit p-2 items-center gap-1 border border-transparent hover:bg-primary/3 cursor-pointer"
200
+
[ngClass]="{'bg-primary/3 !border-primary' : rtMenuVisible}"
201
+
(click)="$event.stopPropagation(); !processingAction ? rtMenuVisible = !rtMenuVisible : undefined"
202
+
>
203
+
<span
204
+
class="material-icons-outlined !text-[1.1em]"
205
+
[class]="post().viewer.repost ? 'text-repost' : undefined"
206
+
>repeat</span>
207
+
208
+
@if (post().repostCount) {
209
+
<span
210
+
class="[text-box:trim-both_cap_alphabetic]"
211
+
>{{post().repostCount | numberFormatter}}</span>
212
+
}
213
+
</button>
214
+
215
+
<ng-template
216
+
cdkConnectedOverlay
217
+
[cdkConnectedOverlayOrigin]="rtMenu"
218
+
[cdkConnectedOverlayOpen]="rtMenuVisible"
219
+
[cdkConnectedOverlayPositions]="[
220
+
{
221
+
originX: 'start',
222
+
originY: 'bottom',
223
+
overlayX: 'start',
224
+
overlayY: 'top',
225
+
offsetY: -1
226
+
},
227
+
{
228
+
originX: 'end',
229
+
originY: 'bottom',
230
+
overlayX: 'end',
231
+
overlayY: 'top',
232
+
offsetY: -1
233
+
},
234
+
{
235
+
originX: 'end',
236
+
originY: 'top',
237
+
overlayX: 'end',
238
+
overlayY: 'bottom',
239
+
offsetY: 1
240
+
}
241
+
]"
242
+
(detach)="rtMenuVisible = false"
243
+
(overlayOutsideClick)="rtMenuVisible = !rtMenuVisible"
244
+
>
245
+
<ul role="listbox" class="border border-primary">
246
+
<li>
247
+
<button
248
+
class="btn-dropdown"
249
+
(click)="repostAction($event)"
250
+
>
251
+
{{post().viewer.repost ? 'Undo Repost' : 'Repost'}}
252
+
</button>
253
+
</li>
254
+
255
+
@if (post().viewer.repost) {
256
+
<li>
257
+
<button
258
+
class="btn-dropdown"
259
+
(click)="refreshRepostAction($event)"
260
+
>
261
+
Repost again
262
+
</button>
263
+
</li>
264
+
}
265
+
266
+
<li>
267
+
<button
268
+
class="btn-dropdown"
269
+
(click)="quotePost()"
270
+
>
271
+
Quote post
272
+
</button>
273
+
</li>
274
+
</ul>
275
+
</ng-template>
276
+
277
+
<button
278
+
class="flex w-fit p-2 items-center gap-1 hover:bg-primary/3 cursor-pointer"
279
+
(click)="likeAction($event)"
280
+
>
281
+
<span
282
+
class="material-icons-outlined transition"
283
+
[class]="post().viewer.like ? 'text-like' : undefined"
284
+
>{{post().viewer.like ? 'favorite' : 'favorite_border'}}</span>
285
+
286
+
@if (post().likeCount) {
287
+
<span
288
+
class="[text-box:trim-both_cap_alphabetic]"
289
+
>{{post().likeCount | numberFormatter}}</span>
290
+
}
291
+
</button>
292
+
293
+
<button
294
+
cdkOverlayOrigin
295
+
#moreMenu="cdkOverlayOrigin"
296
+
class="flex w-fit p-2 items-center gap-1 border border-transparent hover:bg-primary/3 cursor-pointer"
297
+
[ngClass]="{'bg-primary/3 !border-primary' : moreMenuVisible}"
298
+
(click)="$event.stopPropagation(); moreMenuVisible = !moreMenuVisible"
299
+
>
300
+
<span
301
+
class="material-icons-outlined !text-[1.1em]"
302
+
[class]="post().viewer.repost ? 'text-repost' : undefined"
303
+
>more_horiz</span>
304
+
</button>
305
+
306
+
<ng-template
307
+
cdkConnectedOverlay
308
+
[cdkConnectedOverlayOrigin]="moreMenu"
309
+
[cdkConnectedOverlayOpen]="moreMenuVisible"
310
+
[cdkConnectedOverlayPositions]="[
311
+
{
312
+
originX: 'start',
313
+
originY: 'bottom',
314
+
overlayX: 'start',
315
+
overlayY: 'top',
316
+
offsetY: -1
317
+
},
318
+
{
319
+
originX: 'end',
320
+
originY: 'bottom',
321
+
overlayX: 'end',
322
+
overlayY: 'top',
323
+
offsetY: -1
324
+
},
325
+
{
326
+
originX: 'end',
327
+
originY: 'top',
328
+
overlayX: 'end',
329
+
overlayY: 'bottom',
330
+
offsetY: 1
331
+
}
332
+
]"
333
+
(detach)="moreMenuVisible = false"
334
+
(overlayOutsideClick)="moreMenuVisible = !moreMenuVisible"
335
+
>
336
+
<ul role="listbox" class="border border-primary">
337
+
<li>
338
+
<button
339
+
class="btn-dropdown"
340
+
(click)="repostAction($event)"
341
+
>
342
+
{{post().viewer.repost ? 'Undo Repost' : 'Repost'}}
343
+
</button>
344
+
</li>
345
+
346
+
@if (post().viewer.repost) {
347
+
<li>
348
+
<button
349
+
class="btn-dropdown"
350
+
(click)="refreshRepostAction($event)"
351
+
>
352
+
Repost again
353
+
</button>
354
+
</li>
355
+
}
356
+
357
+
<li>
358
+
<button
359
+
class="btn-dropdown"
360
+
(click)="quotePost()"
361
+
>
362
+
Quote post
363
+
</button>
364
+
</li>
365
+
</ul>
366
+
</ng-template>
367
+
</div>
368
+
</ng-template>
+161
src/app/components/cards/post-card-detail/post-card-detail.component.ts
+161
src/app/components/cards/post-card-detail/post-card-detail.component.ts
···
1
+
import {
2
+
booleanAttribute,
3
+
ChangeDetectionStrategy,
4
+
ChangeDetectorRef,
5
+
Component,
6
+
effect,
7
+
input,
8
+
model,
9
+
OnDestroy,
10
+
OnInit
11
+
} from '@angular/core';
12
+
import {AppBskyEmbedImages, AppBskyFeedDefs} from '@atproto/api';
13
+
import {AvatarComponent} from '@components/shared/avatar/avatar.component';
14
+
import {DisplayNamePipe} from '@shared/pipes/display-name.pipe';
15
+
import {IsFeedPostRecordPipe} from '@shared/pipes/type-guards/is-feed-post-record';
16
+
import {RichTextComponent} from '@components/shared/rich-text/rich-text.component';
17
+
import {DatePipe, NgClass, NgTemplateOutlet} from '@angular/common';
18
+
import {IsEmbedRecordViewPipe} from '@shared/pipes/type-guards/is-embed-record-view.pipe';
19
+
import {RecordEmbedComponent} from '@components/embeds/record-embed/record-embed.component';
20
+
import {IsEmbedImagesViewPipe} from '@shared/pipes/type-guards/is-embed-images-view.pipe';
21
+
import {ImagesEmbedComponent} from '@components/embeds/images-embed/images-embed.component';
22
+
import {LinkExtractorPipe} from '@shared/pipes/link-extractor.pipe';
23
+
import {IsEmbedVideoViewPipe} from '@shared/pipes/type-guards/is-embed-video-view.pipe';
24
+
import {VideoEmbedComponent} from '@components/embeds/video-embed/video-embed.component';
25
+
import {IsEmbedRecordWithMediaViewPipe} from '@shared/pipes/type-guards/is-embed-recordwithmedia-view.pipe';
26
+
import {NumberFormatterPipe} from '@shared/pipes/number-formatter.pipe';
27
+
import {PostService} from '@services/post.service';
28
+
import {OverlayModule} from '@angular/cdk/overlay';
29
+
import {ExternalEmbedComponent} from '@components/embeds/external-embed/external-embed.component';
30
+
import {IsEmbedExternalViewPipe} from '@shared/pipes/type-guards/is-embed-external-view.pipe';
31
+
import {MessageService} from '@services/message.service';
32
+
import {DialogService} from '@services/dialog.service';
33
+
34
+
@Component({
35
+
selector: 'post-card-detail',
36
+
imports: [
37
+
AvatarComponent,
38
+
DisplayNamePipe,
39
+
IsFeedPostRecordPipe,
40
+
RichTextComponent,
41
+
NgTemplateOutlet,
42
+
IsEmbedRecordViewPipe,
43
+
RecordEmbedComponent,
44
+
IsEmbedImagesViewPipe,
45
+
ImagesEmbedComponent,
46
+
LinkExtractorPipe,
47
+
IsEmbedVideoViewPipe,
48
+
VideoEmbedComponent,
49
+
IsEmbedRecordWithMediaViewPipe,
50
+
NumberFormatterPipe,
51
+
OverlayModule,
52
+
NgClass,
53
+
ExternalEmbedComponent,
54
+
IsEmbedExternalViewPipe,
55
+
DatePipe
56
+
],
57
+
templateUrl: './post-card-detail.component.html',
58
+
changeDetection: ChangeDetectionStrategy.OnPush,
59
+
providers: [
60
+
DatePipe
61
+
]
62
+
})
63
+
export class PostCardDetailComponent implements OnInit, OnDestroy {
64
+
post = model<AppBskyFeedDefs.PostView>();
65
+
reply = input<AppBskyFeedDefs.ReplyRef>();
66
+
reason = input<AppBskyFeedDefs.ReasonRepost | AppBskyFeedDefs.ReasonPin | { [k: string]: unknown; $type: string; }>();
67
+
hideButtons = input(false, {transform: booleanAttribute});
68
+
69
+
refreshInterval: ReturnType<typeof setInterval>;
70
+
processingAction = false;
71
+
rtMenuVisible = false;
72
+
moreMenuVisible = false;
73
+
74
+
constructor(
75
+
private postService: PostService,
76
+
private messageService: MessageService,
77
+
private dialogService: DialogService,
78
+
private cdRef: ChangeDetectorRef
79
+
) {
80
+
effect(() => {
81
+
if (this.post()) cdRef.markForCheck()
82
+
})
83
+
}
84
+
85
+
ngOnInit() {
86
+
this.refreshInterval = setInterval(() => this.cdRef.markForCheck(), 5e3);
87
+
}
88
+
89
+
ngOnDestroy() {
90
+
clearInterval(this.refreshInterval);
91
+
}
92
+
93
+
replyAction(event: Event) {
94
+
event.stopPropagation();
95
+
this.postService.replyPost(this.post().uri);
96
+
}
97
+
98
+
likeAction(event: Event) {
99
+
event.stopPropagation();
100
+
if (this.processingAction) return;
101
+
this.processingAction = true;
102
+
let promise: Promise<void>;
103
+
104
+
if (this.post().viewer.like) {
105
+
promise = this.postService.deleteLike(this.post);
106
+
} else {
107
+
promise = this.postService.like(this.post);
108
+
}
109
+
110
+
promise
111
+
.then(() => {
112
+
this.cdRef.markForCheck();
113
+
})
114
+
.catch(err => this.messageService.error(err.message))
115
+
.finally(() => this.processingAction = false);
116
+
}
117
+
118
+
repostAction(event: Event) {
119
+
event.stopPropagation();
120
+
if (this.processingAction) return;
121
+
this.rtMenuVisible = false;
122
+
this.processingAction = true;
123
+
let promise: Promise<void>;
124
+
125
+
if (this.post().viewer.repost) {
126
+
promise = this.postService.deleteRepost(this.post);
127
+
} else {
128
+
promise = this.postService.repost(this.post);
129
+
}
130
+
131
+
promise
132
+
.then(() => {
133
+
this.cdRef.markForCheck();
134
+
})
135
+
.catch(err => this.messageService.error(err.message))
136
+
.finally(() => this.processingAction = false);
137
+
}
138
+
139
+
refreshRepostAction(event: Event) {
140
+
event.stopPropagation();
141
+
if (this.processingAction) return;
142
+
this.rtMenuVisible = false;
143
+
this.processingAction = true;
144
+
145
+
this.postService.refreshRepost(this.post)
146
+
.then(() => {
147
+
this.cdRef.markForCheck();
148
+
})
149
+
.catch(err => this.messageService.error(err.message))
150
+
.finally(() => this.processingAction = false);
151
+
}
152
+
153
+
quotePost() {
154
+
this.postService.quotePost(this.post().uri);
155
+
this.rtMenuVisible = false;
156
+
}
157
+
158
+
openImage(images: AppBskyEmbedImages.ViewImage[], index: number) {
159
+
this.dialogService.openImage(images, index);
160
+
}
161
+
}
+53
-20
src/app/components/cards/post-card/post-card.component.html
+53
-20
src/app/components/cards/post-card/post-card.component.html
···
1
1
<div
2
-
class="flex gap-2"
2
+
class="flex gap-3"
3
3
>
4
-
<avatar
5
-
[src]="post().author.avatar"
6
-
class="h-12 w-12 shrink-0"
7
-
/>
4
+
<div
5
+
class="flex flex-col items-center shrink-0"
6
+
>
7
+
<avatar
8
+
[src]="post().author.avatar"
9
+
(click)="$event.stopPropagation()"
10
+
class="h-12 w-12"
11
+
/>
12
+
13
+
@if (parent()) {
14
+
<div
15
+
class="h-full w-[1px] mt-4 border-l border-primary/50"
16
+
></div>
17
+
}
18
+
</div>
19
+
8
20
<div
9
21
class="flex flex-col w-full min-w-0"
10
22
>
11
-
12
23
<ng-container
13
24
[ngTemplateOutlet]="header"
14
25
[ngTemplateOutletContext]="{author: post().author, reply: reply(), record: post().record, reason: reason()}"
···
73
84
74
85
@if (record | isFeedPostRecord) {
75
86
<a
87
+
(click)="$event.stopPropagation()"
76
88
[href]="post().uri | linkExtractor: author.handle"
77
89
target="_blank"
78
90
class="text-sm text-primary/50 hover:underline [text-box:trim-both_cap_alphabetic] shrink-0 ml-auto pl-3"
···
133
145
@if (embed | isEmbedRecordView) {
134
146
<record-embed
135
147
[record]="embed.record"
136
-
(onImgClick)="openImage($event.images, $event.index)"
148
+
(click)="emitEmbedRecord($event, embed)"
137
149
class="mt-2 p-2 hover:bg-primary/2"
138
150
/>
139
151
}
···
141
153
@if (embed | isEmbedImagesView) {
142
154
<images-embed
143
155
[images]="embed.images"
144
-
(onClick)="openImage(embed.images, $event)"
145
-
class="mb-1"
156
+
class="cursor-pointer"
146
157
[class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'"
147
158
/>
148
159
}
···
165
176
@if (embed.media | isEmbedImagesView) {
166
177
<images-embed
167
178
[images]="embed.media.images"
168
-
class="mb-1"
179
+
class="cursor-pointer"
169
180
[class]="$any(post().record).text.length ? 'mt-2' : 'mt-3'"
170
-
(onClick)="openImage(embed.media.images, $event)"
171
181
/>
172
182
}
173
183
···
187
197
188
198
<record-embed
189
199
[record]="embed.record.record"
190
-
(onImgClick)="openImage($event.images, $event.index)"
200
+
(click)="emitEmbedRecord($event, embed.record)"
191
201
class="mt-2 p-2 hover:bg-primary/2"
192
202
/>
193
203
}
···
203
213
class="w-16"
204
214
>
205
215
<button
206
-
class="flex w-fit h-7 p-2 items-center gap-1 hover:bg-primary/3 cursor-pointer"
216
+
class="flex w-fit h-8 p-2 items-center gap-1 hover:bg-primary/3 cursor-pointer"
207
217
(click)="replyAction($event)"
208
218
>
209
219
<span
···
223
233
>
224
234
<button
225
235
cdkOverlayOrigin
226
-
#trigger="cdkOverlayOrigin"
227
-
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"
236
+
#rtMenu="cdkOverlayOrigin"
237
+
class="flex w-fit h-8 p-2 items-center gap-1 border-t border-l border-r border-transparent hover:bg-primary/3 cursor-pointer"
228
238
[ngClass]="{'bg-primary/3 !border-primary' : rtMenuVisible}"
229
-
(click)="!processingAction ? rtMenuVisible = !rtMenuVisible : undefined"
239
+
(click)="$event.stopPropagation(); !processingAction ? rtMenuVisible = !rtMenuVisible : undefined"
230
240
>
231
241
<span
232
242
class="material-icons-outlined !text-[17px]"
233
243
[class]="post().viewer.repost ? 'text-repost' : undefined"
234
244
>repeat</span>
235
245
236
-
@if (post().repostCount) {
246
+
@if (post().repostCount || post().quoteCount) {
237
247
<span
238
248
class="[text-box:trim-both_cap_alphabetic]"
239
-
>{{post().repostCount | numberFormatter}}</span>
249
+
>{{(post().repostCount + post().quoteCount) | numberFormatter}}</span>
240
250
}
241
251
</button>
242
252
243
253
<ng-template
244
254
cdkConnectedOverlay
245
-
[cdkConnectedOverlayOrigin]="trigger"
255
+
[cdkConnectedOverlayOrigin]="rtMenu"
246
256
[cdkConnectedOverlayOpen]="rtMenuVisible"
257
+
[cdkConnectedOverlayPositions]="[
258
+
{
259
+
originX: 'start',
260
+
originY: 'bottom',
261
+
overlayX: 'start',
262
+
overlayY: 'top',
263
+
offsetY: -1
264
+
},
265
+
{
266
+
originX: 'end',
267
+
originY: 'bottom',
268
+
overlayX: 'end',
269
+
overlayY: 'top',
270
+
offsetY: -1
271
+
},
272
+
{
273
+
originX: 'end',
274
+
originY: 'top',
275
+
overlayX: 'end',
276
+
overlayY: 'bottom',
277
+
offsetY: 1
278
+
}
279
+
]"
247
280
(detach)="rtMenuVisible = false"
248
281
(overlayOutsideClick)="rtMenuVisible = !rtMenuVisible"
249
282
>
···
284
317
class="w-16"
285
318
>
286
319
<button
287
-
class="flex w-fit h-7 p-2 items-center gap-1 hover:bg-primary/3 cursor-pointer"
320
+
class="flex w-fit h-8 p-2 items-center gap-1 hover:bg-primary/3 cursor-pointer"
288
321
(click)="likeAction($event)"
289
322
>
290
323
<span
+10
-4
src/app/components/cards/post-card/post-card.component.ts
+10
-4
src/app/components/cards/post-card/post-card.component.ts
···
7
7
input,
8
8
model,
9
9
OnDestroy,
10
-
OnInit
10
+
OnInit,
11
+
output
11
12
} from '@angular/core';
12
-
import {AppBskyEmbedImages, AppBskyFeedDefs} from '@atproto/api';
13
+
import {AppBskyEmbedRecord, AppBskyFeedDefs} from '@atproto/api';
13
14
import {AvatarComponent} from '@components/shared/avatar/avatar.component';
14
15
import {DisplayNamePipe} from '@shared/pipes/display-name.pipe';
15
16
import {IsFeedPostRecordPipe} from '@shared/pipes/type-guards/is-feed-post-record';
···
67
68
reply = input<AppBskyFeedDefs.ReplyRef>();
68
69
reason = input<AppBskyFeedDefs.ReasonRepost | AppBskyFeedDefs.ReasonPin | { [k: string]: unknown; $type: string; }>();
69
70
hideButtons = input(false, {transform: booleanAttribute});
71
+
parent = input(false, {transform: booleanAttribute});
72
+
73
+
onEmbedRecord = output<AppBskyEmbedRecord.View>();
70
74
71
75
refreshInterval: ReturnType<typeof setInterval>;
72
76
processingAction = false;
···
118
122
119
123
repostAction(event: Event) {
120
124
event.stopPropagation();
125
+
121
126
if (this.processingAction) return;
122
127
this.rtMenuVisible = false;
123
128
this.processingAction = true;
···
156
161
this.rtMenuVisible = false;
157
162
}
158
163
159
-
openImage(images: AppBskyEmbedImages.ViewImage[], index: number) {
160
-
this.dialogService.openImage(images, index);
164
+
emitEmbedRecord(event: Event, record: AppBskyEmbedRecord.View) {
165
+
event.stopPropagation();
166
+
this.onEmbedRecord.emit(record);
161
167
}
162
168
}
+12
-4
src/app/components/dialogs/gallery/gallery.component.html
+12
-4
src/app/components/dialogs/gallery/gallery.component.html
···
2
2
class="flex flex-col gap-4 items-center justify-center absolute top-[1rem] left-[5rem] h-[calc(100%_-_4rem)] w-[calc(100%_-_10rem)] pointer-events-none"
3
3
[class]="{'h-[calc(100%_-_4rem)]': images.length > 1, 'h-[calc(100%_-_2rem)]': images.length == 1}"
4
4
>
5
-
<img
6
-
[src]="images[index].fullsize"
7
-
class="flex-1 min-h-0 min-w-0 w-fit pointer-events-auto"
8
-
/>
5
+
<a
6
+
(click)="$event.stopPropagation()"
7
+
[href]="images[index].fullsize"
8
+
target="_blank"
9
+
class="relative w-auto h-auto max-w-[calc(100%_-_10rem)] max-h-[calc(100%_-_4rem)] outline-none pointer-events-auto cursor-pointer"
10
+
>
11
+
<img
12
+
[src]="images[index].fullsize"
13
+
[alt]="images[index].alt"
14
+
class="w-auto h-auto max-w-full max-h-full"
15
+
/>
16
+
</a>
9
17
10
18
@if (images[index].alt) {
11
19
<span
+7
-3
src/app/components/embeds/images-embed/images-embed.component.ts
+7
-3
src/app/components/embeds/images-embed/images-embed.component.ts
···
1
-
import {ChangeDetectionStrategy, Component, input, output} from '@angular/core';
1
+
import {ChangeDetectionStrategy, Component, input} from '@angular/core';
2
2
import {AppBskyEmbedImages} from '@atproto/api';
3
3
import {NgOptimizedImage} from '@angular/common';
4
+
import {DialogService} from '@services/dialog.service';
4
5
5
6
@Component({
6
7
selector: 'images-embed',
···
12
13
})
13
14
export class ImagesEmbedComponent {
14
15
images = input<AppBskyEmbedImages.ViewImage[]>();
15
-
onClick = output<number>();
16
+
17
+
constructor(
18
+
private dialogService: DialogService
19
+
) {}
16
20
17
21
imgClick(index: number, event: Event) {
18
22
event.stopPropagation();
19
-
this.onClick.emit(index);
23
+
this.dialogService.openImage(this.images(), index);
20
24
}
21
25
}
+2
-3
src/app/components/embeds/record-embed/record-embed.component.html
+2
-3
src/app/components/embeds/record-embed/record-embed.component.html
···
2
2
3
3
<div
4
4
class="flex"
5
-
(click)="recordClick($event)"
6
5
>
7
6
<div
8
7
class="overflow-hidden shrink-0 h-5 w-9 flex items-center justify-center"
···
104
103
@if (media | isEmbedImagesView) {
105
104
<images-embed
106
105
[images]="media.images"
106
+
class="cursor-pointer"
107
107
[class]="margin"
108
-
(onClick)="onImgClick.emit({images: media.images, index: $event})"
109
108
/>
110
109
}
111
110
···
127
126
@if (media.media | isEmbedImagesView) {
128
127
<images-embed
129
128
[images]="media.media.images"
129
+
class="cursor-pointer"
130
130
[class]="margin"
131
-
(onClick)="onImgClick.emit({images: media.media.images, index: $event})"
132
131
/>
133
132
}
134
133
+2
-17
src/app/components/embeds/record-embed/record-embed.component.ts
+2
-17
src/app/components/embeds/record-embed/record-embed.component.ts
···
1
-
import {ChangeDetectionStrategy, Component, input, output} from '@angular/core';
2
-
import {
3
-
$Typed,
4
-
AppBskyEmbedImages,
5
-
AppBskyEmbedRecord,
6
-
AppBskyFeedDefs,
7
-
AppBskyGraphDefs,
8
-
AppBskyLabelerDefs
9
-
} from '@atproto/api';
1
+
import {ChangeDetectionStrategy, Component, input} from '@angular/core';
2
+
import {$Typed, AppBskyEmbedRecord, AppBskyFeedDefs, AppBskyGraphDefs, AppBskyLabelerDefs} from '@atproto/api';
10
3
import {DisplayNamePipe} from '@shared/pipes/display-name.pipe';
11
4
import {IsEmbedRecordViewRecordPipe} from '@shared/pipes/type-guards/is-embed-record-viewrecord.pipe';
12
5
import {NgTemplateOutlet} from '@angular/common';
···
67
60
>();
68
61
protected readonly AppBskyFeedDefs = AppBskyFeedDefs;
69
62
protected readonly AppBskyGraphDefs = AppBskyGraphDefs;
70
-
71
-
onClick = output();
72
-
onImgClick = output<{images: AppBskyEmbedImages.ViewImage[], index: number}>();
73
-
74
-
recordClick(event: Event) {
75
-
event.stopPropagation();
76
-
this.onClick.emit();
77
-
}
78
63
}
+7
-7
src/app/components/feeds/notification-feed/notification-feed.component.html
+7
-7
src/app/components/feeds/notification-feed/notification-feed.component.html
···
11
11
<post-card
12
12
[post]="notification.post()"
13
13
(postChange)="notification.post.set($event)"
14
-
class="cursor-pointer hover:bg-primary/2 w-full py-3 px-2"
14
+
(click)="dialogService.openThread(notification.post().uri)"
15
+
(onEmbedRecord)="dialogService.openRecord($event)"
16
+
class="cursor-pointer hover:bg-primary/2 w-full p-3"
15
17
/>
16
18
} @else {
17
19
<notification-card
18
20
[notification]="notification"
19
-
(onClick)="openNotification($event)"
20
-
class="cursor-pointer hover:bg-primary/2 w-full py-3 px-2"
21
+
(click)="openNotification(notification)"
22
+
class="cursor-pointer hover:bg-primary/2 w-full p-3"
21
23
/>
22
24
}
23
-
<div
24
-
class="border-b border-b-primary/10 w-9/10"
25
-
style="mask-image: linear-gradient(to right, transparent, #000000 50%, transparent);"
26
-
></div>
25
+
26
+
<divider/>
27
27
}
28
28
<!-- } @else {-->
29
29
<!-- <div-->
+14
-12
src/app/components/feeds/notification-feed/notification-feed.component.ts
+14
-12
src/app/components/feeds/notification-feed/notification-feed.component.ts
···
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
import {MessageService} from '@services/message.service';
21
+
import {DividerComponent} from '@components/shared/divider/divider.component';
22
+
import {DialogService} from '@services/dialog.service';
21
23
22
24
@Component({
23
25
selector: 'notification-feed',
···
27
29
PostCardComponent,
28
30
IsNotificationArrayPipe,
29
31
NotificationCardComponent,
32
+
DividerComponent,
30
33
],
31
34
templateUrl: './notification-feed.component.html',
32
35
changeDetection: ChangeDetectionStrategy.OnPush
···
42
45
43
46
constructor(
44
47
private postService: PostService,
48
+
protected dialogService: DialogService,
45
49
private messageService: MessageService,
46
-
public cdRef: ChangeDetectorRef
50
+
private cdRef: ChangeDetectorRef
47
51
) {}
48
52
49
53
ngOnInit() {
···
100
104
});
101
105
}
102
106
103
-
openNotification(notification: Notification) {
104
-
//TODO: OpenNotification
105
-
106
-
// Mute all video players
107
-
// this.feed().nativeElement.querySelectorAll('video').forEach((video: HTMLVideoElement) => {
108
-
// video.muted = true;
109
-
// });
110
-
//
111
-
// this.dialogService.openThread(uri, this.feed().nativeElement);
112
-
}
113
-
114
107
manageRefresh() {
115
108
if (this.loading) return;
116
109
···
142
135
} else if (this.reloadReady && this.feed().nativeElement.scrollTop == 0) {
143
136
this.reloadReady = false;
144
137
this.initData();
138
+
}
139
+
}
140
+
141
+
openNotification(notification: Notification) {
142
+
if (
143
+
notification.reason == 'like' ||
144
+
notification.reason == 'repost'
145
+
) {
146
+
this.dialogService.openThread(notification.uri)
145
147
}
146
148
}
147
149
}
+5
-5
src/app/components/feeds/timeline-feed/timeline-feed.component.html
+5
-5
src/app/components/feeds/timeline-feed/timeline-feed.component.html
···
12
12
[reply]="post.reply"
13
13
[reason]="post.reason"
14
14
(postChange)="post.post.set($event)"
15
-
class="cursor-pointer hover:bg-primary/2 w-full py-3 px-2"
15
+
(click)="dialogService.openThread(post.post().uri)"
16
+
(onEmbedRecord)="dialogService.openRecord($event)"
17
+
class="cursor-pointer hover:bg-primary/2 w-full px-3 pt-3 pb-1"
16
18
/>
17
-
<div
18
-
class="border-b border-b-primary/10 w-9/10"
19
-
style="mask-image: linear-gradient(to right, transparent, #000000 50%, transparent);"
20
-
></div>
19
+
20
+
<divider/>
21
21
}
22
22
<!-- } @else {-->
23
23
<!-- <div-->
+4
-11
src/app/components/feeds/timeline-feed/timeline-feed.component.ts
+4
-11
src/app/components/feeds/timeline-feed/timeline-feed.component.ts
···
18
18
import {from} from 'rxjs';
19
19
import {PostCardComponent} from '@components/cards/post-card/post-card.component';
20
20
import {MessageService} from '@services/message.service';
21
+
import {DialogService} from '@services/dialog.service';
22
+
import {DividerComponent} from '@components/shared/divider/divider.component';
21
23
22
24
@Component({
23
25
selector: 'timeline-feed',
···
25
27
CommonModule,
26
28
ScrollDirective,
27
29
PostCardComponent,
30
+
DividerComponent,
28
31
],
29
32
templateUrl: './timeline-feed.component.html',
30
33
changeDetection: ChangeDetectionStrategy.OnPush
···
41
44
constructor(
42
45
private postService: PostService,
43
46
private messageService: MessageService,
47
+
protected dialogService: DialogService,
44
48
public cdRef: ChangeDetectorRef
45
49
) {}
46
50
···
99
103
}, 500);
100
104
}, error: err => this.messageService.error(err.message)
101
105
});
102
-
}
103
-
104
-
openPost(uri: string) {
105
-
//TODO: OpenPost
106
-
107
-
// Mute all video players
108
-
// this.feed().nativeElement.querySelectorAll('video').forEach((video: HTMLVideoElement) => {
109
-
// video.muted = true;
110
-
// });
111
-
//
112
-
// this.dialogService.openThread(uri, this.feed().nativeElement);
113
106
}
114
107
115
108
manageRefresh() {
+38
src/app/models/aux-pane.ts
+38
src/app/models/aux-pane.ts
···
1
+
export class AuxPane {
2
+
}
3
+
4
+
export class ThreadAuxPane extends AuxPane {
5
+
type: AuxPaneType.THREAD = AuxPaneType.THREAD;
6
+
uri: string;
7
+
}
8
+
9
+
export class AuthorAuxPane extends AuxPane {
10
+
type: AuxPaneType.AUTHOR = AuxPaneType.AUTHOR;
11
+
did: string;
12
+
handle: string;
13
+
displayName: string;
14
+
}
15
+
16
+
export class ListAuxPane extends AuxPane {
17
+
type: AuxPaneType.LIST = AuxPaneType.LIST;
18
+
did: string;
19
+
}
20
+
21
+
export class GeneratorAuxPane extends AuxPane {
22
+
type: AuxPaneType.GENERATOR = AuxPaneType.GENERATOR;
23
+
uri: string;
24
+
}
25
+
26
+
export class SearchAuxPane extends AuxPane {
27
+
type: AuxPaneType.SEARCH = AuxPaneType.SEARCH;
28
+
query: string;
29
+
}
30
+
31
+
export enum AuxPaneType {
32
+
THREAD = 'THREAD',
33
+
AUTHOR = 'AUTHOR',
34
+
LIST = 'LIST',
35
+
GENERATOR = 'GENERATOR',
36
+
STARTER_PACK = 'STARTER_PACK',
37
+
SEARCH = 'SEARCH',
38
+
}
+42
-3
src/app/services/dialog.service.ts
+42
-3
src/app/services/dialog.service.ts
···
1
-
import {Injectable} from '@angular/core';
1
+
import {Injectable, signal} from '@angular/core';
2
2
import {Dialog} from '@angular/cdk/dialog';
3
3
import {GalleryComponent} from '@components/dialogs/gallery/gallery.component';
4
-
import {AppBskyEmbedImages} from '@atproto/api';
4
+
import {AppBskyEmbedImages, AppBskyEmbedRecord} from '@atproto/api';
5
+
import {AuxPane, ThreadAuxPane} from '@models/aux-pane';
5
6
6
7
@Injectable({
7
8
providedIn: 'root'
8
9
})
9
10
export class DialogService {
11
+
auxPanes = signal<AuxPane[]>([]);
10
12
11
13
constructor(
12
14
private dialog: Dialog
13
15
) {}
14
16
15
17
openImage(images: AppBskyEmbedImages.ViewImage[], index: number) {
16
-
const dialogRef = this.dialog.open(GalleryComponent, {
18
+
this.dialog.open(GalleryComponent, {
17
19
data: {images: images, index: index},
18
20
hasBackdrop: true
19
21
});
22
+
}
23
+
24
+
openThread(uri: string) {
25
+
// Cancel action if user is selecting text
26
+
if (window.getSelection().toString().length) return;
27
+
// Cancel action if post is the same than the last opened thread
28
+
if (
29
+
this.auxPanes().length &&
30
+
(this.auxPanes()[this.auxPanes().length-1] as ThreadAuxPane).uri &&
31
+
(this.auxPanes()[this.auxPanes().length-1] as ThreadAuxPane).uri == uri
32
+
) return;
33
+
// Mute all video players on auxbar
34
+
document.querySelector('auxbar').querySelectorAll('video').forEach((video: HTMLVideoElement) => {
35
+
video.muted = true;
36
+
});
37
+
38
+
const pane = new ThreadAuxPane();
39
+
pane.uri = uri;
40
+
this.auxPanes.update(panes => {
41
+
return [...panes, pane];
42
+
});
43
+
}
44
+
45
+
openRecord(record: AppBskyEmbedRecord.View) {
46
+
switch (record.record.$type) {
47
+
case 'app.bsky.embed.record#viewRecord':
48
+
this.openThread((record.record as AppBskyEmbedRecord.ViewRecord).uri);
49
+
break;
50
+
case 'app.bsky.graph.defs#listView':
51
+
break;
52
+
case 'app.bsky.feed.defs#generatorView':
53
+
break;
54
+
case 'app.bsky.graph.defs#starterPackViewBasic':
55
+
break;
56
+
case 'app.bsky.labeler.defs#labelerView':
57
+
break;
58
+
}
20
59
}
21
60
}