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

Configure Feed

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

feat: improvements

Hugo a2d70dc6 119bdb66

+174 -141
+35 -3
app/islands/AutomationForm.css.ts
··· 76 76 paddingInline: space[3], 77 77 fontSize: fontSize.base, 78 78 color: vars.color.text, 79 - backgroundColor: vars.color.bg, 79 + backgroundColor: vars.color.surface, 80 80 border: `1px solid ${vars.color.border}`, 81 81 borderRadius: radii.md, 82 82 "::placeholder": { ··· 96 96 paddingInlineEnd: space[7], 97 97 fontSize: fontSize.base, 98 98 color: vars.color.text, 99 - backgroundColor: vars.color.bg, 99 + backgroundColor: vars.color.surface, 100 100 border: `1px solid ${vars.color.border}`, 101 101 borderRadius: radii.md, 102 102 appearance: "none", ··· 291 291 fontSize: fontSize.sm, 292 292 fontFamily: "monospace", 293 293 color: vars.color.text, 294 - backgroundColor: vars.color.bg, 294 + backgroundColor: vars.color.surface, 295 295 border: `1px solid ${vars.color.border}`, 296 296 borderRadius: radii.md, 297 297 resize: "vertical", ··· 616 616 617 617 globalStyle(`${collapsibleSummary}::-webkit-details-marker`, { 618 618 display: "none", 619 + }); 620 + 621 + export const toggleBox = style({ 622 + display: "flex", 623 + flexDirection: "column", 624 + gap: space[3], 625 + minWidth: 0, 626 + paddingBlock: space[3], 627 + paddingInline: space[3], 628 + borderRadius: radii.md, 629 + border: `1px solid ${vars.color.borderSubtle}`, 630 + backgroundColor: vars.color.bg, 631 + }); 632 + 633 + export const toggleHeader = style({ 634 + display: "flex", 635 + alignItems: "center", 636 + gap: space[2], 637 + fontSize: fontSize.sm, 638 + color: vars.color.text, 639 + cursor: "pointer", 640 + userSelect: "none", 641 + }); 642 + 643 + export const togglePanel = style({ 644 + display: "flex", 645 + flexDirection: "column", 646 + gap: space[3], 647 + fontSize: fontSize.xs, 648 + color: vars.color.textSecondary, 649 + lineHeight: 1.6, 650 + wordBreak: "break-word", 619 651 }); 620 652 621 653 export const collapsibleContent = style({
+139 -138
app/islands/AutomationForm.tsx
··· 892 892 // forEach (run per item) editor 893 893 // --------------------------------------------------------------------------- 894 894 895 - const FOR_EACH_OPERATORS = ["eq", "startsWith", "endsWith", "contains", "exists", "not-exists"]; 895 + const COMPARE_OPERATOR_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [ 896 + { value: "eq", label: "equals" }, 897 + { value: "startsWith", label: "starts with" }, 898 + { value: "endsWith", label: "ends with" }, 899 + { value: "contains", label: "contains" }, 900 + ]; 901 + 902 + type PresenceLabels = { exists: string; notExists: string }; 903 + 904 + const PRESENCE_DEFAULT: PresenceLabels = { exists: "exists", notExists: "does not exist" }; 905 + const PRESENCE_FETCH: PresenceLabels = { exists: "is present", notExists: "is missing" }; 906 + 907 + function ConditionOperatorOptions({ presence = PRESENCE_DEFAULT }: { presence?: PresenceLabels }) { 908 + return ( 909 + <> 910 + {COMPARE_OPERATOR_OPTIONS.map((op) => ( 911 + <option key={op.value} value={op.value}> 912 + {op.label} 913 + </option> 914 + ))} 915 + <option value="exists">{presence.exists}</option> 916 + <option value="not-exists">{presence.notExists}</option> 917 + </> 918 + ); 919 + } 896 920 897 921 function ForEachConfigEditor({ 898 922 index, ··· 908 932 const enabled = draft !== undefined; 909 933 const pathId = `action-${index}-foreach-path`; 910 934 const pathListId = `action-${index}-foreach-path-suggestions`; 911 - // Default-open the panel when forEach is set on first mount so users land on 912 - // the config they last saved. After mount, the user controls the toggle via 913 - // the native <details> element — we don't re-bind `open` so re-renders don't 914 - // fight their interaction. 915 - const [initialOpen] = useState(enabled); 916 935 917 936 const updateCondition = (i: number, key: keyof Condition, val: string) => { 918 937 if (!draft) return; ··· 934 953 }; 935 954 936 955 return ( 937 - <details class={s.collapsibleDetails} open={initialOpen}> 938 - <summary class={s.collapsibleSummary}> 939 - Run per item{" "} 940 - <span class={s.hint}> 941 - (fan this action out across an array — each match counts toward the rate limit) 956 + <div class={s.toggleBox}> 957 + <label class={s.toggleHeader}> 958 + <input 959 + type="checkbox" 960 + checked={enabled} 961 + onChange={(e: Event) => { 962 + const checked = (e.target as HTMLInputElement).checked; 963 + if (checked) onChange({ path: "", conditions: [] }); 964 + else onChange(undefined); 965 + }} 966 + /> 967 + <span> 968 + Run per item <span class={s.hint}>Trigger this action for each item in an array</span> 942 969 </span> 943 - </summary> 944 - <div class={s.collapsibleContent}> 945 - <label class={s.checkboxLabel}> 946 - <input 947 - type="checkbox" 948 - checked={enabled} 949 - onChange={(e: Event) => { 950 - const checked = (e.target as HTMLInputElement).checked; 951 - if (checked) onChange({ path: "", conditions: [] }); 952 - else onChange(undefined); 953 - }} 954 - /> 955 - Enable forEach 956 - </label> 957 - 958 - {enabled && ( 959 - <> 960 - <div class={s.fieldGroup}> 961 - <label class={s.label} for={pathId}> 962 - Array path 963 - </label> 964 - <input 965 - id={pathId} 966 - class={s.input} 967 - type="text" 968 - list={pathListId} 969 - placeholder="e.g. event.commit.record.facets[]" 970 - value={draft.path} 971 - onInput={(e: Event) => 972 - onChange({ ...draft, path: (e.target as HTMLInputElement).value }) 973 - } 974 - autocomplete="off" 975 - /> 976 - <span class={s.hint}> 977 - Dotted path with <code>[]</code> segments to mark array levels. Roots:{" "} 978 - <code>event.*</code>, a fetch name, or an upstream <code>actionN</code>. Must end in{" "} 979 - <code>[]</code>. 980 - </span> 981 - {arrayPathSuggestions.length > 0 && ( 982 - <datalist id={pathListId}> 983 - {arrayPathSuggestions.map((p) => ( 984 - <option key={p} value={p} /> 985 - ))} 986 - </datalist> 987 - )} 988 - </div> 970 + </label> 971 + {enabled && ( 972 + <div class={s.togglePanel}> 973 + <div class={s.fieldGroup}> 974 + <label class={s.label} for={pathId}> 975 + Array path 976 + </label> 977 + <input 978 + id={pathId} 979 + class={s.input} 980 + type="text" 981 + list={pathListId} 982 + placeholder="e.g. event.commit.record.facets[]" 983 + value={draft.path} 984 + onInput={(e: Event) => 985 + onChange({ ...draft, path: (e.target as HTMLInputElement).value }) 986 + } 987 + autocomplete="off" 988 + /> 989 + <span class={s.hint}> 990 + Dotted path with <code>[]</code> segments to mark array levels. Roots:{" "} 991 + <code>event.*</code>, a fetch name, or an upstream <code>actionN</code>. Must end in{" "} 992 + <code>[]</code>. 993 + </span> 994 + {arrayPathSuggestions.length > 0 && ( 995 + <datalist id={pathListId}> 996 + {arrayPathSuggestions.map((p) => ( 997 + <option key={p} value={p} /> 998 + ))} 999 + </datalist> 1000 + )} 1001 + </div> 989 1002 990 - <div class={s.fieldGroup}> 991 - <span class={s.label}> 992 - Per-item conditions <span class={s.hint}>(optional)</span> 993 - </span> 994 - <span class={s.hint}> 995 - Each item is filtered by these. Field paths are rooted at the item (e.g.{" "} 996 - <code>$type</code> or <code>features.0.uri</code>). The optional <code>item.</code>{" "} 997 - prefix is also accepted. 998 - </span> 999 - {draft.conditions.map((c, i) => ( 1000 - <div key={i} class={s.conditionRow}> 1001 - <div class={s.conditionField}> 1002 - <input 1003 - class={s.input} 1004 - type="text" 1005 - placeholder="e.g. $type" 1006 - value={c.field} 1007 - onInput={(e: Event) => 1008 - updateCondition(i, "field", (e.target as HTMLInputElement).value) 1009 - } 1010 - autocomplete="off" 1011 - aria-label="Item field" 1012 - /> 1013 - </div> 1014 - <select 1003 + <div class={s.fieldGroup}> 1004 + <span class={s.label}> 1005 + Per-item conditions <span class={s.hint}>(optional)</span> 1006 + </span> 1007 + <span class={s.hint}> 1008 + Each item is filtered by these. Field paths are rooted at the item (e.g.{" "} 1009 + <code>$type</code> or <code>features.0.uri</code>). The optional <code>item.</code>{" "} 1010 + prefix is also accepted. 1011 + </span> 1012 + {draft.conditions.map((c, i) => ( 1013 + <div key={i} class={s.conditionRow}> 1014 + <div class={s.conditionField}> 1015 + <input 1015 1016 class={s.input} 1017 + type="text" 1018 + placeholder="e.g. $type" 1019 + value={c.field} 1020 + onInput={(e: Event) => 1021 + updateCondition(i, "field", (e.target as HTMLInputElement).value) 1022 + } 1023 + autocomplete="off" 1024 + aria-label="Item field" 1025 + /> 1026 + </div> 1027 + <div class={s.conditionOperator}> 1028 + <select 1029 + class={s.select} 1016 1030 value={c.operator} 1017 1031 onChange={(e: Event) => 1018 1032 updateCondition(i, "operator", (e.target as HTMLSelectElement).value) 1019 1033 } 1020 1034 aria-label="Operator" 1021 1035 > 1022 - {FOR_EACH_OPERATORS.map((op) => ( 1023 - <option key={op} value={op}> 1024 - {op} 1025 - </option> 1026 - ))} 1036 + <ConditionOperatorOptions /> 1027 1037 </select> 1028 - {!VALUE_LESS_CONDITION_OPS.has(c.operator) && ( 1029 - <div class={s.conditionValue}> 1030 - <input 1031 - class={s.input} 1032 - type="text" 1033 - placeholder="e.g. app.bsky.richtext.facet#link" 1034 - value={c.value} 1035 - onInput={(e: Event) => 1036 - updateCondition(i, "value", (e.target as HTMLInputElement).value) 1037 - } 1038 - autocomplete="off" 1039 - aria-label="Value" 1040 - /> 1041 - </div> 1042 - )} 1043 - <button type="button" class={s.removeBtn} onClick={() => removeCondition(i)}> 1044 - Remove 1045 - </button> 1046 1038 </div> 1047 - ))} 1048 - <button type="button" class={s.addBtn} onClick={addCondition}> 1049 - + Add item condition 1050 - </button> 1051 - </div> 1039 + {!VALUE_LESS_CONDITION_OPS.has(c.operator) && ( 1040 + <div class={s.conditionValue}> 1041 + <input 1042 + class={s.input} 1043 + type="text" 1044 + placeholder="e.g. app.bsky.richtext.facet#link" 1045 + value={c.value} 1046 + onInput={(e: Event) => 1047 + updateCondition(i, "value", (e.target as HTMLInputElement).value) 1048 + } 1049 + autocomplete="off" 1050 + aria-label="Value" 1051 + /> 1052 + </div> 1053 + )} 1054 + <button type="button" class={s.removeBtn} onClick={() => removeCondition(i)}> 1055 + Remove 1056 + </button> 1057 + </div> 1058 + ))} 1059 + <button type="button" class={s.addBtn} onClick={addCondition}> 1060 + + Add item condition 1061 + </button> 1062 + </div> 1052 1063 1053 - <p class={s.hint}> 1054 - Inside this action, templates can use <code>{`{{item.*}}`}</code> placeholders (e.g.{" "} 1055 - <code>{`{{item.uri}}`}</code>, <code>{`{{item.did}}`}</code>) to read fields off each 1056 - matching item. 1057 - </p> 1058 - </> 1059 - )} 1060 - </div> 1061 - </details> 1064 + <p class={s.hint}> 1065 + Inside this action, templates can use <code>{`{{item.*}}`}</code> placeholders (e.g.{" "} 1066 + <code>{`{{item.uri}}`}</code>, <code>{`{{item.did}}`}</code>) to read fields off each 1067 + matching item. 1068 + </p> 1069 + </div> 1070 + )} 1071 + </div> 1062 1072 ); 1063 1073 } 1064 1074 ··· 2197 2207 } 2198 2208 aria-label="Condition operator" 2199 2209 > 2200 - <option value="eq">equals</option> 2201 - <option value="startsWith">starts with</option> 2202 - <option value="endsWith">ends with</option> 2203 - <option value="contains">contains</option> 2204 - <option value="exists">exists</option> 2205 - <option value="not-exists">does not exist</option> 2210 + <ConditionOperatorOptions /> 2206 2211 </select> 2207 2212 </div> 2208 2213 )} ··· 2652 2657 } 2653 2658 aria-label="Condition operator" 2654 2659 > 2655 - <option value="eq">equals</option> 2656 - <option value="startsWith">starts with</option> 2657 - <option value="endsWith">ends with</option> 2658 - <option value="contains">contains</option> 2659 - <option value="exists">is present</option> 2660 - <option value="not-exists">is missing</option> 2660 + <ConditionOperatorOptions presence={PRESENCE_FETCH} /> 2661 2661 </select> 2662 2662 </div> 2663 2663 )} ··· 2723 2723 id={`fetch-${i}-note`} 2724 2724 class={s.input} 2725 2725 type="text" 2726 - placeholder="A reminder for future-you about why this source exists" 2726 + placeholder="Brief explanation of what this data source provides" 2727 2727 value={f.comment} 2728 2728 onInput={(e: Event) => 2729 2729 updateFetch(i, "comment", (e.target as HTMLInputElement).value) ··· 2809 2809 Remove 2810 2810 </button> 2811 2811 </div> 2812 + <ForEachConfigEditor 2813 + index={i} 2814 + draft={action.forEach} 2815 + onChange={(next) => updateAction(i, { ...action, forEach: next })} 2816 + arrayPathSuggestions={arrayPathSuggestions} 2817 + /> 2812 2818 {action.type === "webhook" ? ( 2813 2819 <WebhookActionEditor 2814 2820 action={action} ··· 2856 2862 id={`action-${i}-note`} 2857 2863 class={s.input} 2858 2864 type="text" 2865 + placeholder="Brief explanation of what this action does" 2859 2866 value={action.comment} 2860 2867 onInput={(e: Event) => 2861 2868 updateAction(i, { ··· 2866 2873 autocomplete="off" 2867 2874 /> 2868 2875 </div> 2869 - <ForEachConfigEditor 2870 - index={i} 2871 - draft={action.forEach} 2872 - onChange={(next) => updateAction(i, { ...action, forEach: next })} 2873 - arrayPathSuggestions={arrayPathSuggestions} 2874 - /> 2875 2876 {isRecordProducingAction(action.type) && ( 2876 2877 <details class={s.collapsibleDetails}> 2877 2878 <summary class={s.collapsibleSummary}>