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