+3
.gitignore
+3
.gitignore
+19
.tangled/workflows/netlify.yml
+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
+15
-12
deno.json
+1
-1
flake.nix
+1
-1
flake.nix
+3
-2
package.json
+3
-2
package.json
···
15
},
16
"scripts": {
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/",
20
"test": "deno test test/**/*.ts"
21
}
22
}
···
15
},
16
"scripts": {
17
"serve": "http-server ./out -C",
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/",
21
"test": "deno test test/**/*.ts"
22
}
23
}
+3
-1
readme
+3
-1
readme
+31
scripts/index.ts
+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
+50
-9
src/desktop.ts
···
1
import { Body, Button, Column, Input, Link, Row } from "./domlink.ts";
2
-
import { resolveMiniDoc } from "./support/slingshot.ts";
3
import { Window } from "./windowing_mod.ts";
4
import { Feed } from "./windows/bluesky/feed.ts";
5
6
const about = new Window("About", 150).with(
7
new Column().with(
···
11
)
12
).closable(false); // haha
13
14
const userInput = new Input();
15
const authorFeedWindowCreator = new Row().with(
16
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
22
})
23
);
24
const instantiator = new Column().with(
25
-
authorFeedWindowCreator,
26
).style((x) => x.maxWidth = "100ch");
27
28
Body.with(
29
-
instantiator,
30
-
about
31
);
···
1
import { Body, Button, Column, Input, Link, Row } from "./domlink.ts";
2
+
import { List, SocialAppToURI } from "./support/bluesky.ts";
3
+
import { DocProxy } from "./support/caching.ts";
4
+
import { getUriRecord, resolveMiniDoc } from "./support/slingshot.ts";
5
import { Window } from "./windowing_mod.ts";
6
import { Feed } from "./windows/bluesky/feed.ts";
7
+
import { IssueSearch } from "./windows/tangled/issuesearch.ts";
8
9
const about = new Window("About", 150).with(
10
new Column().with(
···
14
)
15
).closable(false); // haha
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
+
}
23
const userInput = new Input();
24
const authorFeedWindowCreator = new Row().with(
25
userInput,
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);
57
})
58
);
59
+
60
const instantiator = new Column().with(
61
+
authorFeedWindowCreator,
62
+
listFeedWindowCreator,
63
+
tangledIssuesWindowCreator
64
).style((x) => x.maxWidth = "100ch");
65
66
Body.with(
67
+
instantiator,
68
+
about
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
+3
-2
src/domlink.ts
···
172
}
173
/** Wrapper for {@link HTMLButtonElement `<button>`} */
174
export class Button extends Text {
175
-
constructor(label: string, action: EventListener) {
176
-
let btn = document.createElement("button");
177
btn.textContent = label;
178
btn.addEventListener("click", action);
179
btn.classList.add("LButton");
···
172
}
173
/** Wrapper for {@link HTMLButtonElement `<button>`} */
174
export class Button extends Text {
175
+
constructor(label: string, action: EventListener, asLink: boolean = false) {
176
+
let btn = document.createElement(asLink ? "a" : "button");
177
+
if (asLink) btn.classList.add("LBtnLink");
178
btn.textContent = label;
179
btn.addEventListener("click", action);
180
btn.classList.add("LButton");
+12
src/increment.ts
+12
src/increment.ts
+7
-3
src/support/atproto.ts
+7
-3
src/support/atproto.ts
···
9
return NSIDExpression.test(nsid) ? nsid : null;
10
}
11
12
export type DID = `did:${"web"|"plc"}:${string}`;
13
export function ValidateDID(did: string): string | null {
14
const parts = did.split(":");
···
16
return isValid ? did : null;
17
}
18
19
-
export type AtURIString = `at://${string}/${string}/${string}`;
20
export class AtURI {
21
readonly authority: string | null;
22
readonly collection: string | null;
···
25
const parts = uri.split("/").slice(2);
26
return new AtURI(ValidateDID(parts[0]), ValidateNSID(parts[1]), parts[2]);
27
}
28
-
constructor(authority: string | null, collection: string | null, rkey: string | null) {
29
this.authority = authority;
30
this.collection = collection;
31
this.rkey = rkey;
···
70
71
// technically you can cast it to whatever you want but i feel like using a generic(?) makes it cleaner
72
/** Calls an XRPC "query" method (HTTP GET)
73
-
* @param service Defaults to {@link https://slingshot.microcosm.blue Slingshot}.
74
*/
75
export async function XQuery<T>(method: string, params: Record<string, string | number | null> | null = null, service: string = DEFAULT_SERVICE) {
76
let QueryURL = `${service}/xrpc/${method}`;
···
9
return NSIDExpression.test(nsid) ? nsid : null;
10
}
11
12
+
export type Handle = string;
13
+
export function StripHandle(handle: Handle) {
14
+
return handle.replace("@", "");
15
+
}
16
export type DID = `did:${"web"|"plc"}:${string}`;
17
export function ValidateDID(did: string): string | null {
18
const parts = did.split(":");
···
20
return isValid ? did : null;
21
}
22
23
+
export type AtURIString = string; //`at://${string}/${string}/${string}`;
24
export class AtURI {
25
readonly authority: string | null;
26
readonly collection: string | null;
···
29
const parts = uri.split("/").slice(2);
30
return new AtURI(ValidateDID(parts[0]), ValidateNSID(parts[1]), parts[2]);
31
}
32
+
constructor(authority: string | null, collection: string | null = null, rkey: string | null = null) {
33
this.authority = authority;
34
this.collection = collection;
35
this.rkey = rkey;
···
74
75
// technically you can cast it to whatever you want but i feel like using a generic(?) makes it cleaner
76
/** Calls an XRPC "query" method (HTTP GET)
77
+
* @param service Defaults to the {@link https://api.bsky.app/ Bluesky (PBC) API} service.
78
*/
79
export async function XQuery<T>(method: string, params: Record<string, string | number | null> | null = null, service: string = DEFAULT_SERVICE) {
80
let QueryURL = `${service}/xrpc/${method}`;
+42
src/support/bluesky.ts
+42
src/support/bluesky.ts
···
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";
6
7
export type FeedResponse = {
8
feed: AppBskyFeedDefs.FeedViewPost[];
9
cursor: string;
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
+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
+3
-3
src/support/constellation.ts
···
1
-
import { AtURI, DID, NSID, ValidateNSID } from "./atproto.ts";
2
3
const BASEURL = "https://constellation.microcosm.blue";
4
···
16
cursor: string;
17
};
18
19
-
type Target = AtURI | DID;
20
/**
21
* Retrieves an array of record references to records containing links to the specified target.
22
* @throws When the provided NSID is invalid.
23
*/
24
-
export async function links(target: Target, collection: NSID, path: string): Promise<AtURI[]> {
25
if (ValidateNSID(collection) == null) {
26
throw new Error("invalid NSID for collection parameter");
27
}
···
1
+
import { AtURI, AtURIString, DID, NSID, ValidateNSID } from "./atproto.ts";
2
3
const BASEURL = "https://constellation.microcosm.blue";
4
···
16
cursor: string;
17
};
18
19
+
type Target = AtURIString | DID;
20
/**
21
* Retrieves an array of record references to records containing links to the specified target.
22
* @throws When the provided NSID is invalid.
23
*/
24
+
export async function getLinks(target: Target, collection: NSID, path: string): Promise<AtURI[]> {
25
if (ValidateNSID(collection) == null) {
26
throw new Error("invalid NSID for collection parameter");
27
}
+2
-2
src/support/slingshot.ts
+2
-2
src/support/slingshot.ts
···
17
return await XQuery<MiniDoc>("com.bad-example.identity.resolveMiniDoc", {identifier: identifier}, SLINGSHOT);
18
}
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
-
}
···
17
return await XQuery<MiniDoc>("com.bad-example.identity.resolveMiniDoc", {identifier: identifier}, SLINGSHOT);
18
}
19
export async function getUriRecord<T>(at_uri: AtURI, cid: string | null = null) {
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
+39
-10
src/support/tangled.ts
···
1
-
import * as x from "../extras.ts";
2
-
import { AtURI, ListRecords, XQuery } from "./atproto.ts";
3
import { getUriRecord, MiniDoc } from "./slingshot.ts";
4
import * as Constellation from "./constellation.ts";
5
6
export type Issue = {
7
body: string;
8
createdAt: string;
9
issueId: number;
10
-
owner: string;
11
repo: string;
12
-
title: string;
13
};
14
export type RepoRef = {
15
readonly owner: string;
···
31
};
32
33
export async function GetRepo(ref: RepoRef): Promise<Repo> {
34
-
const repoOwnerDoc = await XQuery<MiniDoc>("com.bad-example.identity.resolveMiniDoc", {identifier: ref.owner});
35
const ownedRepos = await ListRecords<RawRepo>(repoOwnerDoc.did, "sh.tangled.repo", repoOwnerDoc.pds);
36
const repoRecord = ownedRepos.records.find(x=>x.value.name == ref.name)!;
37
return {
···
41
};
42
}
43
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)=>{
46
try {
47
-
return (await x.timeout(timeout/5,getUriRecord<Issue>(uri))).value;
48
} catch {
49
console.log(`issue timed out: ${uri.toString()}`);
50
return null;
51
}
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);
54
}
55
56
/** Takes a string like `tangled.sh/core` or `https://tangled.sh/@tangled.sh/core` and returns a {@link RepoRef} */
···
1
+
import * as x from "@/extras.ts";
2
+
import { AtURI, DID, GetRecord, ListRecords } from "./atproto.ts";
3
import { getUriRecord, MiniDoc } from "./slingshot.ts";
4
import * as Constellation from "./constellation.ts";
5
+
import { DocProxy } from "./caching.ts";
6
7
export type Issue = {
8
body: string;
9
createdAt: string;
10
issueId: number;
11
+
owner: DID;
12
+
ownerHandle: string;
13
repo: 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;
29
};
30
export type RepoRef = {
31
readonly owner: string;
···
47
};
48
49
export async function GetRepo(ref: RepoRef): Promise<Repo> {
50
+
const repoOwnerDoc = await DocProxy.get(ref.owner);
51
const ownedRepos = await ListRecords<RawRepo>(repoOwnerDoc.did, "sh.tangled.repo", repoOwnerDoc.pds);
52
const repoRecord = ownedRepos.records.find(x=>x.value.name == ref.name)!;
53
return {
···
57
};
58
}
59
export async function GetIssues(repo: Repo, timeout: number = 10000) {
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)=>{
62
try {
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;
67
} catch {
68
console.log(`issue timed out: ${uri.toString()}`);
69
return null;
70
}
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};
83
}
84
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
+34
-11
src/windowing_mod.ts
···
1
-
import { string } from "../out/main.js";
2
import { Button, Container, Label, Node } from "./domlink.ts";
3
-
4
5
class TitleBar extends Container {
6
label: Label;
···
19
this.closeButton.wraps.style.display = x ? "inline-block" : "none";
20
}
21
}
22
export class Window extends Container {
23
private static currentlyDragged: Window | null = null;
24
private static mouseRelX = 0;
25
private static mouseRelY = 0;
···
33
this.titleBar = new TitleBar(title, ()=>{this.wraps.remove();});
34
this.add(this.titleBar);
35
this.titleBar.wraps.addEventListener("mousedown", this.titleGrabHandler.bind(this));
36
-
this.wraps.addEventListener("mousedown", ()=>{this.wraps.style.zIndex = `${Window.zIndexCounter++}`;});
37
this.add(this.content);
38
this.style((s)=>{
39
s.width = `${width}px`;
···
41
s.top = "0px";
42
s.left = "0px";
43
});
44
}
45
override with(...nodes: (Node | string)[]): this {
46
const w = this.wraps.style.width;
47
const h = this.wraps.style.height;
···
78
private static onMouseMove(ev: MouseEvent) {
79
const draggedWindow = Window.currentlyDragged;
80
if (draggedWindow) {
81
-
const viewportWidth = document.documentElement.clientWidth;
82
-
const viewportHeight = document.documentElement.clientHeight;
83
-
84
const newLeft = ev.clientX - Window.mouseRelX;
85
const newTop = ev.clientY - Window.mouseRelY;
86
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);
89
90
-
draggedWindow.wraps.style.left = `${clampedLeft}px`;
91
-
draggedWindow.wraps.style.top = `${clampedTop}px`;
92
-
}
93
}
94
95
private static onMouseUp(_ev: MouseEvent) {
96
if (Window.currentlyDragged) {
···
1
import { Button, Container, Label, Node } from "./domlink.ts";
2
3
class TitleBar extends Container {
4
label: Label;
···
17
this.closeButton.wraps.style.display = x ? "inline-block" : "none";
18
}
19
}
20
+
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.
24
private static currentlyDragged: Window | null = null;
25
private static mouseRelX = 0;
26
private static mouseRelY = 0;
···
34
this.titleBar = new TitleBar(title, ()=>{this.wraps.remove();});
35
this.add(this.titleBar);
36
this.titleBar.wraps.addEventListener("mousedown", this.titleGrabHandler.bind(this));
37
+
this.wraps.addEventListener("mousedown", this.front);
38
+
this.front();
39
this.add(this.content);
40
this.style((s)=>{
41
s.width = `${width}px`;
···
43
s.top = "0px";
44
s.left = "0px";
45
});
46
+
47
+
globalThis.window.addEventListener("resize", ()=>{
48
+
// deno-lint-ignore no-self-assign
49
+
this.position = this.position;
50
+
});
51
}
52
+
53
+
front() {
54
+
this.style((s)=>{
55
+
s.zIndex = `${Window.zIndexCounter++}`;
56
+
});
57
+
}
58
+
59
override with(...nodes: (Node | string)[]): this {
60
const w = this.wraps.style.width;
61
const h = this.wraps.style.height;
···
92
private static onMouseMove(ev: MouseEvent) {
93
const draggedWindow = Window.currentlyDragged;
94
if (draggedWindow) {
95
const newLeft = ev.clientX - Window.mouseRelX;
96
const newTop = ev.clientY - Window.mouseRelY;
97
+
draggedWindow.position = [newLeft, newTop];
98
+
}
99
+
}
100
101
+
set position([x, y]: [number, number]) {
102
+
const viewportWidth = document.documentElement.clientWidth;
103
+
const viewportHeight = document.documentElement.clientHeight;
104
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`;
110
}
111
+
112
+
public get position(): [number, number] {
113
+
const rect = this.wraps.getBoundingClientRect();
114
+
return [rect.left, rect.top];
115
+
}
116
+
117
118
private static onMouseUp(_ev: MouseEvent) {
119
if (Window.currentlyDragged) {
+12
-6
src/windows/bluesky/feed.ts
+12
-6
src/windows/bluesky/feed.ts
···
1
// formerly src/main.ts. how far we've come.
2
3
-
import { Column, Container, Image, Row } from "../../domlink.ts";
4
import { AppBskyFeedDefs, AppBskyFeedPost } from '@atcute/bluesky';
5
-
import { XQuery } from "../../support/atproto.ts";
6
-
import { FeedResponse as RawFeedResponse } from "../../support/bluesky.ts";
7
8
class PostView extends Container {
9
constructor(fvp: AppBskyFeedDefs.FeedViewPost) {
···
12
const record = post.record as AppBskyFeedPost.Main;
13
this.with(
14
new Row().with(
15
-
new Image(post.author.avatar ?? "/media/default-avatar.png").class("Avatar"),
16
new Column().with(
17
-
post.author.displayName ?? post.author.handle,
18
record.text,
19
new Row().with(
20
`๐จ๏ธ ${post.replyCount} `,
21
`๐ ${post.repostCount} `,
22
`๐ ${post.quoteCount} `,
23
`โค๏ธ ${post.likeCount} `,
24
-
)
25
),
26
)
27
);
···
48
}
49
static createAuthorGenerator(did: string): FeedGenerator {
50
return (cursor) => XQuery<RawFeedResponse>("app.bsky.feed.getAuthorFeed", {actor: did, cursor: cursor});
51
}
52
}
···
1
// formerly src/main.ts. how far we've come.
2
3
+
import { Button, Column, Container, Image, Row } from "@/domlink.ts";
4
import { AppBskyFeedDefs, AppBskyFeedPost } from '@atcute/bluesky';
5
+
import { AtURI, XQuery } from "@/support/atproto.ts";
6
+
import { FeedResponse as RawFeedResponse } from "@/support/bluesky.ts";
7
+
import { displayAuthorFeed } from "@/desktop.ts";
8
9
class PostView extends Container {
10
constructor(fvp: AppBskyFeedDefs.FeedViewPost) {
···
13
const record = post.record as AppBskyFeedPost.Main;
14
this.with(
15
new Row().with(
16
+
new Image(post.author.avatar ?? "/media/default-avatar.png").class("Avatar").style(s=>s.paddingRight="1ch"),
17
new Column().with(
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";})),
21
record.text,
22
new Row().with(
23
`๐จ๏ธ ${post.replyCount} `,
24
`๐ ${post.repostCount} `,
25
`๐ ${post.quoteCount} `,
26
`โค๏ธ ${post.likeCount} `,
27
+
).style(s=>s.paddingTop="0.5em")
28
),
29
)
30
);
···
51
}
52
static createAuthorGenerator(did: string): FeedGenerator {
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});
57
}
58
}
+78
-113
src/windows/tangled/issuesearch.ts
+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
-
}
47
48
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;
59
}
60
}
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);
70
}
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);
93
}
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");
115
}
116
-
async getIssues(repo: string) {
117
-
const rp = await GetRepo(StringRepoRef(repo));
118
-
this.issues = await GetIssues(rp);
119
-
}
120
-
}
···
1
+
// rewrite of @/issuesearch.ts to be a self-contained class that can be shoved into a window easily
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";
6
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;
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
+
});
36
}
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);
45
}
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);
60
}
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 };
84
}
85
+
}
+1
-1
static/desktop.html
+1
-1
static/desktop.html
+22
static/increment.css
+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
+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>
+4
static/styles/domlink-visual.css
+4
static/styles/domlink-visual.css
+6
-2
static/styles/domlink-windowing.css
+6
-2
static/styles/domlink-windowing.css
···
5
border: 1px solid black;
6
box-sizing: border-box;
7
position: absolute;
8
-
background-color: white;
9
overflow: auto;
10
display: flex;
11
flex-direction: column;
12
}
13
.LWindowHandle {
14
width: auto;
···
23
overflow: hidden;
24
}
25
.LWindowContent {
26
flex: 1;
27
-
overflow: hidden;
28
}
···
5
border: 1px solid black;
6
box-sizing: border-box;
7
position: absolute;
8
overflow: auto;
9
display: flex;
10
flex-direction: column;
11
+
background-color: lightgray;
12
+
padding-bottom: 1em;
13
}
14
.LWindowHandle {
15
width: auto;
···
24
overflow: hidden;
25
}
26
.LWindowContent {
27
+
background-color: white;
28
flex: 1;
29
+
width: 100%;
30
+
height: 100%;
31
+
overflow: auto;
32
}
+12
-1
test/support/atproto.ts
+12
-1
test/support/atproto.ts
···
1
-
import { ValidateNSID, ValidateDID, AtURI } from "../../src/support/atproto.ts";
2
import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts";
3
4
Deno.test("ValidateNSID returns NSID for valid input", () => {
···
55
uri = AtURI.fromString("at://did:plc:example/invalid-collection/rkey123");
56
assertEquals(uri.toString(), null);
57
58
});
···
1
+
import { ValidateNSID, ValidateDID, AtURI } from "@/support/atproto.ts";
2
import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts";
3
4
Deno.test("ValidateNSID returns NSID for valid input", () => {
···
55
uri = AtURI.fromString("at://did:plc:example/invalid-collection/rkey123");
56
assertEquals(uri.toString(), null);
57
58
+
uri = AtURI.fromString("at://did:plc:example//rkey123");
59
+
assertEquals(uri.toString(), null);
60
+
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
+
});