Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: simplify gallery search

Hugo dfac844b 09698899

+7 -98
+4 -42
app/islands/AutomationFilters.tsx
··· 1 1 import { useEffect, useRef, useState } from "hono/jsx"; 2 2 import { Input } from "../components/Input/index.js"; 3 - import { Select } from "../components/Select/index.js"; 4 3 import * as s from "../styles/pages/automations.css.js"; 5 - 6 - type LexiconOption = { 7 - lexicon: string; 8 - count: number; 9 - }; 10 4 11 5 type Props = { 12 6 q: string; 13 - lexicon: string; 14 - lexiconOptions: LexiconOption[]; 15 7 }; 16 8 17 9 const DEBOUNCE_MS = 300; ··· 24 16 return qs ? `/automations?${qs}` : "/automations"; 25 17 }; 26 18 27 - const paramsFromFilters = (q: string, lexicon: string) => { 19 + const paramsFromFilters = (q: string) => { 28 20 const params = new URLSearchParams(); 29 21 const trimmed = q.trim(); 30 22 if (trimmed) params.set("q", trimmed); 31 - if (lexicon) params.set("lexicon", lexicon); 32 23 return params; 33 24 }; 34 25 ··· 51 42 if (next && current) current.replaceWith(next); 52 43 }; 53 44 54 - export default function AutomationFilters({ q, lexicon, lexiconOptions }: Props) { 45 + export default function AutomationFilters({ q }: Props) { 55 46 const [qValue, setQValue] = useState(q); 56 - const [lexiconValue, setLexiconValue] = useState(lexicon); 57 47 const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); 58 48 const abortRef = useRef<AbortController | null>(null); 59 49 const lastSubmittedQRef = useRef<string>(q.trim()); ··· 97 87 const onPopState = () => { 98 88 const params = new URLSearchParams(window.location.search); 99 89 const newQ = (params.get("q") ?? "").trim(); 100 - const newLex = (params.get("lexicon") ?? "").trim(); 101 90 setQValue(newQ); 102 - setLexiconValue(newLex); 103 - // Keep the native select in sync — JSX-level `selected` diffing on options 104 - // doesn't reliably update a select's current value after user interaction. 105 - const selectEl = document.getElementById("lexicon") as HTMLSelectElement | null; 106 - if (selectEl) selectEl.value = newLex; 107 91 void run(params); 108 92 }; 109 93 ··· 140 124 // Treat them as empty — clearing the box still fires. 141 125 if (trimmed.length > 0 && trimmed.length < MIN_Q_LENGTH) return; 142 126 if (trimmed !== lastSubmittedQRef.current) { 143 - void run(paramsFromFilters(value, lexiconValue)); 127 + void run(paramsFromFilters(value)); 144 128 } 145 129 }, DEBOUNCE_MS); 146 130 }; 147 131 148 - // hono/jsx wires `onChange` to the native `input` event on <select>, 149 - // not `change` — both fire on real user interaction, so this is transparent 150 - // except when dispatching events from tests. 151 - const onLexiconChange = (e: Event) => { 152 - const value = (e.currentTarget as HTMLSelectElement).value; 153 - setLexiconValue(value); 154 - void run(paramsFromFilters(qValue, value)); 155 - }; 156 - 157 132 return ( 158 133 <form 159 134 class={s.filterBar} 160 135 onSubmit={(e: Event) => { 161 136 e.preventDefault(); 162 - const params = paramsFromFilters(qValue, lexiconValue); 137 + const params = paramsFromFilters(qValue); 163 138 if (canonical(params.toString()) === canonical(window.location.search)) return; 164 139 void run(params); 165 140 }} ··· 176 151 value={qValue} 177 152 onInput={onQInput} 178 153 /> 179 - </div> 180 - <div class={s.field}> 181 - <label class={s.fieldLabel} for="lexicon"> 182 - Lexicon 183 - </label> 184 - <Select id="lexicon" name="lexicon" onChange={onLexiconChange}> 185 - <option value="">All lexicons</option> 186 - {lexiconOptions.map((row) => ( 187 - <option key={row.lexicon} value={row.lexicon} selected={row.lexicon === lexiconValue}> 188 - {row.lexicon} ({row.count}) 189 - </option> 190 - ))} 191 - </Select> 192 154 </div> 193 155 </form> 194 156 );
+3 -22
app/routes/automations.tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 - import { desc, eq, count, sql } from "drizzle-orm"; 3 2 import { getSessionUser } from "@/auth/middleware.js"; 4 3 import { getFeaturedAutomations, getFeaturedUris } from "@/automations/featured.js"; 5 4 import { searchAutomations } from "@/automations/search.js"; 6 5 import { db } from "@/db/index.js"; 7 - import { automations } from "@/db/schema.js"; 8 6 import { AppShell } from "../components/Layout/AppShell/index.js"; 9 7 import { Header } from "../components/Layout/Header/index.js"; 10 8 import { Container } from "../components/Layout/Container/index.js"; ··· 21 19 const viewer = await getSessionUser(c); 22 20 23 21 const q = (c.req.query("q") ?? "").trim().slice(0, 128); 24 - const lexicon = (c.req.query("lexicon") ?? "").trim().slice(0, 256); 25 22 const limit = Math.min(Math.max(Number(c.req.query("limit")) || PAGE_SIZE, PAGE_SIZE), 200); 26 23 const isFragment = c.req.header("X-Fragment") === "1"; 27 24 28 25 const featuredUris = getFeaturedUris(); 29 - const showFeatured = !q && !lexicon; 26 + const showFeatured = !q; 30 27 const featured = showFeatured ? await getFeaturedAutomations() : []; 31 28 32 29 const rows = await searchAutomations(db, { 33 30 q, 34 - lexicon, 35 31 limit: limit + 1, 36 32 excludeUris: showFeatured ? featuredUris : undefined, 37 33 }); ··· 39 35 const hasMore = rows.length > limit; 40 36 const visible = hasMore ? rows.slice(0, limit) : rows; 41 37 42 - // Fragment responses don't render the lexicon dropdown — the island keeps 43 - // its existing options — so skip the GROUP BY on every debounced keystroke. 44 - const lexiconRows = isFragment 45 - ? [] 46 - : await db 47 - .select({ lexicon: automations.lexicon, count: count() }) 48 - .from(automations) 49 - .where(eq(automations.active, true)) 50 - .groupBy(automations.lexicon) 51 - .orderBy(desc(sql`count(*)`)) 52 - .limit(50); 53 - 54 38 const buildQuery = (overrides: { limit?: number }) => { 55 39 const p = new URLSearchParams(); 56 40 if (q) p.set("q", q); 57 - if (lexicon) p.set("lexicon", lexicon); 58 41 if (overrides.limit) p.set("limit", String(overrides.limit)); 59 42 const qs = p.toString(); 60 43 return qs ? `/automations?${qs}` : "/automations"; ··· 116 99 )} 117 100 </> 118 101 ) : featured.length === 0 ? ( 119 - <p class={s.empty}> 120 - No automations match your filters. Try a broader search or pick a different lexicon. 121 - </p> 102 + <p class={s.empty}>No automations match your search. Try a broader search.</p> 122 103 ) : null} 123 104 </div> 124 105 ); ··· 135 116 description="Browse public automations. Duplicate any of them to your account." 136 117 /> 137 118 138 - <AutomationFilters q={q} lexicon={lexicon} lexiconOptions={lexiconRows} /> 119 + <AutomationFilters q={q} /> 139 120 140 121 {results} 141 122 </Container>
-6
app/styles/pages/automations.css.ts
··· 10 10 gridTemplateColumns: "1fr", 11 11 gap: space[3], 12 12 marginBlockEnd: space[6], 13 - "@media": { 14 - [mq.md]: { 15 - gridTemplateColumns: "minmax(0, 1fr) auto", 16 - alignItems: "end", 17 - }, 18 - }, 19 13 }); 20 14 21 15 export const field = style({
-23
lib/automations/search.test.ts
··· 100 100 expect(await searchAutomations(db, { q: "airglow", limit: 10 })).toEqual([]); 101 101 }); 102 102 103 - it("filters by exact lexicon match", async () => { 104 - await seedAutomation(db, { 105 - uri: "at://did:plc:alice/run.airglow.automation/a1", 106 - did: "did:plc:alice", 107 - rkey: "a1", 108 - name: "A", 109 - lexicon: "run.airglow.automation", 110 - }); 111 - await seedAutomation(db, { 112 - uri: "at://did:plc:bob/run.airglow.automation/b1", 113 - did: "did:plc:bob", 114 - rkey: "b1", 115 - name: "B", 116 - lexicon: "app.bsky.feed.like", 117 - }); 118 - 119 - const rows = await searchAutomations(db, { 120 - lexicon: "run.airglow.automation", 121 - limit: 10, 122 - }); 123 - expect(rows.map((r) => r.rkey)).toEqual(["a1"]); 124 - }); 125 - 126 103 it("excludes URIs listed in excludeUris", async () => { 127 104 await seedAutomation(db, { 128 105 uri: "at://did:plc:alice/run.airglow.automation/a1",
-5
lib/automations/search.ts
··· 20 20 21 21 export type AutomationSearchParams = { 22 22 q?: string; 23 - lexicon?: string; 24 23 limit: number; 25 24 excludeUris?: string[]; 26 25 }; ··· 30 29 params: AutomationSearchParams, 31 30 ): Promise<AutomationSearchResult[]> { 32 31 const conditions = [eq(automations.active, true)]; 33 - 34 - if (params.lexicon) { 35 - conditions.push(eq(automations.lexicon, params.lexicon)); 36 - } 37 32 38 33 if (params.q) { 39 34 // Escape LIKE wildcards with a backslash and declare ESCAPE '\' so '%' and