+14
-22
src/components/account.tsx
+14
-22
src/components/account.tsx
···
1
1
import { Client, CredentialManager } from "@atcute/client";
2
2
import { Did } from "@atcute/lexicons";
3
3
import { deleteStoredSession, getSession, OAuthUserAgent } from "@atcute/oauth-browser-client";
4
-
import { A } from "@solidjs/router";
5
4
import { createSignal, For, onMount, Show } from "solid-js";
6
5
import { createStore } from "solid-js/store";
7
6
import { resolveDidDoc } from "../utils/api.js";
···
76
75
<div class="mb-3 max-h-[20rem] overflow-y-auto md:max-h-[25rem]">
77
76
<For each={Object.keys(sessions)}>
78
77
{(did) => (
79
-
<div class="flex w-full items-center justify-between gap-x-2 rounded-lg hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600">
80
-
<button
81
-
class="flex basis-full items-center justify-between gap-1 truncate p-1"
82
-
onclick={() => resumeSession(did as Did)}
83
-
>
84
-
<span class="truncate">{sessions[did]?.length ? sessions[did] : did}</span>
85
-
<Show when={did === agent()?.sub}>
86
-
<span class="iconify lucide--check shrink-0"></span>
87
-
</Show>
88
-
</button>
89
-
<div class="flex items-center gap-1">
90
-
<A
91
-
href={`/at://${did}`}
92
-
onClick={() => setOpenManager(false)}
93
-
class="flex items-center p-1"
94
-
>
95
-
<span class="iconify lucide--book-user"></span>
96
-
</A>
78
+
<div class="flex items-center gap-1">
79
+
<div class="flex w-full items-center justify-between gap-x-2 rounded-lg hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-700 dark:active:bg-neutral-600">
97
80
<button
98
-
onclick={() => removeSession(did as Did)}
99
-
class="flex items-center p-1 hover:text-red-500 hover:dark:text-red-400"
81
+
class="flex basis-full items-center justify-between gap-1 truncate p-1"
82
+
onclick={() => resumeSession(did as Did)}
100
83
>
101
-
<span class="iconify lucide--user-round-x"></span>
84
+
<span class="truncate">{sessions[did]?.length ? sessions[did] : did}</span>
85
+
<Show when={did === agent()?.sub}>
86
+
<span class="iconify lucide--check shrink-0"></span>
87
+
</Show>
102
88
</button>
103
89
</div>
90
+
<button
91
+
onclick={() => removeSession(did as Did)}
92
+
class="flex items-center p-1 hover:text-red-500 hover:dark:text-red-400"
93
+
>
94
+
<span class="iconify lucide--user-round-x"></span>
95
+
</button>
104
96
</div>
105
97
)}
106
98
</For>
+54
-7
src/components/search.tsx
+54
-7
src/components/search.tsx
···
1
-
import { useLocation, useNavigate } from "@solidjs/router";
2
-
import { createSignal, onCleanup, onMount, Show } from "solid-js";
1
+
import { Client, CredentialManager } from "@atcute/client";
2
+
import { A, useLocation, useNavigate } from "@solidjs/router";
3
+
import { createResource, createSignal, For, onCleanup, onMount, Show, Suspense } from "solid-js";
3
4
import { isTouchDevice } from "../layout";
5
+
import { createDebouncedValue } from "../utils/hooks/debounced";
4
6
5
7
export const [showSearch, setShowSearch] = createSignal(false);
6
8
···
38
40
const Search = () => {
39
41
const navigate = useNavigate();
40
42
let searchInput!: HTMLInputElement;
43
+
const rpc = new Client({
44
+
handler: new CredentialManager({ service: "https://public.api.bsky.app" }),
45
+
});
41
46
42
47
onMount(() => {
43
48
if (useLocation().pathname !== "/") searchInput.focus();
44
49
});
45
50
51
+
const fetchTypeahead = async (input: string) => {
52
+
if (!input.length) return [];
53
+
const res = await rpc.get("app.bsky.actor.searchActorsTypeahead", {
54
+
params: { q: input, limit: 5 },
55
+
});
56
+
if (res.ok) {
57
+
return res.data.actors;
58
+
}
59
+
return [];
60
+
};
61
+
62
+
const [input, setInput] = createSignal<string>();
63
+
const [search] = createResource(createDebouncedValue(input, 300), fetchTypeahead);
64
+
46
65
const processInput = (input: string) => {
47
66
input = input.trim().replace(/^@/, "");
48
67
if (!input.length) return;
···
54
73
(input.startsWith("https://") || input.startsWith("http://"))
55
74
) {
56
75
navigate(`/${input.replace("https://", "").replace("http://", "").replace("/", "")}`);
76
+
} else if (search()?.length) {
77
+
navigate(`/at://${search()![0].did}`);
57
78
} else {
58
79
const uri = input
59
80
.replace("at://", "")
···
64
85
`/at://${uriParts[0]}${uriParts.length > 1 ? `/${uriParts.slice(1).join("/")}` : ""}`,
65
86
);
66
87
}
88
+
setShowSearch(false);
67
89
};
68
90
69
91
return (
70
92
<form
71
-
class="w-[22rem] sm:w-[24rem]"
93
+
class="relative w-[22rem] sm:w-[24rem]"
72
94
onsubmit={(e) => {
73
95
e.preventDefault();
74
96
processInput(searchInput.value);
···
85
107
ref={searchInput}
86
108
id="input"
87
109
class="grow select-none placeholder:text-sm focus:outline-none"
110
+
value={input() ?? ""}
111
+
onInput={(e) => setInput(e.currentTarget.value)}
88
112
/>
89
-
<button
90
-
type="submit"
91
-
class="iconify lucide--arrow-right text-lg text-neutral-500 dark:text-neutral-400"
92
-
></button>
113
+
<Show when={input()}>
114
+
<button
115
+
type="button"
116
+
class="flex items-center rounded-lg p-0.5 hover:bg-neutral-200 active:bg-neutral-300 dark:hover:bg-neutral-600 dark:active:bg-neutral-500"
117
+
onClick={() => setInput(undefined)}
118
+
>
119
+
<span class="iconify lucide--x text-lg"></span>
120
+
</button>
121
+
</Show>
93
122
</div>
123
+
<Show when={search()?.length && input()}>
124
+
<div class="dark:bg-dark-300 absolute z-30 mt-2 flex w-full flex-col rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 p-1 dark:border-neutral-700">
125
+
<Suspense fallback={<div class="p-1">Loading...</div>}>
126
+
<For each={search()}>
127
+
{(actor) => (
128
+
<A
129
+
class="flex items-center gap-2 rounded-lg p-1 hover:bg-neutral-200 dark:hover:bg-neutral-700"
130
+
href={`/at://${actor.did}`}
131
+
onClick={() => setShowSearch(false)}
132
+
>
133
+
<img src={actor.avatar} class="size-6 rounded-full" />
134
+
<span>{actor.handle}</span>
135
+
</A>
136
+
)}
137
+
</For>
138
+
</Suspense>
139
+
</div>
140
+
</Show>
94
141
</form>
95
142
);
96
143
};
+23
src/utils/hooks/debounced.ts
+23
src/utils/hooks/debounced.ts
···
1
+
import { type Accessor, createEffect, createSignal, onCleanup } from 'solid-js';
2
+
3
+
export const createDebouncedValue = <T>(
4
+
accessor: Accessor<T>,
5
+
delay: number,
6
+
equals?: false | ((prev: T, next: T) => boolean),
7
+
): Accessor<T> => {
8
+
const initial = accessor();
9
+
const [state, setState] = createSignal(initial, { equals });
10
+
11
+
createEffect((prev: T) => {
12
+
const next = accessor();
13
+
14
+
if (prev !== next) {
15
+
const timeout = setTimeout(() => setState(() => next), delay);
16
+
onCleanup(() => clearTimeout(timeout));
17
+
}
18
+
19
+
return next;
20
+
}, initial);
21
+
22
+
return state;
23
+
};