Multicolumn Bluesky client powered by Angular

feat: user searching on search-view

kbenlloch ab54c4a4 25671cdc

+494 -190
+3 -21
src/app/components/aux-panes/author-view/author-view.component.html
··· 28 28 <div 29 29 class="flex w-full h-8 mt-2 px-3 justify-end" 30 30 > 31 - <ng-container 32 - [ngTemplateOutlet]="followBtn" 31 + 32 + <button-follow 33 + [author]="author()" 33 34 /> 34 35 </div> 35 36 ··· 133 134 <spinner/> 134 135 </div> 135 136 } 136 - 137 - <ng-template 138 - #followBtn 139 - > 140 - @if (author() | isLoggedUser) { 141 - <button 142 - class="btn-secondary py-0 h-full font-bold" 143 - disabled 144 - >omg it you ✨</button> 145 - } @else if (author().viewer.following) { 146 - <button 147 - class="btn-primary py-0 h-full font-bold" 148 - >following</button> 149 - } @else { 150 - <button 151 - class="btn-secondary py-0 h-full font-bold" 152 - >follow</button> 153 - } 154 - </ng-template>
+3 -4
src/app/components/aux-panes/author-view/author-view.component.ts
··· 12 12 import {agent} from '@core/bsky.api'; 13 13 import {MessageService} from '@services/message.service'; 14 14 import {AppBskyActorDefs} from '@atproto/api'; 15 - import {NgClass, NgTemplateOutlet} from '@angular/common'; 15 + import {NgClass} from '@angular/common'; 16 16 import {SpinnerComponent} from '@components/shared/spinner/spinner.component'; 17 17 import {AvatarComponent} from '@components/shared/avatar/avatar.component'; 18 18 import {DisplayNamePipe} from '@shared/pipes/display-name.pipe'; 19 19 import {RichTextComponent} from '@components/shared/rich-text/rich-text.component'; 20 20 import {AuthorFeedComponent} from '@components/feeds/author-feed/author-feed.component'; 21 21 import {ScrollDirective} from '@shared/directives/scroll.directive'; 22 - import {IsLoggedUserPipe} from '@shared/pipes/is-logged-user.pipe'; 23 22 import {DialogService} from '@services/dialog.service'; 23 + import {ButtonFollowComponent} from '@components/shared/button-follow/button-follow.component'; 24 24 25 25 @Component({ 26 26 selector: 'author-view', 27 27 imports: [ 28 28 SpinnerComponent, 29 29 AvatarComponent, 30 - NgTemplateOutlet, 31 30 DisplayNamePipe, 32 31 RichTextComponent, 33 32 NgClass, 34 33 AuthorFeedComponent, 35 34 ScrollDirective, 36 - IsLoggedUserPipe 35 + ButtonFollowComponent 37 36 ], 38 37 templateUrl: './author-view.component.html', 39 38 styles: `
+82 -154
src/app/components/aux-panes/search-view/search-view.component.html
··· 2 2 class="flex flex-col h-full min-h-0 w-full min-w-0" 3 3 > 4 4 <div 5 - class="flex relative" 5 + class="flex relative shrink-0" 6 6 > 7 7 <span 8 8 class="absolute font-black leading-7.5 ml-1.5" ··· 12 12 [ngModel]="query()" 13 13 (keyup)="triggerSuggestion($event, $any($event.target).value)" 14 14 (keydown.arrowDown)="focusNext(-1)" 15 - (keydown.enter)="query.set($any($event.target).value)" 15 + (keydown.enter)="query.set($any($event.target).value); userSuggestions = undefined" 16 16 placeholder="Search something" 17 - class="h-8 w-full border-b border-primary outline-none pl-5 pr-1 placeholder:opacity-50" 17 + class="h-8 w-full border-b border-r border-primary outline-none pl-5 pr-1 placeholder:opacity-50" 18 18 > 19 19 20 20 <ng-template ··· 44 44 </ul> 45 45 </ng-template> 46 46 </div> 47 - </div> 48 47 49 - <!--@if (savedQuery()) {--> 50 - <!-- <div--> 51 - <!-- #scroll--> 52 - <!-- vScroll--> 53 - <!-- (scrollEnding)="feed.nextData()"--> 54 - <!-- class="flex flex-col h-full overflow-hidden hover:overflow-y-auto"--> 55 - <!-- >--> 56 - <!-- <div--> 57 - <!-- #authorCard--> 58 - <!-- class="flex flex-col"--> 59 - <!-- >--> 60 - <!-- <avatar--> 61 - <!-- [src]="author().banner"--> 62 - <!-- (click)="dialogService.openImage([{fullsize: author().banner, thumb: undefined, alt: undefined}], 0)"--> 63 - <!-- class="w-full aspect-[3_/_1] cursor-pointer"--> 64 - <!-- />--> 65 - 66 - <!-- <div--> 67 - <!-- class="relative h-0 w-full"--> 68 - <!-- >--> 69 - <!-- <avatar--> 70 - <!-- [src]="author().avatar"--> 71 - <!-- (click)="dialogService.openImage([{fullsize: author().avatar, thumb: undefined, alt: undefined}], 0)"--> 72 - <!-- class="h-18 w-18 absolute -top-9 left-2 border-[0.25rem] border-bg !box-content cursor-pointer"--> 73 - <!-- />--> 74 - <!-- </div>--> 75 - 76 - <!-- <div--> 77 - <!-- class="flex w-full h-8 mt-2 px-3 justify-end"--> 78 - <!-- >--> 79 - <!-- <ng-container--> 80 - <!-- [ngTemplateOutlet]="followBtn"--> 81 - <!-- />--> 82 - <!-- </div>--> 83 - 84 - <!-- <span--> 85 - <!-- class="text-2xl font-bold mt-2 ml-3"--> 86 - <!-- >{{author() | displayName}}</span>--> 87 - 88 - <!-- @if (author().displayName) {--> 89 - <!-- <span--> 90 - <!-- class="ml-3 text-primary/50"--> 91 - <!-- >{{'@' + author().handle}}</span>--> 92 - <!-- }--> 93 - 94 - <!-- <div--> 95 - <!-- class="flex ml-3 mt-2 gap-4"--> 96 - <!-- >--> 97 - <!-- <a--> 98 - <!-- class="cursor-pointer hover:underline"--> 99 - <!-- >--> 100 - <!-- <span--> 101 - <!-- class="font-semibold"--> 102 - <!-- >{{author().postsCount}}</span>--> 103 - <!-- <span--> 104 - <!-- class="text-primary/50"--> 105 - <!-- >&#8196;posts</span>--> 106 - <!-- </a>--> 107 - <!-- <a--> 108 - <!-- class="cursor-pointer hover:underline"--> 109 - <!-- >--> 110 - <!-- <span--> 111 - <!-- class="font-semibold"--> 112 - <!-- >{{author().followersCount}}</span>--> 113 - <!-- <span--> 114 - <!-- class="text-primary/50"--> 115 - <!-- >&#8196;followers</span>--> 116 - <!-- </a>--> 117 - <!-- <a--> 118 - <!-- class="cursor-pointer hover:underline"--> 119 - <!-- >--> 120 - <!-- <span--> 121 - <!-- class="font-semibold"--> 122 - <!-- >{{author().followsCount}}</span>--> 123 - <!-- <span--> 124 - <!-- class="text-primary/50"--> 125 - <!-- >&#8196;follows</span>--> 126 - <!-- </a>--> 127 - <!-- </div>--> 128 - 129 - <!-- <rich-text--> 130 - <!-- [text]="author().description"--> 131 - <!-- class="text-sm px-3 mt-2"--> 132 - <!-- [class.line-clamp-3]="!expandBio()"--> 133 - <!-- />--> 134 - 135 - <!-- <a--> 136 - <!-- (click)="expandBio.set(!expandBio())"--> 137 - <!-- class="w-fit ml-3 text-primary/50 cursor-pointer hover:underline"--> 138 - <!-- >{{ expandBio() ? 'Show less' : 'Show more'}}</a>--> 48 + <div 49 + class="flex w-full h-10 shrink-0 bg-bg" 50 + > 51 + <button 52 + (click)="setFilter('top')" 53 + class="btn-secondary border-0 border-b border-b-primary/10 flex flex-1 items-center justify-center" 54 + [ngClass]="{'!border-b-primary font-semibold': searchType() == 'top'}" 55 + >top</button> 56 + <span 57 + (click)="setFilter('latest')" 58 + class="btn-secondary border-0 border-b border-b-primary/10 flex flex-1 items-center justify-center" 59 + [ngClass]="{'!border-b-primary font-semibold': searchType() == 'latest'}" 60 + >latest</span> 61 + <span 62 + (click)="setFilter('user')" 63 + class="btn-secondary border-0 border-b border-b-primary/10 flex flex-1 items-center justify-center" 64 + [ngClass]="{'!border-b-primary font-semibold': searchType() == 'user'}" 65 + >users</span> 66 + <span 67 + (click)="setFilter('generator')" 68 + class="btn-secondary border-0 border-b border-b-primary/10 flex flex-1 items-center justify-center" 69 + [ngClass]="{'!border-b-primary font-semibold': searchType() == 'generator'}" 70 + >feeds</span> 71 + </div> 139 72 140 - <!-- @if (author().viewer.knownFollowers) {--> 141 - <!-- <a--> 142 - <!-- class="w-fit ml-3 mt-3 text-primary/50 cursor-pointer hover:underline"--> 143 - <!-- >{{author().viewer.knownFollowers.count}} followers you know</a>--> 144 - <!-- }--> 145 - <!-- </div>--> 73 + @if (query()) { 74 + <div 75 + #scroll 76 + vScroll 77 + (scrollEnding)="loadMore()" 78 + class="flex flex-col h-full min-h-0 overflow-y-auto" 79 + > 80 + @if (searchType() == 'top') { 81 + <search-feed 82 + #feed 83 + [query]="query()" 84 + [sort]="'top'" 85 + /> 86 + } @else if (searchType() == 'latest') { 87 + <search-feed 88 + #feed 89 + [query]="query()" 90 + [sort]="'latest'" 91 + /> 92 + } @else if (searchType() == 'user') { 93 + <ng-container 94 + [ngTemplateOutlet]="usersTemplate" 95 + /> 96 + } @else if (searchType() == 'generator') { 97 + <ng-container 98 + [ngTemplateOutlet]="feedsTemplate" 99 + /> 100 + } 101 + </div> 102 + } 103 + </div> 146 104 147 - <!-- <div--> 148 - <!-- #selector--> 149 - <!-- class="relative h-0 mt-2"--> 150 - <!-- ></div>--> 151 - <!-- <div--> 152 - <!-- class="flex w-full h-10 sticky top-0 z-1 bg-bg"--> 153 - <!-- >--> 154 - <!-- <button--> 155 - <!-- (click)="setFilter('posts_no_replies')"--> 156 - <!-- class="btn-secondary border-0 border-b border-b-primary/10 flex flex-1 items-center justify-center"--> 157 - <!-- [ngClass]="{'!border-b-primary font-semibold': filter() == 'posts_no_replies'}"--> 158 - <!-- >posts</button>--> 159 - <!-- <span--> 160 - <!-- (click)="setFilter('posts_with_replies')"--> 161 - <!-- class="btn-secondary border-0 border-b border-b-primary/10 flex flex-1 items-center justify-center"--> 162 - <!-- [ngClass]="{'!border-b-primary font-semibold': filter() == 'posts_with_replies'}"--> 163 - <!-- >replies</span>--> 164 - <!-- <span--> 165 - <!-- (click)="setFilter('posts_with_media')"--> 166 - <!-- class="btn-secondary border-0 border-b border-b-primary/10 flex flex-1 items-center justify-center"--> 167 - <!-- [ngClass]="{'!border-b-primary font-semibold': filter() == 'posts_with_media'}"--> 168 - <!-- >media</span>--> 169 - <!-- </div>--> 105 + <ng-template 106 + #usersTemplate 107 + > 108 + @if (users()?.length) { 109 + @for (user of users(); track user.did) { 110 + <author-card 111 + [author]="user" 112 + (click)="dialogService.openAuthor(user)" 113 + class="px-3 py-2 cursor-pointer hover:bg-primary/2" 114 + /> 115 + <divider/> 116 + } 117 + } @else { 118 + <div 119 + class="h-full w-full flex justify-center mt-16 text-4xl" 120 + > 121 + <spinner/> 122 + </div> 123 + } 124 + </ng-template> 170 125 171 - <!-- <author-feed--> 172 - <!-- #feed--> 173 - <!-- [did]="author().did"--> 174 - <!-- [filter]="this.filter()"--> 175 - <!-- />--> 176 - <!-- </div>--> 177 - <!--} @else {--> 178 - <!-- <div--> 179 - <!-- class="h-full w-full flex justify-center mt-16 text-4xl"--> 180 - <!-- >--> 181 - <!-- <spinner/>--> 182 - <!-- </div>--> 183 - <!--}--> 126 + <ng-template 127 + #feedsTemplate 128 + > 184 129 185 - <!--<ng-template--> 186 - <!-- #followBtn--> 187 - <!--&gt;--> 188 - <!-- @if (author() | isLoggedUser) {--> 189 - <!-- <button--> 190 - <!-- class="btn-secondary py-0 h-full font-bold"--> 191 - <!-- disabled--> 192 - <!-- >omg it you ✨</button>--> 193 - <!-- } @else if (author().viewer.following) {--> 194 - <!-- <button--> 195 - <!-- class="btn-primary py-0 h-full font-bold"--> 196 - <!-- >following</button>--> 197 - <!-- } @else {--> 198 - <!-- <button--> 199 - <!-- class="btn-secondary py-0 h-full font-bold"--> 200 - <!-- >follow</button>--> 201 - <!-- }--> 202 - <!--</ng-template>--> 130 + </ng-template>
+49 -9
src/app/components/aux-panes/search-view/search-view.component.ts
··· 17 17 import {DialogService} from '@services/dialog.service'; 18 18 import {FormsModule} from '@angular/forms'; 19 19 import {CdkConnectedOverlay} from '@angular/cdk/overlay'; 20 + import {ScrollDirective} from '@shared/directives/scroll.directive'; 21 + import {SearchFeedComponent} from '@components/feeds/search-feed/search-feed.component'; 22 + import {SpinnerComponent} from '@components/shared/spinner/spinner.component'; 23 + import {NgClass, NgTemplateOutlet} from '@angular/common'; 24 + import {AuthorCardComponent} from '@components/cards/author-card/author-card.component'; 25 + import {DividerComponent} from '@components/shared/divider/divider.component'; 20 26 21 27 @Component({ 22 28 selector: 'search-view', 23 29 imports: [ 24 30 FormsModule, 25 - CdkConnectedOverlay 31 + CdkConnectedOverlay, 32 + ScrollDirective, 33 + SearchFeedComponent, 34 + SpinnerComponent, 35 + NgClass, 36 + NgTemplateOutlet, 37 + AuthorCardComponent, 38 + DividerComponent 26 39 ], 27 40 templateUrl: './search-view.component.html', 28 41 styles: ` 29 - :host ::ng-deep author-feed > div { 42 + :host ::ng-deep search-feed > div { 30 43 scrollbar-gutter: auto; 31 44 } 32 45 `, ··· 45 58 feeds = signal<AppBskyFeedDefs.GeneratorView[]>([]); 46 59 47 60 search = viewChild('search', {read: ElementRef}); 48 - selector = viewChild('selector', {read: ElementRef}); 49 61 scroll = viewChild('scroll', {read: ElementRef}); 50 - userTemplate = viewChild('userTemplate'); 51 - generatorTemplate = viewChild('generatorTemplate'); 62 + searchFeed = viewChild<SearchFeedComponent>('feed'); 52 63 suggestionTemplate = viewChild('suggestionTemplate', {read: ElementRef}); 53 64 54 65 userSuggestions: AppBskyActorDefs.ProfileViewBasic[]; ··· 60 71 private cdRef: ChangeDetectorRef 61 72 ) { 62 73 effect(() => { 63 - if (this.userTemplate() && this.query()) { 74 + if (this.query() && this.searchType() == 'user') { 64 75 this.initUsers(); 65 76 } 66 - if (this.generatorTemplate() && this.query()) { 77 + if (this.query() && this.searchType() == 'generator') { 67 78 this.initFeeds(); 68 79 } 69 80 }); ··· 84 95 initUsers() { 85 96 if (!this.query()) return; 86 97 87 - this.users.set([]); 88 98 agent.searchActors({ 89 99 q: this.query(), 90 - limit: 15 100 + limit: 30 91 101 }).then(response => { 92 102 this.users.set(response.data.actors); 93 103 this.cursor.set(response.data.cursor); ··· 183 193 this.suggestionTemplate().nativeElement.children[this.suggestionTemplate().nativeElement.children.length - 1].firstChild.focus(); 184 194 } else { 185 195 this.suggestionTemplate().nativeElement.children[index - 1].firstChild.focus(); 196 + } 197 + } 198 + 199 + setFilter(filter: 200 + | 'top' 201 + | 'latest' 202 + | 'user' 203 + | 'generator' 204 + ) { 205 + this.searchType.set(filter); 206 + 207 + this.users.set(undefined); 208 + this.feeds.set(undefined); 209 + 210 + if (this.searchType() == 'user') this.initUsers(); 211 + if (this.searchType() == 'generator') this.initFeeds(); 212 + } 213 + 214 + loadMore() { 215 + switch (this.searchType()) { 216 + case 'top': 217 + case 'latest': 218 + this.searchFeed().nextData(); 219 + break; 220 + case 'user': 221 + this.nextUsers(); 222 + break; 223 + case 'generator': 224 + this.nextFeeds(); 225 + break; 186 226 } 187 227 } 188 228 }
+40
src/app/components/cards/author-card/author-card.component.html
··· 1 + <div 2 + class="flex items-center w-full min-w-0 gap-2" 3 + > 4 + <avatar 5 + [src]="author().avatar" 6 + (click)="$event.stopPropagation(); dialogService.openAuthor(author())" 7 + class="h-12 w-12 shrink-0 cursor-pointer" 8 + /> 9 + 10 + <div 11 + class="flex flex-col flex-1 min-w-0 justify-center" 12 + > 13 + <div 14 + class="flex max-w-full items-center h-5 gap-2" 15 + > 16 + <span 17 + (click)="$event.stopPropagation(); dialogService.openAuthor(author())" 18 + class="text-lg font-bold [text-box:trim-both_cap_alphabetic] h-fit w-fit min-w-0 overflow-y-visible overflow-x-clip overflow-ellipsis whitespace-nowrap cursor-pointer hover:underline" 19 + >{{author() | displayName}}</span> 20 + 21 + @if (author().viewer?.followedBy) { 22 + <span 23 + class="text-sm font-bold [text-box:trim-both_cap_alphabetic] flex h-5 w-fit px-1 shrink-0 bg-primary text-bg " 24 + >follows you</span> 25 + } 26 + </div> 27 + 28 + @if (author().displayName?.trim().length) { 29 + <span 30 + (click)="$event.stopPropagation(); dialogService.openAuthor(author())" 31 + class="text-sm text-primary/50 [text-box:trim-both_cap_alphabetic] flex items-baseline h-5 w-fit min-w-0 overflow-y-visible overflow-x-clip whitespace-nowrap text-ellipsis cursor-pointer hover:underline" 32 + >{{'@' + author().handle}}</span> 33 + } 34 + </div> 35 + 36 + <button-follow 37 + [author]="author()" 38 + compact 39 + /> 40 + </div>
+49
src/app/components/cards/author-card/author-card.component.ts
··· 1 + import { 2 + booleanAttribute, 3 + ChangeDetectionStrategy, 4 + ChangeDetectorRef, 5 + Component, 6 + effect, 7 + input, 8 + model, 9 + output 10 + } from '@angular/core'; 11 + import {AppBskyActorDefs, AppBskyEmbedRecord} from '@atproto/api'; 12 + import {AvatarComponent} from '@components/shared/avatar/avatar.component'; 13 + import {DisplayNamePipe} from '@shared/pipes/display-name.pipe'; 14 + import {OverlayModule} from '@angular/cdk/overlay'; 15 + import {DialogService} from '@services/dialog.service'; 16 + import {ButtonFollowComponent} from '@components/shared/button-follow/button-follow.component'; 17 + 18 + @Component({ 19 + selector: 'author-card', 20 + imports: [ 21 + AvatarComponent, 22 + DisplayNamePipe, 23 + OverlayModule, 24 + ButtonFollowComponent 25 + ], 26 + templateUrl: './author-card.component.html', 27 + changeDetection: ChangeDetectionStrategy.OnPush 28 + }) 29 + export class AuthorCardComponent { 30 + author = model.required<Partial<{ 31 + did: string, 32 + handle: string, 33 + avatar?: string, 34 + displayName?: string, 35 + viewer?: AppBskyActorDefs.ViewerState 36 + }>>(); 37 + compact = input(false, {transform: booleanAttribute}); 38 + 39 + onEmbedRecord = output<AppBskyEmbedRecord.View>(); 40 + 41 + constructor( 42 + protected dialogService: DialogService, 43 + private cdRef: ChangeDetectorRef 44 + ) { 45 + effect(() => { 46 + if (this.author()) cdRef.markForCheck(); 47 + }); 48 + } 49 + }
-1
src/app/components/cards/post-card-detail/post-card-detail.component.html
··· 35 35 <div 36 36 class="flex w-full min-w-0 gap-2" 37 37 > 38 - 39 38 <avatar 40 39 [src]="author.avatar" 41 40 (click)="$event.stopPropagation(); dialogService.openAuthor(author)"
+27
src/app/components/feeds/search-feed/search-feed.component.html
··· 1 + <div 2 + #feed 3 + class="w-full h-full min-h-0 flex flex-col margin-[0_auto] overflow-hidden hover:overflow-y-auto transition items-center" 4 + vScroll 5 + (scrollEnding)="nextData(); manageRefresh();" 6 + (scrollTop)="manageRefresh();" 7 + > 8 + @if (posts) { 9 + @for (post of posts; track post().uri) { 10 + <post-card 11 + [post]="post()" 12 + (postChange)="post.set($event)" 13 + (click)="dialogService.openThread(post().uri)" 14 + (onEmbedRecord)="dialogService.openRecord($event)" 15 + class="cursor-pointer hover:bg-primary/2 w-full px-3 pt-3 pb-1" 16 + /> 17 + 18 + <divider/> 19 + } 20 + } @else { 21 + <div 22 + class="h-full w-full flex justify-center mt-16 text-4xl" 23 + > 24 + <spinner/> 25 + </div> 26 + } 27 + </div>
+171
src/app/components/feeds/search-feed/search-feed.component.ts
··· 1 + import { 2 + ChangeDetectionStrategy, 3 + ChangeDetectorRef, 4 + Component, 5 + effect, 6 + ElementRef, 7 + input, 8 + OnDestroy, 9 + OnInit, 10 + viewChild, 11 + WritableSignal, 12 + } from '@angular/core'; 13 + import {CommonModule} from "@angular/common"; 14 + import {agent} from '@core/bsky.api'; 15 + import {ScrollDirective} from '@shared/directives/scroll.directive'; 16 + import {AppBskyFeedDefs} from '@atproto/api'; 17 + import {PostService} from '@services/post.service'; 18 + import {from} from 'rxjs'; 19 + import {PostCardComponent} from '@components/cards/post-card/post-card.component'; 20 + import {MessageService} from '@services/message.service'; 21 + import {DialogService} from '@services/dialog.service'; 22 + import {DividerComponent} from '@components/shared/divider/divider.component'; 23 + import {FeedService} from '@services/feed.service'; 24 + import {SpinnerComponent} from '@components/shared/spinner/spinner.component'; 25 + 26 + @Component({ 27 + selector: 'search-feed', 28 + imports: [ 29 + CommonModule, 30 + ScrollDirective, 31 + PostCardComponent, 32 + DividerComponent, 33 + SpinnerComponent, 34 + ], 35 + templateUrl: './search-feed.component.html', 36 + changeDetection: ChangeDetectionStrategy.OnPush 37 + }) 38 + export class SearchFeedComponent implements OnInit, OnDestroy { 39 + feed = viewChild<ElementRef>('feed'); 40 + query = input.required<string>(); 41 + sort = input.required<'top' | 'latest'>(); 42 + 43 + posts: WritableSignal<AppBskyFeedDefs.PostView>[]; 44 + cursor: string; 45 + loading = true; 46 + reloadReady = false; 47 + reloadTimeout: ReturnType<typeof setTimeout>; 48 + 49 + constructor( 50 + private postService: PostService, 51 + private feedService: FeedService, 52 + protected dialogService: DialogService, 53 + private messageService: MessageService, 54 + private cdRef: ChangeDetectorRef 55 + ) { 56 + effect(() => { 57 + if (this.query() || this.sort()) { 58 + this.initData(); 59 + } 60 + }) 61 + } 62 + 63 + ngOnInit() { 64 + this.initData(); 65 + 66 + // Listen to new posts to refresh 67 + this.postService.refreshFeeds.subscribe({ 68 + next: () => { 69 + if (this.feed().nativeElement.scrollTop == 0) { 70 + this.initData(); 71 + } else { 72 + this.reloadReady = true; 73 + } 74 + } 75 + }); 76 + } 77 + 78 + ngOnDestroy() { 79 + this.postService.refreshFeeds.unsubscribe(); 80 + clearTimeout(this.reloadTimeout); 81 + } 82 + 83 + initData() { 84 + this.loading = true; 85 + from(agent.app.bsky.feed.searchPosts({ 86 + q: this.query(), 87 + sort: this.sort(), 88 + limit: 25 89 + })).subscribe({ 90 + next: response => { 91 + this.feed().nativeElement.scrollTo({top:0}); 92 + this.cursor = response.data.cursor; 93 + this.posts = response.data.posts.map(this.postService.setPost); 94 + this.cdRef.markForCheck(); 95 + setTimeout(() => { 96 + this.loading = false; 97 + this.manageRefresh(); 98 + }, 500); 99 + }, error: err => this.messageService.error(err.message) 100 + }); 101 + } 102 + 103 + nextData() { 104 + if (this.loading) return; 105 + this.loading = true; 106 + 107 + from(agent.app.bsky.feed.searchPosts({ 108 + q: this.query(), 109 + sort: this.sort(), 110 + cursor: this.cursor, 111 + limit: 25 112 + })).subscribe({ 113 + next: response => { 114 + this.cursor = response.data.cursor; 115 + this.posts = [...this.posts, ...response.data.posts.map(this.postService.setPost)]; 116 + this.cdRef.markForCheck(); 117 + setTimeout(() => { 118 + this.loading = false; 119 + }, 500); 120 + }, error: err => this.messageService.error(err.message) 121 + }); 122 + } 123 + 124 + manageRefresh() { 125 + if (this.loading) return; 126 + 127 + if (!this.reloadReady && !this.reloadTimeout) { 128 + this.reloadTimeout = setTimeout(() => { 129 + this.reloadTimeout = undefined; 130 + 131 + if (this.feed().nativeElement.scrollTop == 0) { 132 + this.reloadReady = false; 133 + from(agent.app.bsky.feed.searchPosts({ 134 + q: this.query(), 135 + sort: this.sort(), 136 + limit: 1 137 + })).subscribe({ 138 + next: response => { 139 + const post = response.data.posts[0]; 140 + const lastPost = this.posts[0](); 141 + let isNewPost = false; 142 + 143 + if (post.uri == lastPost.uri) { 144 + isNewPost = true; 145 + } 146 + 147 + if (isNewPost) { 148 + this.initData(); 149 + } else { 150 + this.manageRefresh(); 151 + } 152 + }, error: err => this.messageService.error(err.message) 153 + }); 154 + } else { 155 + this.reloadReady = true; 156 + } 157 + }, 30e3); 158 + // Timer in seconds 159 + } else if (this.reloadReady && this.feed().nativeElement.scrollTop == 0) { 160 + this.reloadReady = false; 161 + this.initData(); 162 + } 163 + } 164 + 165 + scrollToTop() { 166 + this.feed().nativeElement.scrollTo({ 167 + top: 0, 168 + behavior: 'smooth' 169 + }); 170 + } 171 + }
+41
src/app/components/shared/button-follow/button-follow.component.html
··· 1 + @if (compact() && !(author() | isLoggedUser)) { 2 + @if (author().viewer?.following) { 3 + <button 4 + (click)="onUnfollow.emit()" 5 + class="btn-primary h-8 w-8 p-0 flex items-center justify-center font-bold" 6 + > 7 + <span 8 + class="material-icons !text-xl" 9 + >how_to_reg</span> 10 + </button> 11 + } @else { 12 + <button 13 + (click)="onFollow.emit()" 14 + class="btn-secondary h-8 w-8 p-0 flex items-center justify-center font-bold" 15 + > 16 + <span 17 + class="material-icons !text-xl" 18 + >person_add</span> 19 + </button> 20 + } 21 + } @else { 22 + @if (author() | isLoggedUser) { 23 + <button 24 + class="btn-secondary py-0 h-full font-bold" 25 + disabled 26 + >omg it you ✨ 27 + </button> 28 + } @else if (author().viewer?.following) { 29 + <button 30 + (click)="onUnfollow.emit()" 31 + class="btn-primary py-0 h-full font-bold" 32 + >following 33 + </button> 34 + } @else { 35 + <button 36 + (click)="onFollow.emit()" 37 + class="btn-secondary py-0 h-full font-bold" 38 + >follow 39 + </button> 40 + } 41 + }
+21
src/app/components/shared/button-follow/button-follow.component.ts
··· 1 + import {booleanAttribute, Component, input, output} from '@angular/core'; 2 + import {IsLoggedUserPipe} from '@shared/pipes/is-logged-user.pipe'; 3 + import {AppBskyActorDefs} from '@atproto/api'; 4 + 5 + @Component({ 6 + selector: 'button-follow', 7 + imports: [ 8 + IsLoggedUserPipe 9 + ], 10 + templateUrl: './button-follow.component.html' 11 + }) 12 + export class ButtonFollowComponent { 13 + author = input.required<Partial<{ 14 + did: string, 15 + viewer?: AppBskyActorDefs.ViewerState 16 + }>>(); 17 + compact = input(false, {transform: booleanAttribute}); 18 + 19 + onFollow = output(); 20 + onUnfollow = output(); 21 + }
+1 -1
src/app/components/shared/rich-text/rich-text.component.html
··· 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 - (click)="$event.stopPropagation(); $event.preventDefault()" 24 + (click)="openSearch($event, segment)" 25 25 >{{segment.text}}</a> 26 26 } @else { 27 27 <span class="break-words">{{segment.text}}</span>
+7
src/app/components/shared/rich-text/rich-text.component.ts
··· 52 52 53 53 this.dialogService.openAuthor({did: segment.mention?.did, handle: segment.text.replace("@", "")}); 54 54 } 55 + 56 + openSearch(event: MouseEvent, segment: RichTextSegment) { 57 + event.preventDefault(); 58 + event.stopPropagation(); 59 + 60 + this.dialogService.openSearch(segment.text); 61 + } 55 62 }