https://domlink.deployments.hotsocket.fyi/

Compare changes

Choose any two refs to compare.

+3
.gitignore
··· 3 3 out 4 4 node_modules 5 5 certs 6 + 7 + # Local Netlify folder 8 + .netlify
+19
.tangled/workflows/netlify.yml
··· 1 + when: 2 + - event: ["push", "manual"] 3 + branch: ["main"] 4 + 5 + engine: "nixery" 6 + 7 + dependencies: 8 + nixpkgs: 9 + - deno 10 + - rsync 11 + - netlify-cli 12 + 13 + steps: 14 + - name: "Build output" 15 + command: "deno task build-prod" 16 + - name: "Apply workaround for Netlify CLI to work" 17 + command: "sh -c 'echo root:*:0:0:root:/root:/bin/bash > /etc/passwd'" 18 + - name: "Push to Netlify" 19 + command: "netlify deploy --prod --site=domlink --dir=out"
+15 -12
deno.json
··· 1 1 { 2 - "compilerOptions": { 3 - "lib": [ 4 - "dom", 5 - "es2017", 6 - "dom.iterable", 7 - "deno.ns" 8 - ] 9 - }, 10 - "exclude": [ 11 - "node_modules", 12 - "out" 13 - ] 2 + "compilerOptions": { 3 + "lib": [ 4 + "dom", 5 + "es2017", 6 + "dom.iterable", 7 + "deno.ns" 8 + ] 9 + }, 10 + "imports": { 11 + "@/": "./src/" 12 + }, 13 + "exclude": [ 14 + "node_modules", 15 + "out" 16 + ] 14 17 }
+1 -1
flake.nix
··· 11 11 in { 12 12 devShell = pkgs.mkShell { 13 13 buildInputs = with pkgs; [ 14 - deno rsync 14 + deno rsync netlify-cli 15 15 ]; 16 16 }; 17 17 }
+3 -2
package.json
··· 15 15 }, 16 16 "scripts": { 17 17 "serve": "http-server ./out -C", 18 - "build": "deno bundle --platform=browser src/*.ts --outdir=out --sourcemap=inline && rsync -av static/ out/", 19 - "build-prod": "deno bundle --platform=browser src/*.ts --outdir=out --minify && rsync -av static/ out/", 18 + "script_index": "deno run --allow-read --allow-write scripts/index.ts", 19 + "build": "mkdir -p out && deno task script_index && deno bundle --platform=browser src/*.ts --outdir=out --sourcemap=inline && rsync -av static/ out/", 20 + "build-prod": "mkdir -p out && deno task script_index && deno bundle --platform=browser src/*.ts --outdir=out --minify && rsync -av static/ out/", 20 21 "test": "deno test test/**/*.ts" 21 22 } 22 23 }
+3 -1
readme
··· 29 29 bro idk what this is anymore but im a fan of it and will keep going 30 30 31 31 i really gotta stop using Node.wraps... 32 - that literally means that my implementation around it is inadequate 32 + that literally means that my implementation around it is inadequate 33 + 34 + i hate git sometimes
+31
scripts/index.ts
··· 1 + // generates index of html files in static 2 + 3 + let output = ` 4 + <!DOCTYPE html> 5 + <html lang="en"> 6 + <head> 7 + <meta charset="UTF-8"> 8 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 9 + <title>DomLink Index</title> 10 + <style>*{font-family:sans-serif;}</style> 11 + </head> 12 + <body> 13 + <h1>DomLink HTML File Index</h1> 14 + <ul>`; 15 + for (const dirEntry of Deno.readDirSync("./static")) { 16 + if (dirEntry.isFile && dirEntry.name.endsWith(".html")) { 17 + // oh shit that indentation lines up 18 + const currentFile = Deno.readTextFileSync(`./static/${dirEntry.name}`); 19 + const title = currentFile.split("\n").find(x=>x.includes("<title>"))!.split("<title>")[1].split("</title>")[0]; 20 + output = output + ` 21 + <li><a href="./${dirEntry.name}">${title} (${dirEntry.name})</a></li>`; 22 + } 23 + } 24 + output = output + ` 25 + </ul> 26 + </body> 27 + </html>`; 28 + 29 + const outfile = Deno.openSync("./out/index.html", {create: true, write: true, append: false}); 30 + outfile.writeSync(new TextEncoder().encode(output)); 31 + outfile.close();
+50 -9
src/desktop.ts
··· 1 1 import { Body, Button, Column, Input, Link, Row } from "./domlink.ts"; 2 - import { resolveMiniDoc } from "./support/slingshot.ts"; 2 + import { List, SocialAppToURI } from "./support/bluesky.ts"; 3 + import { DocProxy } from "./support/caching.ts"; 4 + import { getUriRecord, resolveMiniDoc } from "./support/slingshot.ts"; 3 5 import { Window } from "./windowing_mod.ts"; 4 6 import { Feed } from "./windows/bluesky/feed.ts"; 7 + import { IssueSearch } from "./windows/tangled/issuesearch.ts"; 5 8 6 9 const about = new Window("About", 150).with( 7 10 new Column().with( ··· 11 14 ) 12 15 ).closable(false); // haha 13 16 17 + export const displayAuthorFeed = async (user: string) => { 18 + const doc = await DocProxy.get(user); 19 + Body.with(new Window("Posts by @"+doc.handle).with( 20 + await new Feed().loadFeed(Feed.createAuthorGenerator(doc.did)), 21 + )); 22 + } 14 23 const userInput = new Input(); 15 24 const authorFeedWindowCreator = new Row().with( 16 25 userInput, 17 - new Button("Get Author Feed", async () => { 18 - const doc = await resolveMiniDoc(userInput.value); 19 - const feed = new Feed(); 20 - Body.with(new Window("Posts by @"+doc.handle).with(feed)); 21 - await feed.loadFeed(Feed.createAuthorGenerator(doc.did)); // separated out to allow for updating 26 + new Button("Get Author Feed", async ()=>{await displayAuthorFeed(userInput.value)}) 27 + ); 28 + const listInput = new Input(); 29 + const listFeedWindowCreator = new Row().with( 30 + listInput, 31 + new Button("Get List Feed", async () => { 32 + const listURI = await SocialAppToURI(listInput.value, true); 33 + const doc = await DocProxy.get(listURI.authority!); 34 + const listInfo = await getUriRecord<List>(listURI); 35 + try{ 36 + Body.with(new Window(`Posts in "${listInfo.value.name}" by @${doc.handle}`).with( 37 + await new Feed().loadFeed(Feed.createListGenerator(listURI)), 38 + )); 39 + } catch (e) { 40 + alert(e); 41 + } 42 + }) 43 + ); 44 + const repoInput = new Input(); 45 + const tangledIssuesWindowCreator = new Row().with( 46 + repoInput, 47 + new Button("Get Issues", async () => { 48 + const issueWindow = new Window(); 49 + const search = new IssueSearch(issueWindow); 50 + issueWindow.with(search); 51 + try { 52 + await search.getIssues(repoInput.value); 53 + } catch (e) { 54 + alert(e); 55 + } 56 + Body.with(issueWindow); 22 57 }) 23 58 ); 59 + 24 60 const instantiator = new Column().with( 25 - authorFeedWindowCreator, 61 + authorFeedWindowCreator, 62 + listFeedWindowCreator, 63 + tangledIssuesWindowCreator 26 64 ).style((x) => x.maxWidth = "100ch"); 27 65 28 66 Body.with( 29 - instantiator, 30 - about 67 + instantiator, 68 + about 31 69 ); 70 + 71 + // clamps to window so it should jump to the bottom right nice and pretty 72 + about.position = [document.documentElement.clientWidth, document.documentElement.clientHeight];
+3 -2
src/domlink.ts
··· 172 172 } 173 173 /** Wrapper for {@link HTMLButtonElement `<button>`} */ 174 174 export class Button extends Text { 175 - constructor(label: string, action: EventListener) { 176 - let btn = document.createElement("button"); 175 + constructor(label: string, action: EventListener, asLink: boolean = false) { 176 + let btn = document.createElement(asLink ? "a" : "button"); 177 + if (asLink) btn.classList.add("LBtnLink"); 177 178 btn.textContent = label; 178 179 btn.addEventListener("click", action); 179 180 btn.classList.add("LButton");
+12
src/increment.ts
··· 1 + import { Body, Button, Column, Label } from "@/domlink.ts"; 2 + 3 + const display = new Label("Number: 0"); 4 + let number = 0; 5 + Body.with( 6 + new Column().with( 7 + display, 8 + new Button("Increment!", ()=>{ 9 + display.text = `Number: ${++number}`; 10 + }) 11 + ) 12 + );
+7 -3
src/support/atproto.ts
··· 9 9 return NSIDExpression.test(nsid) ? nsid : null; 10 10 } 11 11 12 + export type Handle = string; 13 + export function StripHandle(handle: Handle) { 14 + return handle.replace("@", ""); 15 + } 12 16 export type DID = `did:${"web"|"plc"}:${string}`; 13 17 export function ValidateDID(did: string): string | null { 14 18 const parts = did.split(":"); ··· 16 20 return isValid ? did : null; 17 21 } 18 22 19 - export type AtURIString = `at://${string}/${string}/${string}`; 23 + export type AtURIString = string; //`at://${string}/${string}/${string}`; 20 24 export class AtURI { 21 25 readonly authority: string | null; 22 26 readonly collection: string | null; ··· 25 29 const parts = uri.split("/").slice(2); 26 30 return new AtURI(ValidateDID(parts[0]), ValidateNSID(parts[1]), parts[2]); 27 31 } 28 - constructor(authority: string | null, collection: string | null, rkey: string | null) { 32 + constructor(authority: string | null, collection: string | null = null, rkey: string | null = null) { 29 33 this.authority = authority; 30 34 this.collection = collection; 31 35 this.rkey = rkey; ··· 70 74 71 75 // technically you can cast it to whatever you want but i feel like using a generic(?) makes it cleaner 72 76 /** Calls an XRPC "query" method (HTTP GET) 73 - * @param service Defaults to {@link https://slingshot.microcosm.blue Slingshot}. 77 + * @param service Defaults to the {@link https://api.bsky.app/ Bluesky (PBC) API} service. 74 78 */ 75 79 export async function XQuery<T>(method: string, params: Record<string, string | number | null> | null = null, service: string = DEFAULT_SERVICE) { 76 80 let QueryURL = `${service}/xrpc/${method}`;
+42
src/support/bluesky.ts
··· 1 1 import { AppBskyFeedDefs } from "@atcute/bluesky"; 2 + import { AtURI, DID } from "./atproto.ts"; 3 + import * as Constellation from "./constellation.ts"; 4 + import { getUriRecord } from "./slingshot.ts"; 5 + import { DocProxy } from "./caching.ts"; 2 6 3 7 export type FeedResponse = { 4 8 feed: AppBskyFeedDefs.FeedViewPost[]; 5 9 cursor: string; 6 10 }; 11 + 12 + // https://bsky.app/profile/essem.space/feed/non-bsky-pds 13 + const partMap: Map<string, string> = new Map([ 14 + ["feed", "app.bsky.feed.generator"], 15 + ["post", "app.bsky.feed.post"], 16 + ["lists", "app.bsky.graph.list"], 17 + ]); 18 + /** Converts social-app links to {@link AtURI}s */ 19 + export async function SocialAppToURI(link: string, resolve: boolean = false): Promise<AtURI> { 20 + const url = new URL(link); 21 + const parts = url.pathname.split("/").splice(1); 22 + console.log(parts); 23 + let id = parts[1]; 24 + if (resolve) { 25 + id = (await DocProxy.get(id)).handle; 26 + } 27 + if (parts[0] == "profile" && parts.length >= 2) { 28 + if (parts.length == 2) return new AtURI(id); 29 + if (parts.length == 4) return new AtURI(id, partMap.get(parts[2])!, parts[3]); 30 + } 31 + if (parts[0] == "starter-pack" && parts.length == 3) return new AtURI(id, "app.bsky.graph.starterpack", parts[2]); 32 + throw new Error("URL provided is not under /profile nor /starter-pack"); 33 + } 34 + 35 + export type List = { 36 + name: string; 37 + purpose: string; 38 + createdAt: Date; 39 + }; 40 + export type ListItem = { 41 + list: AtURI; 42 + subject: DID; 43 + createdAt: Date; 44 + }; 45 + export async function GetListItems(list: AtURI) { 46 + return (await Promise.all((await Constellation.getLinks(list.toString()!, "app.bsky.graph.listitem", ".list")) 47 + .map(async x=>await getUriRecord<ListItem>(x)))); 48 + }
+45
src/support/caching.ts
··· 1 + import { DID, Handle, StripHandle } from "./atproto.ts"; 2 + import { MiniDoc, resolveMiniDoc } from "./slingshot.ts"; 3 + 4 + type Identifier = DID | Handle; 5 + type PromiseCallbacks = { 6 + resolve: (doc: MiniDoc) => void; 7 + reject: (err: unknown) => void; 8 + } 9 + 10 + class _DocCache { 11 + cache: Map<Identifier, MiniDoc> = new Map(); 12 + waiting: Map<Identifier, PromiseCallbacks[]> = new Map(); 13 + async get(k: Identifier): Promise<MiniDoc> { 14 + const key = StripHandle(k); 15 + if (this.cache.has(key)) { 16 + console.debug("cache hit " + key); 17 + return this.cache.get(key)!; 18 + } 19 + 20 + if (this.waiting.has(key)) { 21 + console.debug("cache soft miss " + key); 22 + return new Promise((resolve, reject)=>{ 23 + this.waiting.get(key)!.push({resolve, reject}); 24 + }); 25 + } 26 + 27 + this.waiting.set(key, []); 28 + try { 29 + console.debug("cache hard miss " + key); 30 + const grabbed = await resolveMiniDoc(key); 31 + this.cache.set(key, grabbed); 32 + this.cache.set(grabbed.did, grabbed); 33 + this.cache.set(grabbed.handle, grabbed); 34 + this.waiting.get(key)!.forEach(p => p.resolve(grabbed)); 35 + return grabbed; 36 + } catch (error) { 37 + this.waiting.get(key)!.forEach(p => p.reject(error)); 38 + throw error; 39 + } finally { 40 + this.waiting.delete(key); 41 + } 42 + } 43 + } 44 + 45 + export const DocProxy = new _DocCache();
+3 -3
src/support/constellation.ts
··· 1 - import { AtURI, DID, NSID, ValidateNSID } from "./atproto.ts"; 1 + import { AtURI, AtURIString, DID, NSID, ValidateNSID } from "./atproto.ts"; 2 2 3 3 const BASEURL = "https://constellation.microcosm.blue"; 4 4 ··· 16 16 cursor: string; 17 17 }; 18 18 19 - type Target = AtURI | DID; 19 + type Target = AtURIString | DID; 20 20 /** 21 21 * Retrieves an array of record references to records containing links to the specified target. 22 22 * @throws When the provided NSID is invalid. 23 23 */ 24 - export async function links(target: Target, collection: NSID, path: string): Promise<AtURI[]> { 24 + export async function getLinks(target: Target, collection: NSID, path: string): Promise<AtURI[]> { 25 25 if (ValidateNSID(collection) == null) { 26 26 throw new Error("invalid NSID for collection parameter"); 27 27 }
+2 -2
src/support/slingshot.ts
··· 17 17 return await XQuery<MiniDoc>("com.bad-example.identity.resolveMiniDoc", {identifier: identifier}, SLINGSHOT); 18 18 } 19 19 export async function getUriRecord<T>(at_uri: AtURI, cid: string | null = null) { 20 - return await XQuery<RecordResponse<T>>("com.bad-example.repo.getRecord", {at_uri: at_uri.toString()!, cid: cid}, SLINGSHOT); 21 - } 20 + return await XQuery<RecordResponse<T>>("com.bad-example.repo.getUriRecord", {at_uri: at_uri.toString()!, cid: cid}, SLINGSHOT); 21 + }
+39 -10
src/support/tangled.ts
··· 1 - import * as x from "../extras.ts"; 2 - import { AtURI, ListRecords, XQuery } from "./atproto.ts"; 1 + import * as x from "@/extras.ts"; 2 + import { AtURI, DID, GetRecord, ListRecords } from "./atproto.ts"; 3 3 import { getUriRecord, MiniDoc } from "./slingshot.ts"; 4 4 import * as Constellation from "./constellation.ts"; 5 + import { DocProxy } from "./caching.ts"; 5 6 6 7 export type Issue = { 7 8 body: string; 8 9 createdAt: string; 9 10 issueId: number; 10 - owner: string; 11 + owner: DID; 12 + ownerHandle: string; 11 13 repo: string; 12 - title: string; 14 + title: string; 15 + uri: AtURI; // not part of actual issue 16 + }; 17 + export enum IssueStateValues { 18 + Open = "sh.tangled.repo.issue.state.open", 19 + Closed = "sh.tangled.repo.issue.state.closed" 20 + } 21 + export type IssueState = { 22 + issue: AtURI; 23 + state: IssueStateValues; 24 + }; 25 + 26 + export type IssueMeta = { 27 + issue: Issue; 28 + state: IssueState; 13 29 }; 14 30 export type RepoRef = { 15 31 readonly owner: string; ··· 31 47 }; 32 48 33 49 export async function GetRepo(ref: RepoRef): Promise<Repo> { 34 - const repoOwnerDoc = await XQuery<MiniDoc>("com.bad-example.identity.resolveMiniDoc", {identifier: ref.owner}); 50 + const repoOwnerDoc = await DocProxy.get(ref.owner); 35 51 const ownedRepos = await ListRecords<RawRepo>(repoOwnerDoc.did, "sh.tangled.repo", repoOwnerDoc.pds); 36 52 const repoRecord = ownedRepos.records.find(x=>x.value.name == ref.name)!; 37 53 return { ··· 41 57 }; 42 58 } 43 59 export async function GetIssues(repo: Repo, timeout: number = 10000) { 44 - const allIssueRefs = await x.timeout(timeout, Constellation.links(repo.uri, "sh.tangled.repo.issue", ".repo")); 45 - const issues = allIssueRefs.map(async (uri)=>{ 60 + const allIssueRefs = await x.timeout(timeout, Constellation.getLinks(repo.uri.toString()!, "sh.tangled.repo.issue", ".repo")); 61 + const issues = await Promise.all(allIssueRefs.map(async (uri)=>{ 46 62 try { 47 - return (await x.timeout(timeout/5,getUriRecord<Issue>(uri))).value; 63 + const issue = (await x.timeout(timeout/5,getUriRecord<Issue>(uri))).value; 64 + issue.uri = uri; 65 + issue.ownerHandle = (await DocProxy.get(issue.owner)).handle; 66 + return issue; 48 67 } catch { 49 68 console.log(`issue timed out: ${uri.toString()}`); 50 69 return null; 51 70 } 52 - }).filter(x=>x!=null) as unknown as Issue[]; // despite filtering them out, typescript still thinks there could be nulls 53 - return issues.sort((a,b)=>b.issueId-a.issueId); 71 + })) as unknown as Issue[]; // despite filtering them out, typescript still thinks there could be nulls 72 + return issues.filter(x=>x!=null).sort((a,b)=>b.issueId-a.issueId); 73 + } 74 + export async function GetIssueState(issue: Issue) { 75 + const ref = await Constellation.getLinks(issue.uri.toString()!, "sh.tangled.repo.issue.state", ".issue"); 76 + const state = GetRecord<IssueState>(ref[0]); 77 + return state; 78 + } 79 + export async function GetIssueWithMeta(uri: AtURI) { 80 + const issue = (await getUriRecord<Issue>(uri)).value; 81 + const state = await GetIssueState(issue); 82 + return {issue, state}; 54 83 } 55 84 56 85 /** Takes a string like `tangled.sh/core` or `https://tangled.sh/@tangled.sh/core` and returns a {@link RepoRef} */
+34 -11
src/windowing_mod.ts
··· 1 - import { string } from "../out/main.js"; 2 1 import { Button, Container, Label, Node } from "./domlink.ts"; 3 - 4 2 5 3 class TitleBar extends Container { 6 4 label: Label; ··· 19 17 this.closeButton.wraps.style.display = x ? "inline-block" : "none"; 20 18 } 21 19 } 20 + 22 21 export class Window extends Container { 22 + // i know that i dont technically need to have these per-intance (there's no more than one cursor), 23 + // but i still kind of feel like i should. 23 24 private static currentlyDragged: Window | null = null; 24 25 private static mouseRelX = 0; 25 26 private static mouseRelY = 0; ··· 33 34 this.titleBar = new TitleBar(title, ()=>{this.wraps.remove();}); 34 35 this.add(this.titleBar); 35 36 this.titleBar.wraps.addEventListener("mousedown", this.titleGrabHandler.bind(this)); 36 - this.wraps.addEventListener("mousedown", ()=>{this.wraps.style.zIndex = `${Window.zIndexCounter++}`;}); 37 + this.wraps.addEventListener("mousedown", this.front); 38 + this.front(); 37 39 this.add(this.content); 38 40 this.style((s)=>{ 39 41 s.width = `${width}px`; ··· 41 43 s.top = "0px"; 42 44 s.left = "0px"; 43 45 }); 46 + 47 + globalThis.window.addEventListener("resize", ()=>{ 48 + // deno-lint-ignore no-self-assign 49 + this.position = this.position; 50 + }); 44 51 } 52 + 53 + front() { 54 + this.style((s)=>{ 55 + s.zIndex = `${Window.zIndexCounter++}`; 56 + }); 57 + } 58 + 45 59 override with(...nodes: (Node | string)[]): this { 46 60 const w = this.wraps.style.width; 47 61 const h = this.wraps.style.height; ··· 78 92 private static onMouseMove(ev: MouseEvent) { 79 93 const draggedWindow = Window.currentlyDragged; 80 94 if (draggedWindow) { 81 - const viewportWidth = document.documentElement.clientWidth; 82 - const viewportHeight = document.documentElement.clientHeight; 83 - 84 95 const newLeft = ev.clientX - Window.mouseRelX; 85 96 const newTop = ev.clientY - Window.mouseRelY; 97 + draggedWindow.position = [newLeft, newTop]; 98 + } 99 + } 86 100 87 - const clampedLeft = Math.min(Math.max(0, newLeft), viewportWidth - draggedWindow.wraps.offsetWidth); 88 - const clampedTop = Math.min(Math.max(0, newTop), viewportHeight - draggedWindow.wraps.offsetHeight); 101 + set position([x, y]: [number, number]) { 102 + const viewportWidth = document.documentElement.clientWidth; 103 + const viewportHeight = document.documentElement.clientHeight; 89 104 90 - draggedWindow.wraps.style.left = `${clampedLeft}px`; 91 - draggedWindow.wraps.style.top = `${clampedTop}px`; 92 - } 105 + const clampedLeft = Math.min(Math.max(0, x), viewportWidth - this.wraps.offsetWidth); 106 + const clampedTop = Math.min(Math.max(0, y), viewportHeight - this.wraps.offsetHeight); 107 + 108 + this.wraps.style.left = `${clampedLeft}px`; 109 + this.wraps.style.top = `${clampedTop}px`; 93 110 } 111 + 112 + public get position(): [number, number] { 113 + const rect = this.wraps.getBoundingClientRect(); 114 + return [rect.left, rect.top]; 115 + } 116 + 94 117 95 118 private static onMouseUp(_ev: MouseEvent) { 96 119 if (Window.currentlyDragged) {
+12 -6
src/windows/bluesky/feed.ts
··· 1 1 // formerly src/main.ts. how far we've come. 2 2 3 - import { Column, Container, Image, Row } from "../../domlink.ts"; 3 + import { Button, Column, Container, Image, Row } from "@/domlink.ts"; 4 4 import { AppBskyFeedDefs, AppBskyFeedPost } from '@atcute/bluesky'; 5 - import { XQuery } from "../../support/atproto.ts"; 6 - import { FeedResponse as RawFeedResponse } from "../../support/bluesky.ts"; 5 + import { AtURI, XQuery } from "@/support/atproto.ts"; 6 + import { FeedResponse as RawFeedResponse } from "@/support/bluesky.ts"; 7 + import { displayAuthorFeed } from "@/desktop.ts"; 7 8 8 9 class PostView extends Container { 9 10 constructor(fvp: AppBskyFeedDefs.FeedViewPost) { ··· 12 13 const record = post.record as AppBskyFeedPost.Main; 13 14 this.with( 14 15 new Row().with( 15 - new Image(post.author.avatar ?? "/media/default-avatar.png").class("Avatar"), 16 + new Image(post.author.avatar ?? "/media/default-avatar.png").class("Avatar").style(s=>s.paddingRight="1ch"), 16 17 new Column().with( 17 - post.author.displayName ?? post.author.handle, 18 + new Row().with(post.author.displayName ?? "", 19 + new Button(`@${post.author.handle}`, ()=>{displayAuthorFeed(post.author.handle);}, true) 20 + .style(s=>{s.fontStyle="italic";s.color="gray";s.paddingLeft="0.5em";})), 18 21 record.text, 19 22 new Row().with( 20 23 `๐Ÿ—จ๏ธ ${post.replyCount} `, 21 24 `๐Ÿ” ${post.repostCount} `, 22 25 `๐Ÿ”ƒ ${post.quoteCount} `, 23 26 `โค๏ธ ${post.likeCount} `, 24 - ) 27 + ).style(s=>s.paddingTop="0.5em") 25 28 ), 26 29 ) 27 30 ); ··· 48 51 } 49 52 static createAuthorGenerator(did: string): FeedGenerator { 50 53 return (cursor) => XQuery<RawFeedResponse>("app.bsky.feed.getAuthorFeed", {actor: did, cursor: cursor}); 54 + } 55 + static createListGenerator(list: AtURI): FeedGenerator { 56 + return (cursor) => XQuery<RawFeedResponse>("app.bsky.feed.getListFeed", {list: list.toString(), cursor: cursor}); 51 57 } 52 58 }
+78 -113
src/windows/tangled/issuesearch.ts
··· 1 - import { Body, Button, Column, Input, Label, Link, Node, Row, Table, TableCell, TableRow } from "../../domlink.ts"; 2 - import { AtURI, AtURIString, GetRecord, ListRecords, XQuery } from "../../support/atproto.ts"; 3 - import { GetIssues, GetRepo, Issue, StringRepoRef } from "../../support/tangled.ts"; 4 - import * as Constellation from "../../support/constellation.ts"; 5 - import { MiniDoc } from "../../support/slingshot.ts"; 6 - Body.with(new Link("TypeScript source here!").to("https://tangled.sh/@hotsocket.fyi/domlink/blob/main/src/issuesearch.ts")); 7 - 8 - type recordListingItem<T> = { 9 - uri: string; 10 - cid: string; 11 - value: T; 12 - }; 13 - 14 - async function timeout<T>(time: number, promise: Promise<T>): Promise<T> { 15 - const result = await Promise.race([new Promise<null>(r => setTimeout(r, time, null)), promise]); 16 - if (result == null) { 17 - throw new Error("timeout"); 18 - } 19 - return result; 20 - } 21 - 22 - type issueItem = ({ handle: string; issue: Issue }); 23 - let allIssues: issueItem[] = []; 24 - let currentRepoLink = ""; // used to build issue links 25 - 26 - function renderIssues(issues: issueItem[]) { 27 - const view_issues_list = new Table().add( 28 - new TableRow().with( 29 - new TableCell().add("Handle"), 30 - new TableCell().add("Issue#"), 31 - new TableCell().add("Title") 32 - ) 33 - ); 34 - issues.forEach(issue => { 35 - if (issue) { 36 - const view_issue_row = new TableRow(); 37 - view_issue_row.with( 38 - new TableCell().add(new Link(issue.handle).to(`https://tangled.sh/@${issue.handle}`)), 39 - new TableCell().add(new Link(issue.issue.issueId.toString()).to(`${currentRepoLink}/issues/${issue.issue.issueId}`)), 40 - new TableCell().add(issue.issue.title) 41 - ); 42 - view_issues_list.add(view_issue_row); 43 - } 44 - }); 45 - return view_issues_list; 46 - } 1 + // rewrite of @/issuesearch.ts to be a self-contained class that can be shoved into a window easily 47 2 3 + import { Column, Container, Node, Input, Link, Row, Table, TableCell, TableRow, Button } from "@/domlink.ts"; 4 + import { GetIssues, GetRepo, Issue, RepoRef } from "@/support/tangled.ts"; 5 + import { Window } from "@/windowing_mod.ts"; 48 6 49 - const repoUrl = new Input(); 50 - let last: Node | null = null; 51 - const statusText = new Label("waiting"); 52 - const runButton = new Button("GO!", async () => { 53 - (runButton.wraps as HTMLButtonElement).disabled = true; 54 - if (last) { 55 - try { 56 - Body.delete(last); 57 - } finally { 58 - last = null; 7 + export class IssueSearch extends Column { 8 + repoInput = new Input(); 9 + searchInput = new Input(); 10 + searchRow = new Row().with("Search: ", this.searchInput).style(s=>s.display="none"); 11 + renderOut: Node = new Container().with("<nothing yet>"); 12 + repoInfo: RepoRef = {} as RepoRef; 13 + issues: Issue[] = []; 14 + window: Window | null = null; 15 + constructor(window: Window | null = null) { 16 + super(); 17 + if (window) { 18 + this.window = window; 59 19 } 20 + this.class("IssueSearch"); 21 + this.with( 22 + new Row().with("Repo: ", this.repoInput, new Button("Get", async ()=>{await this.getIssues(this.repoInput.value);}), this.searchRow), 23 + this.renderOut 24 + ); 25 + this.searchInput.watch((search)=>{ 26 + if (this.issues.length > 0) { 27 + this.render(this.issues.filter((issue)=>{ 28 + return issue.issueId == Number(search) || 29 + search.split(" ").every((word)=> ( 30 + issue.owner.toLowerCase().includes(word) || 31 + issue.title.toLowerCase().includes(word) || 32 + issue.body.toLowerCase().includes(word))) 33 + })); 34 + } 35 + }); 60 36 } 61 - allIssues = []; 62 - searchRow.style(x => x.display = "none"); 63 - try { 64 - await getIssues(repoUrl.value); 65 - last = renderIssues(allIssues); 66 - searchRow.style(x => x.removeProperty("display")); 67 - } catch (e) { 68 - last = new Label("Failed"); 69 - console.error(e); 37 + async getIssues(repo: string) { 38 + this.repoInfo = IssueSearch.parseRepoInfo(repo); 39 + this.repoInput.value = `@${this.repoInfo.owner}/${this.repoInfo.name}`; 40 + if (window) this.window!.title = `Issues in @${this.repoInfo.owner}/${this.repoInfo.name}`; 41 + const repoRecord = await GetRepo(this.repoInfo); 42 + this.issues = await GetIssues(repoRecord); 43 + this.searchRow.style(s=>s.removeProperty("display")); 44 + this.render(this.issues); 70 45 } 71 - Body.add(last); 72 - searchInput.value = ""; 73 - (runButton.wraps as HTMLButtonElement).disabled = false; 74 - }); 75 - 76 - 77 - 78 - const searchInput = new Input(); 79 - searchInput.watch((search) => { 80 - if (allIssues.length > 0) { 81 - if (last) { 82 - Body.delete(last); 83 - } 84 - search = search.toLowerCase(); 85 - last = renderIssues(allIssues.filter((item) => { 86 - return item.issue.issueId == Number(item) || 87 - search.split(" ").every((word) => ( 88 - item.handle.toLowerCase().includes(word) || 89 - item.issue.title.toLowerCase().includes(word) || 90 - item.issue.body.toLowerCase().includes(word))) 91 - })); 92 - Body.add(last); 46 + render(issues: Issue[]) { 47 + this.remove(this.renderOut); 48 + this.renderOut = new Table().with( 49 + new TableRow().with( 50 + new TableCell().with("Handle"), 51 + new TableCell().with("Issue#"), 52 + new TableCell().with("Title")).style(s=>s.fontWeight="bold"), 53 + ...(issues.map(issue => new TableRow().with( 54 + new TableCell().with(new Link(`@${issue.ownerHandle}`).to(`https://tangled.sh/${issue.ownerHandle}`)), 55 + new TableCell().with(new Link(`${issue.issueId}`).to(`https://tangled.sh/@${this.repoInfo.owner}/${this.repoInfo.name}/issues/${issue.issueId}`)), 56 + new TableCell().with(issue.title) 57 + ))) 58 + ); 59 + this.with(this.renderOut); 93 60 } 94 - }); 95 - const searchRow = new Row().with( 96 - "search", 97 - searchInput 98 - ); 99 - searchRow.style(x => x.display = "none"); 100 - 101 - 102 - 103 - Body.add(new Row().with( 104 - "List issues for repo: ", 105 - repoUrl, 106 - runButton, 107 - statusText 108 - )); 109 - Body.add(searchRow); 110 - export class IssueList extends Column { 111 - issues: Issue[] | null = null; 112 - constructor() { 113 - super(); 114 - this.class("IssueList"); 61 + 62 + static parseRepoInfo(repo: string) { 63 + const repoSplat = repo.split("//"); 64 + let owner: string; 65 + let name: string; 66 + if (repoSplat.length == 1) { 67 + const repoHalves = repo.split("/"); 68 + if (repoHalves.length == 1) throw new Error("invalid repo string"); 69 + owner = repoHalves[0]; 70 + name = repoHalves[1]; 71 + } else { 72 + if (repoSplat[0] == "https:") { 73 + const repoUrlSplat = new URL(repo).pathname.split("/"); 74 + // [ "", "@tangled.sh", "core" ] 75 + if (repoUrlSplat.length < 2) throw new Error("invalid repo url"); 76 + owner = repoUrlSplat[1]; 77 + name = repoUrlSplat[2]; 78 + } else { 79 + throw new Error("unknown repo uri scheme"); 80 + } 81 + } 82 + if (owner.startsWith("@")) owner = owner.substring(1); 83 + return { owner, name }; 115 84 } 116 - async getIssues(repo: string) { 117 - const rp = await GetRepo(StringRepoRef(repo)); 118 - this.issues = await GetIssues(rp); 119 - } 120 - } 85 + }
+1 -1
static/desktop.html
··· 3 3 <head> 4 4 <meta charset="UTF-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 - <title>desktop</title> 6 + <title>==&gt;DESKTOP&lt;==</title> 7 7 <link rel="stylesheet" href="styles/domlink.css"> 8 8 <link rel="stylesheet" href="mine.css"> 9 9 </head>
+22
static/increment.css
··· 1 + :root { 2 + color-scheme: light dark; 3 + } 4 + * { 5 + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif !important; 6 + } 7 + 8 + body { 9 + margin: 0 auto; 10 + margin-top: 10em; 11 + max-width: fit-content; 12 + display: flex; 13 + background-color: Canvas; 14 + } 15 + 16 + button { 17 + margin-top: 1em; 18 + border: 1px solid ButtonBorder; 19 + color: ButtonText !important; 20 + border-radius: 1em; 21 + background-color: AccentColor; 22 + }
+13
static/increment.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>DomLink Increment Demo</title> 7 + <link rel="stylesheet" href="styles/domlink.css"> 8 + <link rel="stylesheet" href="increment.css"> 9 + </head> 10 + <body> 11 + <script src="increment.js" type="module"></script> 12 + </body> 13 + </html>
+12 -1
test/support/atproto.ts
··· 1 - import { ValidateNSID, ValidateDID, AtURI } from "../../src/support/atproto.ts"; 1 + import { ValidateNSID, ValidateDID, AtURI } from "@/support/atproto.ts"; 2 2 import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; 3 3 4 4 Deno.test("ValidateNSID returns NSID for valid input", () => { ··· 55 55 uri = AtURI.fromString("at://did:plc:example/invalid-collection/rkey123"); 56 56 assertEquals(uri.toString(), null); 57 57 58 + uri = AtURI.fromString("at://did:plc:example//rkey123"); 59 + assertEquals(uri.toString(), null); 60 + 58 61 }); 62 + 63 + Deno.test("AtURI.toString works with varying number of parts", () => { 64 + let uri = AtURI.fromString("at://did:plc:example/com.example.collection"); 65 + assertEquals(uri.toString(), "at://did:plc:example/com.example.collection"); 66 + 67 + uri = AtURI.fromString("at://did:plc:example"); 68 + assertEquals(uri.toString(), "at://did:plc:example"); 69 + });