Thread viewer for Bluesky
1<script lang="ts">
2 import { api } from '../api.js';
3
4 export type AutocompleteUser = {
5 did: string;
6 handle: string;
7 avatar?: string;
8 displayName?: string;
9 }
10
11 let { selectedUsers = $bindable([]) }: { selectedUsers: AutocompleteUser[] } = $props();
12
13 let typedValue = $state('');
14 let autocompleteResults: AutocompleteUser[] = $state([]);
15 let autocompleteIndex = $state(-1);
16
17 let selectedUserDIDs: string[] = $derived(selectedUsers.map(u => u.did));
18 let autocompleteVisible = $derived(autocompleteResults.length > 0);
19 let autocompleteVerticalOffset = $state(0);
20
21 let autocompleteTimer: number | undefined;
22
23 $effect(() => {
24 let html = document.body.parentNode!
25 html.addEventListener('click', hideAutocomplete);
26
27 return () => {
28 html.removeEventListener('click', hideAutocomplete);
29 };
30 });
31
32 function onTextInput() {
33 if (autocompleteTimer) {
34 clearTimeout(autocompleteTimer);
35 }
36
37 let query = typedValue.trim();
38
39 if (query.length > 0) {
40 autocompleteTimer = setTimeout(() => fetchAutocomplete(query), 100);
41 } else {
42 hideAutocomplete();
43 autocompleteTimer = undefined;
44 }
45 }
46
47 function onKeyPress(e: KeyboardEvent) {
48 if (e.key == 'Enter') {
49 e.preventDefault();
50
51 if (autocompleteIndex >= 0) {
52 selectUser(autocompleteIndex);
53 }
54 } else if (e.key == 'Escape') {
55 hideAutocomplete();
56 } else if (e.key == 'ArrowDown' && autocompleteResults.length > 0) {
57 e.preventDefault();
58 moveAutocomplete(+1);
59 } else if (e.key == 'ArrowUp' && autocompleteResults.length > 0) {
60 e.preventDefault();
61 moveAutocomplete(-1);
62 }
63 }
64
65 async function fetchAutocomplete(query: string) {
66 let users = await api.autocompleteUsers(query) as AutocompleteUser[];
67
68 let selectedDIDs = new Set(selectedUserDIDs);
69 users = users.filter(u => !selectedDIDs.has(u.did));
70
71 if (users.length > 0) {
72 autocompleteResults = users;
73 autocompleteIndex = 0;
74 } else {
75 hideAutocomplete();
76 }
77 }
78
79 function hideAutocomplete() {
80 autocompleteResults = [];
81 autocompleteIndex = -1;
82 }
83
84 function moveAutocomplete(change: 1 | -1) {
85 if (autocompleteResults.length == 0) {
86 return;
87 }
88
89 let newIndex = autocompleteIndex + change;
90
91 if (newIndex < 0) {
92 newIndex = autocompleteResults.length - 1;
93 } else if (newIndex >= autocompleteResults.length) {
94 newIndex = 0;
95 }
96
97 autocompleteIndex = newIndex;
98 }
99
100 function selectAutocomplete(e: MouseEvent, index: number) {
101 e.preventDefault();
102 selectUser(index);
103 }
104
105 function selectUser(index: number) {
106 let user = autocompleteResults[index];
107
108 if (!user) {
109 return;
110 }
111
112 selectedUsers.push(user);
113 typedValue = '';
114 hideAutocomplete();
115 }
116
117 function removeUser(e: MouseEvent, index: number) {
118 e.preventDefault();
119 selectedUsers.splice(index, 1);
120 }
121</script>
122
123<div class="user-choice">
124 <input type="text" placeholder="Add user" autocomplete="off" autofocus
125 oninput={onTextInput}
126 onkeydown={onKeyPress}
127 bind:value={typedValue}
128 bind:offsetHeight={autocompleteVerticalOffset}>
129
130 {#if autocompleteVisible}
131 <div class="autocomplete"
132 style:display={autocompleteVisible ? 'block' : 'none'}
133 style:top="{autocompleteVerticalOffset}px">
134
135 {#each autocompleteResults as user, i (user.did)}
136 <div class="user-row"
137 class:highlighted={autocompleteIndex == i}
138 onmouseenter={() => { autocompleteIndex = i }}
139 onmousedown={(e) => { selectAutocomplete(e, i) }}>
140 {@render userRow(user)}
141 </div>
142 {/each}
143 </div>
144 {/if}
145
146 <div class="selected-users">
147 {#each selectedUsers as user, i (user.did)}
148 <div class="user-row">
149 {@render userRow(user)}
150 <a class="remove" href="#" onclick={(e) => { removeUser(e, i) }}>✕</a>
151 </div>
152 {/each}
153 </div>
154</div>
155
156{#snippet userRow(user: AutocompleteUser)}
157 <img class="avatar" alt="Avatar" src={user.avatar}>
158 <span class="name">{user.displayName || '–'}</span>
159 <span class="handle">{user.handle}</span>
160{/snippet}
161
162<style>
163 .user-choice {
164 position: relative;
165 }
166
167 input {
168 width: 260px;
169 font-size: 11pt;
170 }
171
172 .autocomplete {
173 position: absolute;
174 left: 0;
175 top: 0;
176 margin-top: 4px;
177 width: 350px;
178 max-height: 250px;
179 overflow-y: auto;
180 background-color: white;
181 border: 1px solid #ccc;
182 z-index: 10;
183 }
184
185 .selected-users {
186 width: 275px;
187 height: 150px;
188 overflow-y: auto;
189 border: 1px solid #aaa;
190 padding: 4px;
191 margin-top: 20px;
192 }
193
194 .user-row {
195 position: relative;
196 padding: 2px 4px 2px 37px;
197 cursor: pointer;
198 }
199
200 .user-row .avatar {
201 position: absolute;
202 left: 6px;
203 top: 8px;
204 width: 24px;
205 border-radius: 12px;
206 }
207
208 .user-row span {
209 display: block;
210 overflow-x: hidden;
211 text-overflow: ellipsis;
212 }
213
214 .user-row .name {
215 font-size: 11pt;
216 margin-top: 1px;
217 margin-bottom: 1px;
218 }
219
220 .user-row .handle {
221 font-size: 10pt;
222 margin-bottom: 2px;
223 color: #666;
224 }
225
226 .autocomplete .user-row {
227 cursor: pointer;
228 }
229
230 .autocomplete .user-row.highlighted {
231 background-color: hsl(207, 100%, 85%);
232 }
233
234 .selected-users .user-row span {
235 padding-right: 14px;
236 }
237
238 .selected-users .user-row .remove {
239 position: absolute;
240 right: 4px;
241 top: 11px;
242 padding: 0px 4px;
243 color: #333;
244 line-height: 17px;
245 }
246
247 .selected-users .user-row .remove:hover {
248 text-decoration: none;
249 background-color: #ddd;
250 border-radius: 8px;
251 }
252
253 @media (prefers-color-scheme: dark) {
254 .autocomplete {
255 background-color: hsl(210, 5%, 18%);
256 border-color: #4b4b4b;
257 }
258
259 .selected-users {
260 border-color: #666;
261 }
262
263 .user-row .handle {
264 color: #888;
265 }
266
267 .autocomplete .user-row.highlighted {
268 background-color: hsl(207, 90%, 25%);
269 }
270
271 .selected-users .user-row .remove {
272 color: #aaa;
273 }
274
275 .selected-users .user-row .remove:hover {
276 background-color: #555;
277 color: #bbb;
278 }
279 }
280</style>