Multicolumn Bluesky client powered by Angular

feat: thread view

+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
··· 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
··· 1 1 <div 2 - class="flex gap-2" 3 - (click)="onClick.emit(notification())" 2 + class="flex gap-3" 4 3 > 5 4 @if ( 6 5 (notification() | isLikeNotification) || ··· 76 75 > 77 76 <avatar 78 77 [src]="author.avatar" 78 + (click)="openAuthor($event, author.did)" 79 79 /> 80 80 </a> 81 81 }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 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
··· 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 }
+4 -4
src/app/components/feeds/author-feed/author-feed.component.html
··· 12 12 [reply]="post.reply" 13 13 [reason]="post.reason" 14 14 (postChange)="post.post.set($event)" 15 + (click)="dialogService.openThread(post.post().uri)" 16 + (onEmbedRecord)="dialogService.openRecord($event)" 15 17 class="cursor-pointer hover:bg-primary/2 w-full py-3 px-2" 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-->
+5 -12
src/app/components/feeds/author-feed/author-feed.component.ts
··· 19 19 import {from} from 'rxjs'; 20 20 import {PostCardComponent} from '@components/cards/post-card/post-card.component'; 21 21 import {MessageService} from '@services/message.service'; 22 + import {DialogService} from '@services/dialog.service'; 23 + import {DividerComponent} from '@components/shared/divider/divider.component'; 22 24 23 25 @Component({ 24 26 selector: 'author-feed', ··· 26 28 CommonModule, 27 29 ScrollDirective, 28 30 PostCardComponent, 31 + DividerComponent, 29 32 ], 30 33 templateUrl: './author-feed.component.html', 31 34 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() { ··· 103 107 }, 500); 104 108 }, error: err => this.messageService.error(err.message) 105 109 }); 106 - } 107 - 108 - openPost(uri: string) { 109 - //TODO: OpenPost 110 - 111 - // Mute all video players 112 - // this.feed().nativeElement.querySelectorAll('video').forEach((video: HTMLVideoElement) => { 113 - // video.muted = true; 114 - // }); 115 - // 116 - // this.dialogService.openThread(uri, this.feed().nativeElement); 117 110 } 118 111 119 112 manageRefresh() {
+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
··· 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
··· 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
··· 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() {
+39 -4
src/app/components/navigation/auxbar/auxbar.component.html
··· 1 1 <div 2 2 class="h-full w-xs flex flex-col" 3 + [class.!w-lg]="dialogService.auxPanes().length" 3 4 > 4 5 <div 5 6 class="flex h-9 w-full shrink-0" 7 + [ngClass]="{'border-b border-primary': dialogService.auxPanes().length}" 6 8 > 7 - <!-- <span--> 8 - <!-- class="bg-primary text-bg text-xl font-bold flex items-center px-3"--> 9 - <!-- >//consolesky.</span>--> 9 + @if (dialogService.auxPanes().length) { 10 + <span 11 + class="bg-primary text-bg text-xl font-medium flex items-center px-3" 12 + >thread view</span> 13 + <span 14 + (click)="closePane()" 15 + class="text-primary font-semibold flex items-center px-3 hover:underline cursor-pointer" 16 + >close</span> 17 + 18 + <span 19 + (click)="dialogService.auxPanes.set([])" 20 + class="text-primary font-semibold flex items-center px-3 hover:underline cursor-pointer ml-auto" 21 + >close all</span> 22 + } 10 23 </div> 11 24 <div 12 - class="flex flex-col flex-1 border-l border-primary" 25 + class="relative flex flex-col flex-1 min-h-0 min-w-0 border-l border-primary" 13 26 > 14 27 <span 15 28 class="text-lg font-bold ml-3" ··· 22 35 class="ml-3 mt-1" 23 36 >{{topic.topic}}</span> 24 37 } 38 + 39 + @if (dialogService.auxPanes().length) { 40 + <ng-container 41 + [ngTemplateOutlet]="auxPane" 42 + /> 43 + } 25 44 </div> 26 45 27 46 <div ··· 32 51 /> 33 52 </div> 34 53 </div> 54 + 55 + <ng-template 56 + #auxPane 57 + > 58 + @for (pane of dialogService.auxPanes(); track $index) { 59 + <div 60 + class="absolute h-full w-full bg-bg overflow-y-auto" 61 + > 62 + @if (pane | isAuxPaneThread) { 63 + <thread-view 64 + [uri]="pane.uri" 65 + /> 66 + } 67 + </div> 68 + } 69 + </ng-template>
+21 -5
src/app/components/navigation/auxbar/auxbar.component.ts
··· 1 1 import {ChangeDetectionStrategy, ChangeDetectorRef, Component, signal} from '@angular/core'; 2 - import {PostService} from '@services/post.service'; 3 2 import {from} from 'rxjs'; 4 3 import {agent} from '@core/bsky.api'; 5 4 import {AuthService} from '@core/auth/auth.service'; 6 5 import type * as AppBskyUnspeccedDefs from '@atproto/api/src/client/types/app/bsky/unspecced/defs'; 7 6 import {LoggerComponent} from '@components/shared/logger/logger.component'; 7 + import {DialogService} from '@services/dialog.service'; 8 + import {IsAuxPaneThreadPipe} from '@shared/pipes/type-guards/is-auxpane-thread'; 9 + import {ThreadViewComponent} from '@components/aux-panes/thread-view/thread-view.component'; 10 + import {NgClass, NgTemplateOutlet} from '@angular/common'; 11 + import {MessageService} from '@services/message.service'; 8 12 9 13 @Component({ 10 14 selector: 'auxbar', 11 15 imports: [ 12 - LoggerComponent 16 + LoggerComponent, 17 + IsAuxPaneThreadPipe, 18 + ThreadViewComponent, 19 + NgTemplateOutlet, 20 + NgClass 13 21 ], 14 22 templateUrl: './auxbar.component.html', 15 23 changeDetection: ChangeDetectionStrategy.OnPush ··· 18 26 topics = signal<AppBskyUnspeccedDefs.TrendingTopic[]>([]); 19 27 20 28 constructor( 21 - protected postService: PostService, 29 + protected dialogService: DialogService, 30 + private messageService: MessageService, 22 31 private authService: AuthService, 23 32 private cdRef: ChangeDetectorRef 24 33 ) { ··· 33 42 next: response => { 34 43 this.topics.set(response.data.topics); 35 44 this.cdRef.markForCheck(); 36 - } 37 - }) 45 + }, error: err => this.messageService.error(err.message) 46 + }); 47 + } 48 + 49 + closePane() { 50 + this.dialogService.auxPanes.update(panes => { 51 + panes.pop(); 52 + return panes; 53 + }); 38 54 } 39 55 }
+18
src/app/components/shared/divider/divider.component.ts
··· 1 + import {ChangeDetectionStrategy, Component} from '@angular/core'; 2 + 3 + @Component({ 4 + selector: 'divider', 5 + template: ` 6 + <div 7 + class="border-b border-b-primary/10 w-9/10" 8 + style="mask-image: linear-gradient(to right, transparent, #000000 50%, transparent);" 9 + ></div> 10 + `, 11 + styles: ` 12 + :host() { 13 + width: 100%; 14 + } 15 + `, 16 + changeDetection: ChangeDetectionStrategy.OnPush 17 + }) 18 + export class DividerComponent {}
+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
··· 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 }
+12
src/app/shared/pipes/type-guards/is-auxpane-thread.ts
··· 1 + import {Pipe, PipeTransform} from '@angular/core'; 2 + import {AuxPaneType, ThreadAuxPane} from '@models/aux-pane'; 3 + 4 + @Pipe({ 5 + name: 'isAuxPaneThread' 6 + }) 7 + export class IsAuxPaneThreadPipe implements PipeTransform { 8 + transform(value: unknown): value is ThreadAuxPane { 9 + const typedValue = value as ThreadAuxPane; 10 + return typedValue && typedValue.type && typedValue.type == AuxPaneType.THREAD; 11 + } 12 + }
-1
src/styles.css
··· 242 242 } 243 243 244 244 .btn-dropdown { 245 - text-box: trim-both cap alphabetic; 246 245 box-sizing: border-box; 247 246 background-color: var(--color-bg); 248 247 color: var(--color-primary);