Browse Source

fresh repo

master
Silberengel 4 weeks ago
parent
commit
010b420d7f
  1. 1
      .envrc
  2. 44
      .eslintrc
  3. 22
      .gitignore
  4. 9
      .gitlint
  5. 16
      .prettierrc
  6. 21
      .storybook/main.ts
  7. 16
      .storybook/preview.ts
  8. BIN
      .storybook/test-assets/test-profile-image.jpg
  9. 31
      .storybook/test-runner.ts
  10. 6
      .vscode/extensions.json
  11. 13
      .vscode/settings.json
  12. 1
      .yarnrc.yml
  13. 4
      README.md
  14. 82
      flake.lock
  15. 64
      flake.nix
  16. 35
      git_hooks/commit-msg
  17. 11
      maintainers.yaml
  18. 7
      netlify.toml
  19. 9685
      package-lock.json
  20. 65
      package.json
  21. 6
      postcss.config.js
  22. 21
      src/404.html
  23. 3
      src/app.css
  24. 21
      src/app.html
  25. 19
      src/lib/components/AlertError.svelte
  26. 18
      src/lib/components/AlertWarning.svelte
  27. 13
      src/lib/components/Container.svelte
  28. 77
      src/lib/components/CopyField.svelte
  29. 33
      src/lib/components/InstallNgit.svelte
  30. 32
      src/lib/components/Navbar.stories.svelte
  31. 68
      src/lib/components/Navbar.svelte
  32. 28
      src/lib/components/RepoSummaryCard.stories.svelte
  33. 81
      src/lib/components/RepoSummaryCard.svelte
  34. 69
      src/lib/components/ReposSummaryList.stories.svelte
  35. 119
      src/lib/components/ReposSummaryList.svelte
  36. 49
      src/lib/components/events/Compose.svelte
  37. 157
      src/lib/components/events/EventWrapper.svelte
  38. 23
      src/lib/components/events/EventWrapperLite.svelte
  39. 48
      src/lib/components/events/ThreadWrapper.svelte
  40. 22
      src/lib/components/events/content/IssuePreview.svelte
  41. 51
      src/lib/components/events/content/ParsedContent.svelte
  42. 252
      src/lib/components/events/content/Patch.svelte
  43. 19
      src/lib/components/events/content/Repo.svelte
  44. 14
      src/lib/components/events/content/Status.svelte
  45. 54
      src/lib/components/events/content/utils.spec.ts
  46. 318
      src/lib/components/events/content/utils.ts
  47. 12
      src/lib/components/events/type.ts
  48. 27
      src/lib/components/icons.ts
  49. 18
      src/lib/components/issues/icons.ts
  50. 60
      src/lib/components/issues/type.ts
  51. 50
      src/lib/components/proposals/ProposalDetails.svelte
  52. 26
      src/lib/components/proposals/ProposalHeader.stories.svelte
  53. 104
      src/lib/components/proposals/ProposalHeader.svelte
  54. 56
      src/lib/components/proposals/ProposalsList.stories.svelte
  55. 47
      src/lib/components/proposals/ProposalsList.svelte
  56. 34
      src/lib/components/proposals/ProposalsListItem.stories.svelte
  57. 167
      src/lib/components/proposals/ProposalsListItem.svelte
  58. 27
      src/lib/components/proposals/Status.stories.svelte
  59. 110
      src/lib/components/proposals/Status.svelte
  60. 141
      src/lib/components/proposals/StatusSelector.svelte
  61. 22
      src/lib/components/proposals/icons.ts
  62. 60
      src/lib/components/proposals/type.ts
  63. 66
      src/lib/components/proposals/vectors.ts
  64. 55
      src/lib/components/repo/RepoDetails.stories.svelte
  65. 365
      src/lib/components/repo/RepoDetails.svelte
  66. 24
      src/lib/components/repo/RepoHeader.stories.svelte
  67. 61
      src/lib/components/repo/RepoHeader.svelte
  68. 129
      src/lib/components/repo/type.ts
  69. 100
      src/lib/components/repo/utils.spec.ts
  70. 135
      src/lib/components/repo/utils.ts
  71. 127
      src/lib/components/repo/vectors.ts
  72. 141
      src/lib/components/users/UserHeader.stories.svelte
  73. 166
      src/lib/components/users/UserHeader.svelte
  74. 38
      src/lib/components/users/type.ts
  75. 39
      src/lib/components/users/vectors.ts
  76. 29
      src/lib/kinds.ts
  77. 204
      src/lib/stores/Issue.ts
  78. 203
      src/lib/stores/Issues.ts
  79. 210
      src/lib/stores/Proposal.ts
  80. 251
      src/lib/stores/Proposals.ts
  81. 56
      src/lib/stores/ReposIdentifier.ts
  82. 78
      src/lib/stores/ReposPubkey.ts
  83. 55
      src/lib/stores/ReposRecent.ts
  84. 21
      src/lib/stores/ndk.ts
  85. 128
      src/lib/stores/repo.ts
  86. 367
      src/lib/stores/repos.ts
  87. 184
      src/lib/stores/users.ts
  88. 4
      src/lib/stores/utils.ts
  89. 136
      src/lib/wrappers/ComposeIssue.svelte
  90. 159
      src/lib/wrappers/ComposeReply.svelte
  91. 89
      src/lib/wrappers/EventCard.svelte
  92. 69
      src/lib/wrappers/EventPreview.svelte
  93. 23
      src/lib/wrappers/Navbar.svelte
  94. 13
      src/lib/wrappers/RepoDetails.svelte
  95. 76
      src/lib/wrappers/RepoMenu.svelte
  96. 70
      src/lib/wrappers/RepoPageWrapper.svelte
  97. 24
      src/lib/wrappers/Thread.svelte
  98. 138
      src/lib/wrappers/ThreadTree.svelte
  99. 231
      src/lib/wrappers/thread_tree.spec.ts
  100. 70
      src/lib/wrappers/thread_tree.ts
  101. Some files were not shown because too many files have changed in this diff Show More

1
.envrc

@ -1 +0,0 @@
use flake

44
.eslintrc

@ -1,44 +0,0 @@
// eslint-plugin-svelte
// https://sveltejs.github.io/eslint-plugin-svelte/user-guide/
{
"root": true,
"extends": [
// add more generic rule sets here, such as:
// 'eslint:recommended',
"plugin:@typescript-eslint/recommended",
"prettier",
"plugin:svelte/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
// ...
"project": "./tsconfig.json",
"extraFileExtensions": [".svelte"] // This is a required setting in `@typescript-eslint/parser` v4.24.0.
},
"plugins": ["@typescript-eslint", "prettier"],
"rules": {
"no-console": 1,
"prettier/prettier": 2,
"comma-dangle": [2, "always-multiline"],
"no-alert": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
]
// 'svelte/rule-name': 'error'
},
"overrides": [
{
"files": ["*.svelte"],
"parser": "svelte-eslint-parser",
// Parse the `<script>` in `.svelte` as TypeScript by adding the following configuration.
"parserOptions": {
"parser": "@typescript-eslint/parser"
}
}
]
}

22
.gitignore vendored

@ -1,22 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.direnv
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/result
storybook-static
yarn-error.log
tmp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

9
.gitlint

@ -1,9 +0,0 @@
[general]
regex-style-search=true
# this ignore all body rules if there is a nostr link rather than just this line
# until https://github.com/jorisroovers/gitlint/issues/255#issuecomment-1040868243
# is implemented with [ignore-body-lines] this skips validation of other body line lengths
[ignore-by-body]
regex = ^(nostr:[^\s]*|https://[^\s]*)$
ignore = body-max-line-length

16
.prettierrc

@ -1,16 +0,0 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"printWidth": 80,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.eslintrc",
"options": {
"trailingComma": "none"
}
}
]
}

21
.storybook/main.ts

@ -1,21 +0,0 @@
import type { StorybookConfig } from "@storybook/sveltekit";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx|svelte)"],
addons: [
"@storybook/addon-svelte-csf",
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
"@storybook/addon-a11y"
],
framework: {
name: "@storybook/sveltekit",
options: {},
},
staticDirs: ['test-assets'],
docs: {
autodocs: "tag",
},
};
export default config;

16
.storybook/preview.ts

@ -1,16 +0,0 @@
import type { Preview } from "@storybook/svelte";
import '../src/app.css'
const preview: Preview = {
parameters: {
backgrounds: { default: 'dark' },
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
};
export default preview;

BIN
.storybook/test-assets/test-profile-image.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

31
.storybook/test-runner.ts

@ -1,31 +0,0 @@
import type { TestRunnerConfig } from "@storybook/test-runner";
import { toMatchImageSnapshot } from 'jest-image-snapshot';
const customSnapshotsDir = `${process.cwd()}/__snapshots__`;
const config: TestRunnerConfig = {
setup() {
expect.extend({ toMatchImageSnapshot });
},
async postVisit(page, context) {
// DOM Snapshot
const elementHandler = await page.$('#storybook-root');
if (elementHandler) {
const innerHTML = await elementHandler.innerHTML();
expect(innerHTML).toMatchSnapshot();
}
else throw "cannot find storybook DOM root to take DOM screenshot"
// Image Snapshop
const image = await page.screenshot();
expect(image).toMatchImageSnapshot({
customSnapshotsDir,
customSnapshotIdentifier: context.id,
comparisonMethod: 'ssim',
failureThresholdType: 'percent',
failureThreshold: 0.002,
});
},
};
export default config;

6
.vscode/extensions.json vendored

@ -1,6 +0,0 @@
{
"recommendations": [
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss"
]
}

13
.vscode/settings.json vendored

@ -1,13 +0,0 @@
{
"eslint.validate": ["javascript", "javascriptreact", "svelte"],
"editor.defaultFormatter": "esbenp.prettier-vscode",
"diffEditor.ignoreTrimWhitespace": false,
"editor.formatOnPaste": true,
"editor.formatOnSave": true,
"files.associations": {
"*.css": "tailwindcss"
},
"editor.codeActionsOnSave": {
"source.fixAll": "always"
}
}

1
.yarnrc.yml

@ -1 +0,0 @@
nodeLinker: node-modules

4
README.md

@ -1,5 +1,3 @@
# GitCitadel Online # GitCitadel Online
Repo containing the basic webpages for our [GitCitadel project](https://github.com/ShadowySupercode), including the Alexandria reader and publisher, the server monitoring page, and the possibility to submit PRs and issues to our software projects over [ngit events](https://gitcitadel.eu/ngit). Repo containing the basic webpages for our [GitCitadel company](https://gitcitadel.com).
Feel free to contact us over [Nostr](https://nosta.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaksz8mhwden5te0dehhxarj9ejkjmn4dej85ampdeaxjeewwdcxzcm9j3xeaa) or email us at [support@gitcitadel.eu](mailto:support@gitcitadel.eu).

82
flake.lock

@ -1,82 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1726560853,
"narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1726463316,
"narHash": "sha256-gI9kkaH0ZjakJOKrdjaI/VbaMEo9qBbSUl93DnU7f4c=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "99dc8785f6a0adac95f5e2ab05cc2e1bf666d172",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"gitignore": "gitignore",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

64
flake.nix

@ -1,64 +0,0 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
gitignore = {
url = "github:hercules-ci/gitignore.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{ nixpkgs
, gitignore
, flake-utils
, ...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
packageJSON = pkgs.lib.importJSON ./package.json;
gitignoreSource = gitignore.lib.gitignoreSource;
in
{
packages = rec {
site-src = pkgs.mkYarnPackage rec {
name = "${packageJSON.name}-site-${version}";
version = packageJSON.version;
src = gitignoreSource ./.;
packageJson = "${src}/package.json";
yarnLock = "${src}/yarn.lock";
buildPhase = ''
yarn --offline build
'';
distPhase = "true";
};
default = pkgs.writeShellApplication {
name = packageJSON.name;
runtimeInputs = [ site-src pkgs.nodejs ];
text = ''
node ${site-src}/libexec/${packageJSON.name}/deps/${packageJSON.name}/build
'';
};
};
devShell = pkgs.mkShell {
buildInputs = [
pkgs.gitlint
pkgs.nodejs
pkgs.corepack
];
shellHook = ''
yarn set version 4.5.0
# auto-install git hooks
dot_git="$(git rev-parse --git-common-dir)"
if [[ ! -d "$dot_git/hooks" ]]; then mkdir "$dot_git/hooks"; fi
for hook in git_hooks/* ; do ln -sf "$(pwd)/$hook" "$dot_git/hooks/" ; done
'';
};
}
);
}

35
git_hooks/commit-msg

@ -1,35 +0,0 @@
#!/bin/sh
### gitlint commit-msg hook start ###
# Determine whether we have a tty available by trying to access it.
# This allows us to deal with UI based gitclient's like Atlassian SourceTree.
# NOTE: "exec < /dev/tty" sets stdin to the keyboard
stdin_available=1
(exec < /dev/tty) 2> /dev/null || stdin_available=0
if [ $stdin_available -eq 1 ]; then
# Now that we know we have a functional tty, set stdin to it so we can ask the user questions :-)
exec < /dev/tty
# On Windows, we need to explicitly set our stdout to the tty to make terminal editing work (e.g. vim)
# See SO for windows detection in bash (slight modified to work on plain shell (not bash)):
# https://stackoverflow.com/questions/394230/how-to-detect-the-os-from-a-bash-script
if [ "$OSTYPE" = "cygwin" ] || [ "$OSTYPE" = "msys" ] || [ "$OSTYPE" = "win32" ]; then
exec > /dev/tty
fi
fi
gitlint --staged --msg-filename "$1" run-hook
exit_code=$?
# If we fail to find the gitlint binary (command not found), let's retry by executing as a python module.
# This is the case for Atlassian SourceTree, where $PATH deviates from the user's shell $PATH.
if [ $exit_code -eq 127 ]; then
echo "Fallback to python module execution"
python -m gitlint.cli --staged --msg-filename "$1" run-hook
exit_code=$?
fi
exit $exit_code
### gitlint commit-msg hook end ###

11
maintainers.yaml

@ -1,11 +0,0 @@
identifier: gitcitadel.eu
maintainers:
- npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z
- npub1wqfzz2p880wq0tumuae9lfwyhs8uz35xd0kr34zrvrwyh3kvrzuskcqsyn
- npub1qdjn8j4gwgmkj3k5un775nq6q3q7mguv5tvajstmkdsqdja2havq03fqm7
- npub1ecdlntvjzexlyfale2egzvvncc8tgqsaxkl5hw7xlgjv2cxs705s9qs735
- npub1m3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srqhqa5sf
relays:
- wss://thecitadel.nostr1.com
- wss://theforest.nostr1.com

7
netlify.toml

@ -1,7 +0,0 @@
[build]
command = "yarn run build"
publish = "build"
[[headers]]
for = "/.well-known/nostr.json"
[headers.values]
Access-Control-Allow-Origin = "*"

9685
package-lock.json generated

File diff suppressed because it is too large Load Diff

65
package.json

@ -1,65 +0,0 @@
{
"name": "gitcitadel.eu",
"version": "0.0.1",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint 'src/**/*.{ts,svelte}' --fix",
"format": "prettier 'src/**/*.{ts,svelte}' --write",
"storybook": "storybook dev -p 6006",
"test": "vitest"
},
"license": "MIT",
"devDependencies": {
"@storybook/addon-a11y": "^8.3.2",
"@storybook/addon-essentials": "^8.3.2",
"@storybook/addon-interactions": "^8.3.2",
"@storybook/addon-links": "^8.3.2",
"@storybook/addon-svelte-csf": "^4.1.7",
"@storybook/blocks": "^8.3.2",
"@storybook/svelte": "^8.3.2",
"@storybook/sveltekit": "^8.3.2",
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.5.28",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@tailwindcss/typography": "^0.5.15",
"@types/ramda": "^0.30.2",
"@typescript-eslint/eslint-plugin": "^8.6.0",
"@typescript-eslint/parser": "^8.6.0",
"autoprefixer": "^10.4.20",
"eslint": "^8.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-svelte": "^2.44.0",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-tailwindcss": "^0.6.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"storybook": "^8.3.2",
"svelte": "^4.2.19",
"svelte-check": "^4.0.2",
"tailwindcss": "^3.4.12",
"tslib": "^2.7.0",
"typescript": "^5.6.2",
"vite": "^5.4.6",
"vitest": "^2.1.1"
},
"type": "module",
"dependencies": {
"@nostr-dev-kit/ndk": "^2.10.0",
"@nostr-dev-kit/ndk-svelte": "^2.2.18",
"daisyui": "^4.12.10",
"dayjs": "^1.11.13",
"highlight.js": "^11.10.0",
"nostr-tools": "^2.7.2",
"parse-diff": "^0.11.1",
"ramda": "^0.30.1",
"svelte-markdown": "^0.4.1"
},
"packageManager": "yarn@4.5.0"
}

6
postcss.config.js

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

21
src/404.html

@ -1,21 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Favicon -->
<link
rel="icon"
href="/icons/icon-32x32.png?v=1"
sizes="32x32"
type="image/png"
/>
<!-- SvelteKit Head -->
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

3
src/app.css

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

21
src/app.html

@ -1,21 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Favicon -->
<link
rel="icon"
href="/icons/icon-32x32.png?v=1"
sizes="32x32"
type="image/png"
/>
<!-- SvelteKit Head -->
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

19
src/lib/components/AlertError.svelte

@ -1,19 +0,0 @@
<div role="alert" class="m-auto max-w-xl">
<div role="alert" class="alert alert-error m-auto mt-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg
>
<div>
<slot />
</div>
</div>
</div>

18
src/lib/components/AlertWarning.svelte

@ -1,18 +0,0 @@
<div role="alert" class="m-auto max-w-xl">
<div role="alert" class="alert alert-warning m-auto mt-6 bg-yellow-300">
<!-- https://icon-sets.iconify.design/ph/warning-fill/ -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
viewBox="0 0 256 256"
>
<path
fill="currentColor"
d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72M120 104a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0Zm8 88a12 12 0 1 1 12-12a12 12 0 0 1-12 12"
/></svg
>
<div>
<slot />
</div>
</div>
</div>

13
src/lib/components/Container.svelte

@ -1,13 +0,0 @@
<script lang="ts">
export let no_wrap: boolean = false
</script>
<div class="mx-auto lg:container">
{#if no_wrap}
<slot />
{:else}
<div class="px-3">
<slot />
</div>
{/if}
</div>

77
src/lib/components/CopyField.svelte

@ -1,77 +0,0 @@
<script lang="ts">
import { icons_misc } from './icons'
export let label: string = ''
export let content: string = ''
export let border_color = 'primary'
export let no_border = false
export let icon: undefined | string[] = undefined
export let truncate: undefined | [number, number] = undefined
const truncatedContent = () => {
if (truncate && content.length > truncate[0] + truncate[1] + 3) {
return `${content.substring(0, truncate[0])}...${content.substring(content.length - 1 - truncate[1])}`
}
return content
}
let copied = false
</script>
<!-- eslint-disable-next-line svelte/valid-compile -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="group cursor-pointer"
class:mt-3={!no_border}
on:click={async () => {
try {
await navigator.clipboard.writeText(content)
copied = true
setTimeout(() => {
copied = false
}, 2000)
} catch {}
}}
>
{#if label.length > 0}
{label}
{#if copied}<span class="text-sm text-success opacity-50">
(copied to clipboard)</span
>{/if}
{/if}
<div
class="items flex w-full items-center rounded-lg border border-{border_color} opacity-50"
class:mt-1={no_border && label.length === 0}
class:border={!no_border}
class:p-3={!no_border}
class:text-success={copied}
>
{#if icon}<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="mr-1 mt-1 inline h-4 w-4 flex-none fill-base-content opacity-50"
class:fill-success={copied}
>
{#each icon as d}
<path {d} />
{/each}
</svg>{/if}
<div
class="truncate text-sm"
class:flex-auto={!no_border}
class:flex-none={no_border}
>
{truncatedContent()}
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="ml-1 inline h-4 w-4 flex-none fill-base-content opacity-50 group-hover:opacity-100"
class:opacity-100={copied}
class:fill-success={copied}
>
{#each icons_misc.copy as d}
<path {d} />
{/each}
</svg>
</div>
</div>

33
src/lib/components/InstallNgit.svelte

@ -1,33 +0,0 @@
<script lang="ts">
export let size: 'sm' | 'md' = 'md'
const version = 'v1.5.2'
</script>
<div class="prose" class:text-sm={size === 'sm'}>
<p>
download binaries and add them to a directory from which they can be run
globally:
</p>
<p>
<a
href="https://github.com/DanConwayDev/ngit-cli/releases/download/{version}/ngit-{version}-x86_64-unknown-linux-gnu.tar.gz"
class="btn btn-neutral"
class:btn-sm={size === 'sm'}>Linux</a
>
<a
href="https://github.com/DanConwayDev/ngit-cli/releases/download/{version}/ngit-{version}-aarch64-apple-darwin.tar.gz"
class="btn btn-neutral"
class:btn-sm={size === 'sm'}>Mac</a
>
<a
href="https://github.com/DanConwayDev/ngit-cli/releases/download/{version}/ngit-{version}-x86_64-pc-windows-msvc.zip"
class="btn btn-neutral"
class:btn-sm={size === 'sm'}>Windows</a
>
{version}
</p>
<p>
alternatively, if you have cargo installed run<code>cargo install ngit</code
>
</p>
</div>

32
src/lib/components/Navbar.stories.svelte

@ -1,32 +0,0 @@
<script lang="ts" context="module">
import type { Meta } from '@storybook/svelte'
import Navbar from '$lib/components/Navbar.svelte'
import { Story, Template } from '@storybook/addon-svelte-csf'
import { UserVectors } from '$lib/components/users/vectors'
export const meta: Meta<Navbar> = {
title: 'Navbar',
component: Navbar,
tags: ['autodocs'],
}
</script>
<Template let:args>
<Navbar {...args} />
</Template>
<Story name="Default" />
<Story name="NIP07 Loading" args={{ nip07_plugin: undefined }} />
<Story name="NoNIP07" args={{ nip07_plugin: false }} />
<Story
name="NIP07Exists"
args={{ nip07_plugin: true, logged_in_user: undefined }}
/>
<Story
name="Logged in"
args={{ nip07_plugin: true, logged_in_user: { ...UserVectors.default } }}
/>

68
src/lib/components/Navbar.svelte

@ -1,68 +0,0 @@
<script lang="ts">
import { logout } from '$lib/stores/users'
import Container from './Container.svelte'
import UserHeader from './users/UserHeader.svelte'
import type { User } from './users/type'
export let logged_in_user: User | undefined = undefined
export let nip07_plugin: boolean | undefined = undefined
export let login_function = () => {}
export let singup_function = () => {}
</script>
<div class="bg-base-400">
<Container>
<div class="navbar">
<div class="navbar-start">
<a href="/repos" class="btn btn-ghost btn-sm normal-case">Repos</a>
</div>
<div class="navbar-center">
<a class="align-middle text-lg" href="/">
GitCitadel
</a>
</div>
<div class="navbar-end gap-4">
{#if logged_in_user}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="m-1">
<UserHeader user={logged_in_user} link_to_profile={false} />
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<ul
tabindex="0"
class="menu dropdown-content z-[1] -mr-4 rounded-box bg-base-400 p-2 shadow"
>
<li><UserHeader user={logged_in_user} /></li>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-missing-attribute -->
<li>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<a
on:click={() => {
logout()
}}>Logout</a
>
</li>
</ul>
</div>
{:else if nip07_plugin === undefined}
<div class="skeleton h-8 w-20"></div>
{:else if nip07_plugin}
<button
on:click={() => {
login_function()
}}
class="btn btn-ghost btn-sm normal-case">Login</button
>
{:else}
<button
on:click={() => {
singup_function()
}}
class="btn btn-ghost btn-sm normal-case">Sign up</button
>
{/if}
</div>
</div>
</Container>
</div>

28
src/lib/components/RepoSummaryCard.stories.svelte

@ -1,28 +0,0 @@
<script lang="ts" context="module">
import type { Meta } from '@storybook/svelte'
import RepoSummaryCard from './RepoSummaryCard.svelte'
import { Story, Template } from '@storybook/addon-svelte-csf'
import { RepoSummaryCardArgsVectors as vectors } from './repo/vectors'
export const meta: Meta<RepoSummaryCard> = {
title: 'Repo/Summary/Card',
component: RepoSummaryCard,
tags: ['autodocs'],
}
</script>
<Template let:args>
<RepoSummaryCard {...args} />
</Template>
<Story name="Short Details" args={vectors.Short} />
<Story name="Long Details" args={vectors.Long} />
<Story name="Long and No Spaces" args={vectors.LongNoSpaces} />
<Story name="No Details" args={{}} />
<Story name="loading" args={{ loading: true }} />
<Story name="Multiple Maintainers" args={vectors.MulipleMaintainers} />

81
src/lib/components/RepoSummaryCard.svelte

@ -1,81 +0,0 @@
<script lang="ts">
import { summary_defaults } from './repo/type'
import UserHeader from './users/UserHeader.svelte'
import type { User } from './users/type'
export let { name, description, identifier, maintainers, naddr, loading } =
summary_defaults
let short_name: string
$: {
if (name && name.length > 45) short_name = name.slice(0, 45) + '...'
else if (name && name.length >= 0) short_name = name
else if (identifier && identifier.length > 45)
short_name = identifier.slice(0, 45) + '...'
else if (identifier && identifier.length >= 0) short_name = identifier
else short_name = 'Untitled'
}
let additional_maintainers: User[] = []
let author: User | undefined = undefined
$: short_descrption =
description.length > 50 ? description.slice(0, 45) + '...' : description
$: {
additional_maintainers = (([_, ...xs]) => xs)(maintainers)
author = maintainers[0]
}
</script>
<div
class="rounded-lg bg-base-200 p-4"
style={`min-height: ${maintainers.length * 1.325 + 2}rem;`}
>
{#if loading}
<div class="skeleton mb-2 h-5 w-40"></div>
<div class="w-100 skeleton h-4"></div>
{:else}
<a class="link-primary break-words" href="/r/{naddr}">{short_name}</a>
{#if short_descrption.length > 0}
<p class="text-muted break-words pb-1 text-sm">
{short_descrption}
</p>
{/if}
<div class="break-words text-right text-xs text-slate-400">
{#if author}
<div
class="inline"
class:p-1={additional_maintainers.length > 0}
class:rounded-md={additional_maintainers.length > 0}
class:bg-base-400={additional_maintainers.length > 0}
class:text-white={additional_maintainers.length > 0}
>
<UserHeader user={author} inline={true} size="xs" />
</div>
{#if additional_maintainers.length > 0}
<span>with</span>
<ul class="reposummarycard inline">
{#each additional_maintainers as user}
<li class="inline">
<UserHeader {user} inline={true} size="xs" />
</li>
{/each}
</ul>
{/if}
{/if}
</div>
{/if}
</div>
<style lang="postcss">
.reposummarycard li::before {
content: ', ';
}
.reposummarycard li:last-child::before {
content: ' and ';
}
.reposummarycard li:first-child::before {
content: '';
}
</style>

69
src/lib/components/ReposSummaryList.stories.svelte

@ -1,69 +0,0 @@
<script lang="ts" context="module">
import type { Meta } from '@storybook/svelte'
import ReposSummaryList from './ReposSummaryList.svelte'
import { Story, Template } from '@storybook/addon-svelte-csf'
import { RepoSummaryCardArgsVectors as vectors } from './repo/vectors'
export const meta: Meta<ReposSummaryList> = {
title: 'Repo/Summary/List',
component: ReposSummaryList,
tags: ['autodocs'],
}
</script>
<Template let:args>
<ReposSummaryList {...args} />
</Template>
<Story
name="Default"
args={{
title: 'Featured Repositories',
repos: [
vectors.Short,
vectors.Long,
vectors.LongNoSpaces,
vectors.MulipleMaintainers,
],
}}
/>
<Story
name="No Title"
args={{
repos: [vectors.Short, vectors.Long],
}}
/>
<Story
name="Empty"
args={{
title: 'Latest',
repos: [],
}}
/>
<Story
name="Loading"
args={{
title: 'Latest',
repos: [],
loading: true,
}}
/>
<Story
name="Partially Loaded"
args={{
title: 'Latest',
repos: [vectors.Short, vectors.Long],
loading: true,
}}
/>
<Story
name="Multiple Maintainers"
args={{
title: 'Multiple Maintainers',
repos: [vectors.MulipleMaintainers, vectors.Long],
}}
/>

119
src/lib/components/ReposSummaryList.svelte

@ -1,119 +0,0 @@
<script lang="ts">
import type { RepoSummary } from './repo/type'
import RepoSummaryCard from '$lib/components/RepoSummaryCard.svelte'
export let title: string = ''
export let repos: RepoSummary[] = []
export let loading: boolean = false
export let group_by: 'name' | 'identifier' | undefined = undefined
let grouped_repos: RepoSummary[][] = []
let selected_group: string | undefined = undefined
$: {
grouped_repos = []
repos.forEach((collection) => {
if (!group_by) {
grouped_repos.push([collection])
return
}
const added_to_group = grouped_repos.some((group, i) => {
if (group.some((c) => c[group_by] === collection[group_by])) {
grouped_repos[i].push(collection)
return true
}
return false
})
if (!added_to_group) grouped_repos.push([collection])
})
}
</script>
<div class="min-width">
{#if title.length > 0}
<div class="prose mb-3">
<h3>{title}</h3>
</div>
{/if}
{#if repos.length == 0 && !loading}
<p class="prose">None</p>
{:else}
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each grouped_repos as group}
{#if group.length === 0}
<RepoSummaryCard loading={true} />
{:else if group.length === 1}
{#each group as { name, description, identifier, maintainers, naddr }}
<RepoSummaryCard
{name}
{description}
{identifier}
{maintainers}
{naddr}
/>
{/each}
{:else if group_by}
<div class="stack">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex min-h-28 cursor-pointer items-center rounded-lg border border-base-400 bg-base-200 p-4 hover:bg-base-300"
on:click={() => {
selected_group = group[0][group_by]
}}
>
<div class="m-auto text-center">
<div class="">{group[0][group_by]}</div>
<div class=" text-sm opacity-50">{group.length} Items</div>
</div>
</div>
{#each group as { name, description, identifier, maintainers, naddr }}
<div class="rounded-lg border border-base-400">
<RepoSummaryCard
{name}
{description}
{identifier}
{maintainers}
{naddr}
/>
</div>
{/each}
</div>
{/if}
{/each}
{#if loading}
<RepoSummaryCard loading={true} />
{#if repos.length == 0}
<RepoSummaryCard loading={true} />
<RepoSummaryCard loading={true} />
{/if}
{/if}
</div>
{/if}
</div>
{#if selected_group}
<div class="modal modal-open">
<div class="modal-box max-w-full text-wrap text-xs">
<div class="prose max-w-full">
<h3 class="mb-3 max-w-full text-center">
{group_by}: "{selected_group}"
</h3>
</div>
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each repos.filter((summary) => group_by && summary[group_by] === selected_group) as { name, description, identifier, maintainers, naddr }}
<RepoSummaryCard
{name}
{description}
{identifier}
{maintainers}
{naddr}
/>
{/each}
</div>
<div class="modal-action">
<button class="btn btn-sm" on:click={() => (selected_group = undefined)}
>Close</button
>
</div>
</div>
</div>
{/if}

49
src/lib/components/events/Compose.svelte

@ -1,49 +0,0 @@
<script lang="ts">
import { logged_in_user, login } from '$lib/stores/users'
import UserHeader from '../users/UserHeader.svelte'
import { defaults as user_defaults } from '../users/type'
export let sendReply: (content: string) => void = () => {}
export let placeholder = 'reply...'
export let submitting = false
export let logged_in = false
let submit = () => {
if (!logged_in) login()
sendReply(content)
}
let content = ''
</script>
<div class="flex pt-5">
<div class="mt-0 flex-none px-3">
<UserHeader
avatar_only={true}
user={$logged_in_user || { ...user_defaults, loading: false }}
/>
</div>
<div class="flex-grow pt-2">
{#if !submitting}
<textarea
bind:value={content}
class="textarea textarea-primary w-full"
{placeholder}
></textarea>
{/if}
<div class="flex">
<div class="flex-auto"></div>
<button
on:click={submit}
disabled={submitting || content.length === 0}
class="align-right btn btn-primary btn-sm mt-2 align-bottom"
>
{#if submitting}
Sending
{:else if !logged_in}
Login before Sending
{:else}
Send
{/if}
</button>
</div>
</div>
</div>

157
src/lib/components/events/EventWrapper.svelte

@ -1,157 +0,0 @@
<script lang="ts">
import dayjs from 'dayjs'
import UserHeader from '../users/UserHeader.svelte'
import type { User } from '../users/type'
import { defaults as user_defaults } from '../users/type'
import ComposeReply from '$lib/wrappers/ComposeReply.svelte'
import { logged_in_user } from '$lib/stores/users'
import type { NDKEvent } from '@nostr-dev-kit/ndk'
import CopyField from '../CopyField.svelte'
import { ndkEventToNeventOrNaddr } from '../repo/utils'
export let type: 'proposal' | 'issue' = 'proposal'
export let author: User = { ...user_defaults }
export let created_at: number | undefined
export let event: NDKEvent
let show_compose = false
let show_raw_json_modal = false
let show_share_modal = false
let created_at_ago = ''
$: created_at_ago = created_at ? dayjs(created_at * 1000).fromNow() : ''
const replySent = () => {
show_compose = false
}
</script>
<div class="max-w-4xl border-b border-base-300 p-3 pl-3">
<div class="flex">
<div class="flex-auto">
<UserHeader user={author} in_event_header={true} />
</div>
<span class="m-auto text-xs">{created_at_ago}</span>
<div class="m-auto ml-2">
{#if event}
<div class="tooltip align-middle" data-tip="event json">
<button
on:click={() => {
show_raw_json_modal = true
}}
class="btn btn-xs text-neutral-content"
>
<!-- https://icon-sets.iconify.design/ph/brackets-curly-bold -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 256 256"
><path
fill="currentColor"
d="M54.8 119.49a35.06 35.06 0 0 1-5.75 8.51a35.06 35.06 0 0 1 5.75 8.51C60 147.24 60 159.83 60 172c0 25.94 1.84 32 20 32a12 12 0 0 1 0 24c-19.14 0-32.2-6.9-38.8-20.51C36 196.76 36 184.17 36 172c0-25.94-1.84-32-20-32a12 12 0 0 1 0-24c18.16 0 20-6.06 20-32c0-12.17 0-24.76 5.2-35.49C47.8 34.9 60.86 28 80 28a12 12 0 0 1 0 24c-18.16 0-20 6.06-20 32c0 12.17 0 24.76-5.2 35.49M240 116c-18.16 0-20-6.06-20-32c0-12.17 0-24.76-5.2-35.49C208.2 34.9 195.14 28 176 28a12 12 0 0 0 0 24c18.16 0 20 6.06 20 32c0 12.17 0 24.76 5.2 35.49A35.06 35.06 0 0 0 207 128a35.06 35.06 0 0 0-5.75 8.51C196 147.24 196 159.83 196 172c0 25.94-1.84 32-20 32a12 12 0 0 0 0 24c19.14 0 32.2-6.9 38.8-20.51c5.2-10.73 5.2-23.32 5.2-35.49c0-25.94 1.84-32 20-32a12 12 0 0 0 0-24"
/></svg
></button
>
</div>
{#if show_raw_json_modal}
<div class="modal" class:modal-open={show_raw_json_modal}>
<div class="modal-box max-w-full text-wrap text-xs">
<code class="w-full">{JSON.stringify(event.rawEvent())}</code>
<div class="modal-action">
<button
class="btn btn-sm"
on:click={() => (show_raw_json_modal = false)}>Close</button
>
</div>
</div>
</div>
{/if}
<div class="tooltip align-middle" data-tip="share">
<button
on:click={() => {
show_share_modal = true
}}
class="btn btn-xs text-neutral-content"
>
<!-- https://icon-sets.iconify.design/ph/share-network-bold/ -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 256 256"
><path
fill="currentColor"
d="M176 156a43.78 43.78 0 0 0-29.09 11l-40.81-26.2a44.07 44.07 0 0 0 0-25.6L146.91 89a43.83 43.83 0 1 0-13-20.17L93.09 95a44 44 0 1 0 0 65.94l40.81 26.26A44 44 0 1 0 176 156m0-120a20 20 0 1 1-20 20a20 20 0 0 1 20-20M64 148a20 20 0 1 1 20-20a20 20 0 0 1-20 20m112 72a20 20 0 1 1 20-20a20 20 0 0 1-20 20"
/></svg
></button
>
</div>
{#if show_share_modal}
<div class="modal" class:modal-open={show_share_modal}>
<div class="modal-box max-w-lg text-wrap">
<div class="prose"><h3>Share</h3></div>
<CopyField
label="nostr address"
content={`nostr:${ndkEventToNeventOrNaddr(event)}`}
/>
<CopyField
label="njump"
content={`https://njump.me/${ndkEventToNeventOrNaddr(event)}`}
border_color="secondary"
/>
<CopyField
label="raw event id"
content={event.id}
border_color="neutral-content"
/>
<div class="modal-action">
<button
class="btn btn-sm"
on:click={() => (show_share_modal = false)}>Close</button
>
</div>
</div>
</div>
{/if}
{/if}
{#if !show_compose && $logged_in_user}
<div class="tooltip align-middle" data-tip="reply">
<button
on:click={() => {
show_compose = true
}}
class="btn btn-xs"
><svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
><path
fill="currentColor"
d="M6.78 1.97a.75.75 0 0 1 0 1.06L3.81 6h6.44A4.75 4.75 0 0 1 15 10.75v2.5a.75.75 0 0 1-1.5 0v-2.5a3.25 3.25 0 0 0-3.25-3.25H3.81l2.97 2.97a.749.749 0 0 1-.326 1.275a.749.749 0 0 1-.734-.215L1.47 7.28a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0"
/></svg
></button
>
</div>
{/if}
</div>
</div>
<div class="ml-11">
<slot />
{#if show_compose}
<div class="">
<div class="flex">
<div class="flex-auto"></div>
<button
on:click={() => {
show_compose = false
}}
class="btn btn-circle btn-ghost btn-sm right-2 top-2">✕</button
>
</div>
<div class="">
<ComposeReply {type} {event} sentFunction={() => replySent()} />
</div>
</div>
{/if}
</div>
</div>

23
src/lib/components/events/EventWrapperLite.svelte

@ -1,23 +0,0 @@
<script lang="ts">
import dayjs from 'dayjs'
import UserHeader from '../users/UserHeader.svelte'
import type { User } from '../users/type'
import { defaults as user_defaults } from '../users/type'
export let author: User = { ...user_defaults }
export let created_at: number | undefined
let created_at_ago = ''
$: created_at_ago = created_at ? dayjs(created_at * 1000).fromNow() : ''
</script>
<div class="max-w-4xl border-b border-base-300 p-3 pl-3">
<div class="flex">
<div class="flex-auto">
<div class="inline text-neutral-400"><slot /></div>
<div class="badge bg-base-400 text-neutral-400">
<UserHeader user={author} inline />
</div>
</div>
<span class="m-auto flex-none py-1 text-xs">{created_at_ago}</span>
</div>
</div>

48
src/lib/components/events/ThreadWrapper.svelte

@ -1,48 +0,0 @@
<script lang="ts">
import { icons_misc } from '../icons'
let show_replies = true
export let num_replies = 0
const toggle_replies = () => {
show_replies = !show_replies
}
</script>
<div class="border-l border-blue-500 pl-1">
{#if num_replies > 0}
{#if show_replies}
<div class="opacity-60 hover:opacity-90" class:relative={show_replies}>
<button
on:click={() => {
toggle_replies()
}}
class="-ml-1 -mt-8"
class:absolute={show_replies}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="h-7 w-7 flex-none fill-blue-500 pt-1"
>
{#each show_replies ? icons_misc.chevron_up : icons_misc.chevron_down as p}
<path d={p} />
{/each}
</svg>
</button>
</div>
{:else}
<button
on:click={() => {
toggle_replies()
}}
class="w-full cursor-pointer bg-base-300 p-3 text-left hover:bg-base-400"
>
show {num_replies} hidden replies
</button>
{/if}
{/if}
<div class:hidden={!show_replies}>
<slot />
</div>
</div>

22
src/lib/components/events/content/IssuePreview.svelte

@ -1,22 +0,0 @@
<script lang="ts">
import { extractAReference } from '$lib/components/repo/utils'
import { extractRepoAFromProposalEvent } from '$lib/stores/Proposals'
import type { NDKEvent } from '@nostr-dev-kit/ndk'
import { nip19 } from 'nostr-tools'
import { extractIssueTitle } from './utils'
export let event: NDKEvent
let nevent = nip19.neventEncode({
id: event.id,
relays: event.relay ? [event.relay.url] : undefined,
})
let a_string = extractRepoAFromProposalEvent(event)
let pointer = a_string ? extractAReference(a_string) : undefined
let naddr = pointer ? nip19.naddrEncode(pointer) : undefined
</script>
<span>
Git Issue for <a class="opacity-50" href={`/e/${naddr}`}
>{pointer?.identifier}</a
>: <a href={`/e/${nevent}`}>{extractIssueTitle(event)}</a> by
</span>

51
src/lib/components/events/content/ParsedContent.svelte

@ -1,51 +0,0 @@
<script lang="ts">
import type { NDKTag } from '@nostr-dev-kit/ndk'
import {
isImage,
isParsedLink,
isParsedNaddr,
isParsedNevent,
isParsedNewLine,
isParsedNote,
isParsedNprofile,
isParsedNpub,
isParsedText,
parseContent,
type ParsedPart,
} from './utils'
import UserHeader from '$lib/components/users/UserHeader.svelte'
import EventPreview from '$lib/wrappers/EventPreview.svelte'
export let content: string = ''
export let tags: NDKTag[] = []
let fullContent: ParsedPart[] = []
$: fullContent = parseContent(content, tags)
</script>
<div class="prose max-w-prose break-words">
{#each fullContent as part}
{#if isParsedNewLine(part)}
{#if part.value.length > 1}
<br />
{/if}
<br />
{:else if isParsedLink(part)}
{#if isImage(part.url)}
<img src={part.url} alt={part.imeta?.alt} />
{:else}
<a href={part.url} target="_blank">
{part.url.replace(/https?:\/\/(www\.)?/, '')}
</a>
{/if}
{:else if isParsedNpub(part) || isParsedNprofile(part)}
<div class="badge badge-neutral">
<UserHeader user={part.hex} inline={true} size="sm" />
</div>
{:else if isParsedNevent(part) || isParsedNote(part) || isParsedNaddr(part)}
<EventPreview pointer={part.data} />
{:else if isParsedText(part)}
{part.value}
{/if}
{/each}
</div>

252
src/lib/components/events/content/Patch.svelte

@ -1,252 +0,0 @@
<script lang="ts">
import type { NDKEvent, NDKTag } from '@nostr-dev-kit/ndk'
import parseDiff from 'parse-diff'
import hljs from 'highlight.js/lib/common'
import 'highlight.js/styles/agate.min.css'
import type { Change, AddChange, DeleteChange } from 'parse-diff'
import ParsedContent from './ParsedContent.svelte'
import { extractPatchMessage, extractTagContent } from './utils'
import { nip19 } from 'nostr-tools'
import { extractRepoAFromProposalEvent } from '$lib/stores/Proposals'
import { extractAReference } from '$lib/components/repo/utils'
export let event: NDKEvent
export let preview = false
let content: string = event ? event.content : ''
let tags: NDKTag[] = event ? event.tags : []
let commit_id_shorthand =
extractTagContent('commit', tags)?.substring(0, 8) || '[commit_id unknown]'
let commit_message =
extractTagContent('description', tags) ||
extractPatchMessage(content) ||
'[untitled]'
let commit_title = commit_message.split('\n')[0]
let files = parseDiff(content)
let expand_files = files.map((file) => file.deletions + file.additions < 20)
if (
files.reduce((acc, file) => acc + file.deletions + file.additions, 0) < 60
) {
expand_files = expand_files.map((_) => true)
}
let expand_full_files = files.map((_) => false)
let isAddChange = (change: Change): change is AddChange =>
change.type == 'add'
let isDeleteChange = (change: Change): change is DeleteChange =>
change.type == 'del'
let extractChangeLine = (change: Change, stage?: 'before' | 'after') => {
if (isAddChange(change) || isDeleteChange(change)) {
return change.ln
} else {
if (stage === 'before') return change.ln1
if (stage === 'after') return change.ln2
if (change.ln2 === change.ln2) return change.ln1
return '#'
}
}
let getFortmattedDiffHtml = (
change: Change,
language: string
): string | undefined => {
try {
return hljs.highlight(
change.type == 'normal' ? change.content : change.content.substring(1),
{ language }
).value
} catch {
return undefined
}
}
let nevent = nip19.neventEncode({
id: event.id,
relays: event.relay ? [event.relay.url] : undefined,
})
let a_string = extractRepoAFromProposalEvent(event)
let pointer = a_string ? extractAReference(a_string) : undefined
let naddr = pointer ? nip19.naddrEncode(pointer) : undefined
</script>
{#if preview}
<span>
Git Patch for <a class="opacity-50" href={`/e/${naddr}`}
>{pointer?.identifier}</a
>: <a href={`/e/${nevent}`}>{commit_title}</a> by
</span>
{:else}
<div class="">
<div class="flex rounded-t bg-base-300 p-1">
<article class="ml-2 flex-grow font-mono text-sm">
<ParsedContent content={commit_message} />
</article>
<div class="flex-none p-1 align-middle text-xs text-neutral">commit</div>
</div>
<div class="flex p-3">
<div class="flex-grow text-xs">Changes:</div>
<div class="flex-none text-right font-mono text-xs">
{commit_id_shorthand}
</div>
</div>
{#each files as file, index}
<div
class="my-2 border border-base-300 {expand_full_files[index]
? 'absolute left-0 z-10 w-screen bg-base-300 px-5'
: ''}"
>
<div class="flex w-full bg-base-200">
<button
class="flex shrink flex-grow p-3 text-sm"
on:click={() => {
if (expand_full_files[index]) {
expand_full_files[index] = false
expand_files[index] = false
} else if (expand_files[index]) {
expand_full_files[index] = true
} else {
expand_files[index] = true
}
}}
><div class="shrink text-wrap text-left">
<span class="pr-3">{file.to || file.from}</span>
<span
class="text-middle flex-none align-middle font-mono text-xs opacity-70"
>{#if file.new}<span>created&nbsp;file</span
>&nbsp;{/if}{#if file.deleted}<span>deleted&nbsp;file</span
>&nbsp;{/if}{#if !file.deleted}<span class="text-success"
>+{file.additions}</span
>{/if}&nbsp;{#if !file.new}<span class="text-error"
>-{file.deletions}</span
>{/if}
</span>
</div>
<div class="flex-grow"></div>
</button>
<button
class="flex-none p-3 text-right text-xs opacity-40"
on:click={() => {
expand_files[index] = !expand_files[index]
expand_full_files[index] = false
}}
>
{expand_files[index] ? 'colapse' : 'expand'}
</button>
<button
class="flex-none p-3 text-right text-xs opacity-40"
on:click={() => {
expand_full_files[index] = !expand_full_files[index]
if (expand_full_files[index]) expand_files[index] = true
}}
>
full
</button>
</div>
{#if expand_files[index]}
<div class="border-t-1 flex border-base-300 font-mono text-xs">
<div class="flex-full select-none text-right">
{#each file.chunks as chunk, index}
{#if index !== 0}
<div class="flex w-full bg-base-200">
<div
class="w-8 flex-none whitespace-pre pb-2 pr-2 pt-1 opacity-50"
>
...
</div>
</div>
{/if}
{#each chunk.changes as change, i}
<div class="flex w-full bg-base-100">
<div
class="w-8 flex-none whitespace-pre {change.type == 'add'
? 'bg-success/50'
: change.type == 'del'
? 'bg-error/50'
: 'bg-slate-500/20'} pr-2 opacity-50"
class:pt-3={index === 0 && i === 0}
class:pb-3={index === file.chunks.length - 1 &&
i === chunk.changes.length - 1}
>
{isAddChange(change) &&
i !== 0 &&
isDeleteChange(chunk.changes[i - 1])
? ' '
: extractChangeLine(change)}
</div>
</div>
{/each}
{/each}
</div>
<div class="flex-auto overflow-x-auto">
<div class="w-fit">
{#each file.chunks as chunk, index}
{#if index !== 0}
<div class="flex h-7 w-full bg-base-200"></div>
{/if}
{#each chunk.changes as change, i}
<div class="flex w-full bg-base-100">
<div
class="w-full flex-grow whitespace-pre {change.type ==
'add'
? 'bg-success/20'
: change.type == 'del'
? 'bg-error/20'
: ''}"
class:pt-3={index === 0 && i === 0}
class:pb-3={index === file.chunks.length - 1 &&
i === chunk.changes.length - 1}
>
{#if getFortmattedDiffHtml(change, (file.to || file.from)
?.split('.')
.pop() || '')}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html getFortmattedDiffHtml(
change,
(file.to || file.from)?.split('.').pop() || ''
)}
{:else}
{change.type == 'normal'
? change.content
: change.content.substring(1)}
{/if}
{#if (change.type == 'normal' ? change.content : change.content.substring(1)).length === 0}
<!-- force empty line to have height -->
<span></span>
{/if}
</div>
</div>
{/each}
{/each}
</div>
</div>
</div>
{/if}
</div>
<!-- vertical padding for full width so that content retains it space -->
{#if expand_full_files[index]}
<div class="w-full whitespace-pre font-mono text-xs">
<span class="block p-3 text-sm"> </span>
{#each file.chunks as chunk, index}
{#if index !== 0}
<span class="block h-7 p-3"> </span>
{/if}
{#each chunk.changes as _, i}
<span
class="block"
class:pt-3={index === 0 && i === 0}
class:pb-3={index === file.chunks.length - 1 &&
i === chunk.changes.length - 1}
>&nbsp;
</span>
{/each}
{/each}
</div>
{/if}
{/each}
</div>
{/if}

19
src/lib/components/events/content/Repo.svelte

@ -1,19 +0,0 @@
<script lang="ts">
import type { RepoEvent } from '$lib/components/repo/type'
import { eventToRepoEvent } from '$lib/stores/repos'
import type { NDKEvent } from '@nostr-dev-kit/ndk'
export let event: NDKEvent | RepoEvent
const isRepoEvent = (event: NDKEvent | RepoEvent): event is RepoEvent => {
return Object.keys(event).includes('web')
}
let repo = isRepoEvent(event) ? event : eventToRepoEvent(event)
</script>
{#if repo}
<span class="">
Git Repository: <a href={`/r/${repo.naddr}`}>{repo.name}</a> by
</span>
{/if}

14
src/lib/components/events/content/Status.svelte

@ -1,14 +0,0 @@
<script lang="ts">
import Status from '$lib/components/proposals/Status.svelte'
export let type: 'proposal' | 'issue' = 'proposal'
export let status: number | undefined
</script>
<div class="">
{#if status}
set status to <Status {type} {status} />
{:else}
set status incorrectly
{/if}
</div>

54
src/lib/components/events/content/utils.spec.ts

@ -1,54 +0,0 @@
import { describe, expect, test } from 'vitest'
import { extractPatchMessage } from './utils'
// const simple =
// const example = `From 35ef1fe53b5a460266a1666709d886560d99cd67 Mon Sep 17 00:00:00 2001\nFrom: fiatjaf <fiatjaf@gmail.com>\nDate: Mon, 29 Jan 2024 09:41:27 -0300\nSubject: [PATCH] fix multi-attempt password prompt.\n\nthe print was doing nothing\nand the continue was missing\n---\nfound this bug while copying these functions to be used in nak\n\n helpers.go | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n\ndiff --git a/helpers.go b/helpers.go\nindex 0b3790d..9b5c3da 100644\n--- a/helpers.go\n+++ b/helpers.go\n@@ -176,7 +176,7 @@ func promptDecrypt(ncryptsec1 string) (string, error) {\n \t\t}\n \t\tsec, err := nip49.Decrypt(ncryptsec1, password)\n \t\tif err != nil {\n-\t\t\tfmt.Fprintf(os.Stderr, "failed to decrypt: %s", err)\n+\t\t\tcontinue\n \t\t}\n \t\treturn sec, nil\n \t}\n--\n2.43.0\n', tags: (3) […], kind: 1617, id: "fd5d1be541bf2d20c51ca63265cc893eecb4be8720db9b42abec21b9ca9747de", sig: "d4733b8b32c05d1fb33a76105926fc537e4060df25405521b3f74f91ed7d65f345386260e8a825c79d67c3dd67f5e7eea7d532cda48cb8d45f09f9be19775289", pubkey: "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", … }`
describe('extractPatchMessage', () => {
test('extractPatchMessage - normal message end', () => {
expect(
extractPatchMessage(
'From 5ec8fb38b7e4d7b2081e276be456519e2dc76d46 Mon Sep 17 00:00:00 2001\nFrom: fiatjaf <fiatjaf@gmail.com>\nDate: Mon, 29 Jan 2024 09:49:32 -0300\nSubject: [PATCH] invert alias order for `git str send --to`\n\n---\n send.go | 6 +++---\n 1 file changed, 3 insertions(+), 3 deletions(-)\n\ndiff --git a/send.go b/send.go\nindex bc81c00..d9017b6 100644\n--- a/send.go\n+++ b/send.go\n@@ -25,8 +25,8 @@ var send = &cli.Command{\n \t\t\tUsage: "if we should save the secret key to git config --local",\n \t\t},\n \t\t&cli.StringFlag{\n-\t\t\tName: "repository",\n-\t\t\tAliases: []string{"a", "to"},\n+\t\t\tName: "to",\n+\t\t\tAliases: []string{"a", "repository"},\n \t\t\tUsage: "repository reference, as an naddr1... code",\n \t\t},\n \t\t&cli.StringSliceFlag{\n@@ -170,7 +170,7 @@ func getAndApplyTargetRepository(\n \t\treturn nil, nil\n \t}\n \n-\ttarget := c.String("repository")\n+\ttarget := c.String("to")\n \tvar stored string\n \tif target == "" {\n \t\ttarget, _ = git("config", "--local", "str.upstream")\n-- \n2.43.0'
)
).toEqual('invert alias order for `git str send --to`')
})
test('extractPatchMessage - unusual message end', () => {
expect(
extractPatchMessage(
`From 35ef1fe53b5a460266a1666709d886560d99cd67 Mon Sep 17 00:00:00 2001\nFrom: fiatjaf <fiatjaf@gmail.com>\nDate: Mon, 29 Jan 2024 09:41:27 -0300\nSubject: [PATCH] fix multi-attempt password prompt.\n\nthe print was doing nothing\nand the continue was missing\n---\nfound this bug while copying these functions to be used in nak\n\n helpers.go | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n\ndiff --git a/helpers.go b/helpers.go\nindex 0b3790d..9b5c3da 100644\n--- a/helpers.go\n+++ b/helpers.go\n@@ -176,7 +176,7 @@ func promptDecrypt(ncryptsec1 string) (string, error) {\n \t\t}\n \t\tsec, err := nip49.Decrypt(ncryptsec1, password)\n \t\tif err != nil {\n-\t\t\tfmt.Fprintf(os.Stderr, "failed to decrypt: %s", err)\n+\t\t\tcontinue\n \t\t}\n \t\treturn sec, nil\n \t}\n--\n2.43.0\n`
)
).toEqual(
'fix multi-attempt password prompt.\n\nthe print was doing nothing\nand the continue was missing\n---\nfound this bug while copying these functions to be used in nak'
)
})
test('cover letter', () => {
expect(
extractPatchMessage(
`From 8a45afcacd035de474e142e29cbdfa979d23f751 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] testing multiple revisions of multi patch proposal with cover letter\n\nhere is the cover letter description`
)
).toEqual(
'testing multiple revisions of multi patch proposal with cover letter\n\nhere is the cover letter description'
)
})
test('extractPatchMessage - returns undefined if not parsed', () => {
expect(
extractPatchMessage(
`From 35ef1fe53b5a460266a1666709d886560d99cd67 Mon Sep 17 00:00:00 2001\nFrom: fiatjaf <fiatjaf@gmail.com>\nDate: Mon, 29 Jan 20`
)
).toBeUndefined()
})
// TODO make this pass
test.skip('extractPatchMessage - anotherunusual message end', () => {
expect(
extractPatchMessage(
`From 1263051aa4426937c5ef4f7616e06e9a8ea021e0 Mon Sep 17 00:00:00 2001\nFrom: William Casarin <jb55@jb55.com>\nDate: Mon, 22 Jan 2024 14:41:54 -0800\nSubject: [PATCH] Revert "mention: fix broken mentions when there is text is\n directly after"\n\nThis reverts commit af75eed83a2a1dd0eb33a0a27ded71c9f44dacbd.\n---\n damus/Views/PostView.swift | 7 -------\n damusTests/PostViewTests.swift | 22 ----------------------\n 2 files changed, 29 deletions(-)\n\ndiff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift\nindex 21ca0498..934ed7de 100644\n--- a/damus/Views/PostView.swift\n+++ b/damus/Views/PostView.swift\n@@ -619,13 +619,6 @@ func load_draft_for_post(drafts: Drafts, action: PostAction) -> DraftArtifacts?\n func build_post(state: DamusState, post: NSMutableAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], references: [RefId]) -> NostrPost {\n post.enumerateAttributes(in: NSRange(location: 0, length: post.length), options: []) { attributes, range, stop in\n if let link = attributes[.link] as? String {\n- let nextCharIndex = range.upperBound\n- if nextCharIndex < post.length,\n- let nextChar = post.attributedSubstring(from: NSRange(location: nextCharIndex, length: 1)).string.first,\n- !nextChar.isWhitespace {\n- post.insert(NSAttributedString(string: " "), at: nextCharIndex)\n- }\n-\n let normalized_link: String\n if link.hasPrefix("damus:nostr:") {\n // Replace damus:nostr: URI prefix with nostr: since the former is for internal navigation and not meant to be posted.\ndiff --git a/damusTests/PostViewTests.swift b/damusTests/PostViewTests.swift\nindex 51976cad..ae78c3e6 100644\n--- a/damusTests/PostViewTests.swift\n+++ b/damusTests/PostViewTests.swift\n@@ -142,28 +142,6 @@ final class PostViewTests: XCTestCase {\n checkMentionLinkEditorHandling(content: content, replacementText: "", replacementRange: NSRange(location: 5, length: 28), shouldBeAbleToChangeAutomatically: true)\n \n }\n- \n- func testMentionLinkEditorHandling_noWhitespaceAfterLink1_addsWhitespace() {\n- var content: NSMutableAttributedString\n-\n- content = NSMutableAttributedString(string: "Hello @user ")\n- content.addAttribute(.link, value: "damus:1234", range: NSRange(location: 6, length: 5))\n- checkMentionLinkEditorHandling(content: content, replacementText: "up", replacementRange: NSRange(location: 11, length: 1), shouldBeAbleToChangeAutomatically: true, expectedNewCursorIndex: 13, handleNewContent: { newManuallyEditedContent in\n- XCTAssertEqual(newManuallyEditedContent.string, "Hello @user up")\n- XCTAssertNil(newManuallyEditedContent.attribute(.link, at: 11, effectiveRange: nil))\n- })\n- }\n- \n- func testMentionLinkEditorHandling_noWhitespaceAfterLink2_addsWhitespace() {\n- var content: NSMutableAttributedString\n-\n- content = NSMutableAttributedString(string: "Hello @user test")\n- content.addAttribute(.link, value: "damus:1234", range: NSRange(location: 6, length: 5))\n- checkMentionLinkEditorHandling(content: content, replacementText: "up", replacementRange: NSRange(location: 11, length: 1), shouldBeAbleToChangeAutomatically: true, expectedNewCursorIndex: 13, handleNewContent: { newManuallyEditedContent in\n- XCTAssertEqual(newManuallyEditedContent.string, "Hello @user uptest")\n- XCTAssertNil(newManuallyEditedContent.attribute(.link, at: 11, effectiveRange: nil))\n- })\n- }\n }\n \n func checkMentionLinkEditorHandling(\n\nbase-commit: c67741983e3f07f2386eaa388cb8a1475e8e0471\n-- \n2.42.0\n\n`
)
).toEqual(
'Revert "mention: fix broken mentions when there is text is\n directly after"\n\nThis reverts commit af75eed83a2a1dd0eb33a0a27ded71c9f44dacbd.'
)
})
})

318
src/lib/components/events/content/utils.ts

@ -1,318 +0,0 @@
import type { NDKEvent, NDKTag } from '@nostr-dev-kit/ndk'
import { nip19 } from 'nostr-tools'
import type { AddressPointer, EventPointer } from 'nostr-tools/nip19'
import last from 'ramda/src/last'
export const TOPIC = 'topic'
export const LINKCOLLECTION = 'link[]'
export const HTML = 'html'
export const INVOICE = 'invoice'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const first = (list: any) => (list ? list[0] : undefined)
export const fromNostrURI = (s: string) => s.replace(/^[\w+]+:\/?\/?/, '')
export const urlIsMedia = (url: string): boolean =>
(!url.match(/\.(apk|docx|xlsx|csv|dmg)/) &&
last(url.split('://'))?.includes('/')) ||
false
export const isImage = (url: string) =>
url.match(/^.*\.(jpg|jpeg|png|webp|gif|avif|svg)/gi)
export const isVideo = (url: string) =>
url.match(/^.*\.(mov|mkv|mp4|avi|m4v|webm)/gi)
export const isAudio = (url: string) => url.match(/^.*\.(ogg|mp3|wav)/gi)
export const NEWLINE = 'newline'
type PartTypeNewLine = 'newline'
export type ParsedNewLine = {
type: PartTypeNewLine
value: string
}
export const LINK = 'link'
type PartTypeLink = 'link'
export type ParsedLink = {
type: PartTypeLink
url: string
is_media: boolean
imeta: Imeta | undefined
}
type Imeta = {
url: string
m: string | undefined
alt: string | undefined
size: string | undefined
dim: string | undefined
x: string | undefined
fallback: string[]
blurhash: string | undefined
}
export const NOSTR_NPUB = 'nostr:npub'
type PartTypeNpub = 'nostr:npub'
export type ParsedNpub = {
type: PartTypeNpub
hex: string
}
export const NOSTR_NPROFILE = 'nostr:nprofile'
type PartTypeNprofile = 'nostr:nprofile'
export type ParsedNprofile = {
type: PartTypeNprofile
hex: string
relays: string[]
}
export const NOSTR_NOTE = 'nostr:note'
type PartTypeNote = 'nostr:note'
export type ParsedNote = {
type: PartTypeNote
data: EventPointer
}
export const NOSTR_NEVENT = 'nostr:nevent'
type PartTypeNevent = 'nostr:nevent'
export type ParsedNevent = {
type: PartTypeNevent
data: EventPointer
}
export const NOSTR_NADDR = 'nostr:naddr'
type PartTypeNaddr = 'nostr:naddr'
export type ParsedNaddr = {
type: PartTypeNaddr
data: AddressPointer
}
export type ParsedNostrLink =
| ParsedNpub
| ParsedNprofile
| ParsedNevent
| ParsedNote
| ParsedNaddr
export const TEXT = 'text'
type PartTypeText = 'text'
export type ParsedText = {
type: PartTypeText
value: string
}
export type ParsedPart =
| ParsedNewLine
| ParsedText
| ParsedNostrLink
| ParsedLink
export const isParsedNewLine = (part: ParsedPart): part is ParsedNewLine =>
part.type == NEWLINE
export const isParsedLink = (part: ParsedPart): part is ParsedLink =>
part.type == LINK
export const isParsedNostrLink = (part: ParsedPart): part is ParsedNostrLink =>
part.type == NOSTR_NPUB ||
part.type == NOSTR_NPROFILE ||
part.type == NOSTR_NEVENT ||
part.type == NOSTR_NOTE ||
part.type == NOSTR_NADDR
export const isParsedNpub = (part: ParsedPart): part is ParsedNpub =>
part.type == NOSTR_NPUB
export const isParsedNprofile = (part: ParsedPart): part is ParsedNprofile =>
part.type == NOSTR_NPROFILE
export const isParsedNevent = (part: ParsedPart): part is ParsedNevent =>
part.type == NOSTR_NEVENT
export const isParsedNote = (part: ParsedPart): part is ParsedNote =>
part.type == NOSTR_NOTE
export const isParsedNaddr = (part: ParsedPart): part is ParsedNaddr =>
part.type == NOSTR_NADDR
export const isParsedText = (part: ParsedPart): part is ParsedText =>
part.type == TEXT
export const parseContent = (content: string, tags: NDKTag[]): ParsedPart[] => {
const result: ParsedPart[] = []
let text = content.trim()
let buffer = ''
const getIMeta = (url: string): undefined | Imeta => {
const imeta_tag_for_url = tags.find(
(tag) => tag[0] === 'imeta' && tag.some((e) => e.includes(url))
)
if (!imeta_tag_for_url) return undefined
const pairs = imeta_tag_for_url.map((s) => [
s.split(' ')[0],
s.substring(s.indexOf(' ') + 1),
])
return {
url,
m: pairs.find((p) => p[0] === 'm')?.[1],
alt: pairs.find((p) => p[0] === 'alt')?.[1],
x: pairs.find((p) => p[0] === 'x')?.[1],
size: pairs.find((p) => p[0] === 'size')?.[1],
dim: pairs.find((p) => p[0] === 'dim')?.[1],
blurhash: pairs.find((p) => p[0] === 'blurhash')?.[1],
fallback: pairs.filter((p) => p[0] === 'fallback')?.map((p) => p[1]),
}
}
const parseNewline = (): undefined | [string, ParsedNewLine] => {
const newline: string = first(text.match(/^\n+/))
if (newline) {
return [newline, { type: NEWLINE, value: newline }]
}
}
const parseUrl = (): undefined | [string, ParsedLink] => {
const raw: string = first(
text.match(
/^([a-z\+:]{2,30}:\/\/)?[^<>\(\)\s]+\.[a-z]{2,6}[^\s]*[^<>"'\.!?,:\s\)\(]/gi
)
)
// Skip url if it's just the end of a filepath
if (!raw) {
return
}
const prev = last(result)
if (prev?.type === TEXT && prev.value.endsWith('/')) {
return
}
let url = raw
// Skip ellipses and very short non-urls
if (url.match(/\.\./)) {
return
}
if (!url.match('://')) {
url = 'https://' + url
}
return [
raw,
{ type: LINK, url, is_media: urlIsMedia(url), imeta: getIMeta(url) },
]
}
const parseNostrLinks = (): undefined | [string, ParsedNostrLink] => {
const bech32: string = first(
text.match(
/^(web\+)?(nostr:)?\/?\/?n(event|ote|profile|pub|addr)1[\d\w]+/i
)
)
if (bech32) {
try {
const entity = fromNostrURI(bech32)
const decoded = nip19.decode(entity)
if (decoded.type === 'npub') {
return [bech32, { type: NOSTR_NPUB, hex: decoded.data }]
}
if (decoded.type === 'nprofile') {
return [bech32, { type: NOSTR_NPUB, hex: decoded.data.pubkey }]
}
if (decoded.type === 'note') {
return [bech32, { type: NOSTR_NOTE, data: { id: decoded.data } }]
}
if (decoded.type === 'nevent') {
return [bech32, { type: NOSTR_NEVENT, data: decoded.data }]
}
if (decoded.type === 'naddr') {
return [bech32, { type: NOSTR_NADDR, data: decoded.data }]
}
} catch {}
}
}
while (text) {
// The order that this runs matters
const part = parseNewline() || parseUrl() || parseNostrLinks()
if (part) {
if (buffer) {
result.push({ type: TEXT, value: buffer })
buffer = ''
}
const [raw, parsed] = part
result.push(parsed)
text = text.slice(raw.length)
} else {
// Instead of going character by character and re-running all the above regular expressions
// a million times, try to match the next word and add it to the buffer
const match = first(text.match(/^[\w\d]+ ?/i)) || text[0]
buffer += match
text = text.slice(match.length)
}
}
if (buffer) {
result.push({ type: TEXT, value: buffer })
}
return result
}
export const isCoverLetter = (s: string): boolean => {
return s.indexOf('PATCH 0/') > 0
}
export function extractTagContent(
name: string,
tags: NDKTag[]
): string | undefined {
const tag = tags.find((tag) => tag[0] === name)
return tag ? tag[1] : undefined
}
/** this doesn't work for all patch formats and options */
export const extractPatchMessage = (s: string): string | undefined => {
try {
if (isCoverLetter(s)) {
return s.substring(s.indexOf('] ') + 2)
}
const t = s.split('\nSubject: [')[1].split('] ')[1]
if (t.split('\n\n---\n ').length > 1) return t.split('\n\n---\n ')[0]
return t.split('\n\ndiff --git ')[0].split('\n\n ').slice(0, -1).join('')
} catch {
return undefined
}
}
/** this doesn't work for all patch formats and options */
export const extractPatchTitle = (s: string): string | undefined => {
const msg = extractPatchMessage(s)
if (!msg) return undefined
return s.split('\n')[0]
}
/** patch message without first line */
export const extractPatchDescription = (s: string): string | undefined => {
const msg = extractPatchMessage(s)
if (!msg) return ''
const i = msg.indexOf('\n')
if (i === -1) return ''
return msg.substring(i).trim()
}
export const extractIssueTitle = (event: NDKEvent): string => {
return event.tagValue('subject') || event.content.split('\n')[0] || ''
}
export const extractIssueDescription = (s: string): string => {
const split = s.split('\n')
if (split.length === 0) return ''
return s.substring(split[0].length) || ''
}

12
src/lib/components/events/type.ts

@ -1,12 +0,0 @@
import type { NDKEvent } from '@nostr-dev-kit/ndk'
import type { User } from '../users/type'
export interface Event {
author: User
content: unknown
}
export interface ThreadTreeNode {
event: NDKEvent
child_nodes: ThreadTreeNode[]
}

27
src/lib/components/icons.ts

@ -1,27 +0,0 @@
export const icons_misc = {
chevron_down: [
'M6 8.825c-.2 0-.4-.1-.5-.2l-3.3-3.3c-.3-.3-.3-.8 0-1.1c.3-.3.8-.3 1.1 0l2.7 2.7l2.7-2.7c.3-.3.8-.3 1.1 0c.3.3.3.8 0 1.1l-3.2 3.2c-.2.2-.4.3-.6.3',
],
chevron_up: [
'M6 4c-.2 0-.4.1-.5.2L2.2 7.5c-.3.3-.3.8 0 1.1c.3.3.8.3 1.1 0L6 5.9l2.7 2.7c.3.3.8.3 1.1 0c.3-.3.3-.8 0-1.1L6.6 4.3C6.4 4.1 6.2 4 6 4',
],
// https://icon-sets.iconify.design/octicon/copy-16/
copy: [
'M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z',
'M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z',
],
// https://icon-sets.iconify.design/octicon/key-16/ MIT licence
key: [
'M10.5 0a5.499 5.499 0 1 1-1.288 10.848l-.932.932a.75.75 0 0 1-.53.22H7v.75a.75.75 0 0 1-.22.53l-.5.5a.75.75 0 0 1-.53.22H5v.75a.75.75 0 0 1-.22.53l-.5.5a.75.75 0 0 1-.53.22h-2A1.75 1.75 0 0 1 0 14.25v-2c0-.199.079-.389.22-.53l4.932-4.932A5.5 5.5 0 0 1 10.5 0m-4 5.5c-.001.431.069.86.205 1.269a.75.75 0 0 1-.181.768L1.5 12.56v1.69c0 .138.112.25.25.25h1.69l.06-.06v-1.19a.75.75 0 0 1 .75-.75h1.19l.06-.06v-1.19a.75.75 0 0 1 .75-.75h1.19l1.023-1.025a.75.75 0 0 1 .768-.18A4 4 0 1 0 6.5 5.5M11 6a1 1 0 1 1 0-2a1 1 0 0 1 0 2',
],
// https://icon-sets.iconify.design/clarity/lightning-solid/ MIT licence
lightning: [
'M5.52.359A.5.5 0 0 1 6 0h4a.5.5 0 0 1 .474.658L8.694 6H12.5a.5.5 0 0 1 .395.807l-7 9a.5.5 0 0 1-.873-.454L6.823 9.5H3.5a.5.5 0 0 1-.48-.641z',
],
info: [
'M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m8-6.5a6.5 6.5 0 1 0 0 13a6.5 6.5 0 0 0 0-13M6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75M8 6a1 1 0 1 1 0-2a1 1 0 0 1 0 2',
],
link: [
'm7.775 3.275l1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0a.75.75 0 0 1 .018-1.042a.75.75 0 0 1 1.042-.018a2 2 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.75.75 0 0 1-1.042-.018a.75.75 0 0 1-.018-1.042m-4.69 9.64a2 2 0 0 0 2.83 0l1.25-1.25a.75.75 0 0 1 1.042.018a.75.75 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0a.75.75 0 0 1-.018 1.042a.75.75 0 0 1-1.042.018a2 2 0 0 0-2.83 0l-2.5 2.5a2 2 0 0 0 0 2.83',
],
}

18
src/lib/components/issues/icons.ts

@ -1,18 +0,0 @@
// icon are MIT licenced
export const issue_icon_path = {
// https://icon-sets.iconify.design/octicon/issue-opened-16/
open: [
'M8 9.5a1.5 1.5 0 1 0 0-3a1.5 1.5 0 0 0 0 3',
'M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0M1.5 8a6.5 6.5 0 1 0 13 0a6.5 6.5 0 0 0-13 0',
],
// https://icon-sets.iconify.design/octicon/issue-closed-16/
resolved: [
'M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69L5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0z',
'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-1.5 0a6.5 6.5 0 1 0-13 0a6.5 6.5 0 0 0 13 0',
],
// https://icon-sets.iconify.design/octicon/no-entry-16/
closed: [
'M4.25 7.25a.75.75 0 0 0 0 1.5h7.5a.75.75 0 0 0 0-1.5z',
'M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-1.5 0a6.5 6.5 0 1 0-13 0a6.5 6.5 0 0 0 13 0',
],
}

60
src/lib/components/issues/type.ts

@ -1,60 +0,0 @@
import type { User } from '../users/type'
import { defaults as user_defaults } from '../users/type'
import type { Event } from '../events/type'
import type { NDKEvent } from '@nostr-dev-kit/ndk'
export interface IssueSummary {
type: 'issue'
title: string
descritpion: string
repo_a: string
id: string
comments: number
status: undefined | number
status_date: number
author: User
created_at: number | undefined
loading: boolean
}
export const summary_defaults: IssueSummary = {
type: 'issue',
title: '',
descritpion: '',
repo_a: '',
id: '',
comments: 0,
status: undefined,
status_date: 0,
author: { ...user_defaults },
created_at: 0,
loading: true,
}
export interface IssueSummaries {
repo_a: string | undefined
summaries: IssueSummary[]
loading: boolean
}
export const summaries_defaults: IssueSummaries = {
repo_a: '',
summaries: [],
loading: true,
}
export interface IssueFull {
summary: IssueSummary
issue_event: NDKEvent | undefined
labels: string[]
events: Event[]
loading: boolean
}
export const full_defaults: IssueFull = {
summary: { ...summary_defaults },
issue_event: undefined,
labels: [],
events: [],
loading: true,
}

50
src/lib/components/proposals/ProposalDetails.svelte

@ -1,50 +0,0 @@
<script lang="ts">
import { full_defaults, summary_defaults, type ProposalSummary } from './type'
import UserHeader from '../users/UserHeader.svelte'
import StatusSelector from './StatusSelector.svelte'
import type { IssueSummary } from '../issues/type'
export let type: 'proposal' | 'issue' = 'proposal'
export let summary: ProposalSummary | IssueSummary = { ...summary_defaults }
export let { labels, loading } = { ...full_defaults }
</script>
<div class="max-w-md">
<div>
{#if loading}
<div class="skeleton my-3 h-5 w-20"></div>
<div class="badge skeleton my-2 block w-60"></div>
<div class="badge skeleton my-2 block w-40"></div>
{:else}
<h4>Author</h4>
<UserHeader user={summary.author} />
{/if}
</div>
<div>
{#if loading}
<div class="skeleton my-3 h-5 w-20"></div>
<div class="badge skeleton my-2 block w-60"></div>
<div class="badge skeleton my-2 block w-40"></div>
{:else}
<h4>Status</h4>
<StatusSelector
{type}
status={summary.status}
proposal_or_issue_id={summary.id}
/>
{/if}
</div>
<div>
{#if loading}
<div class="badge skeleton w-20"></div>
<div class="badge skeleton w-20"></div>
{:else}
<h4>Labels</h4>
{#each labels as label}
<div class="badge badge-secondary mr-2">{label}</div>
{/each}
{/if}
</div>
</div>

26
src/lib/components/proposals/ProposalHeader.stories.svelte

@ -1,26 +0,0 @@
<script lang="ts" context="module">
import type { Meta } from '@storybook/svelte'
import ProposalHeader from './ProposalHeader.svelte'
import { Story, Template } from '@storybook/addon-svelte-csf'
import { ProposalsListItemArgsVectors as vectors } from './vectors'
export const meta: Meta<ProposalHeader> = {
title: 'Proposals/Header',
component: ProposalHeader,
tags: ['autodocs'],
}
</script>
<Template let:args>
<ProposalHeader {...args} />
</Template>
<Story name="Short Details" args={vectors.Short} />
<Story name="Long Details" args={vectors.Long} />
<Story name="Long and No Spaces" args={vectors.LongNoSpaces} />
<Story name="Author Loading" args={vectors.AuthorLoading} />
<Story name="loading" args={{ loading: true }} />

104
src/lib/components/proposals/ProposalHeader.svelte

@ -1,104 +0,0 @@
<script lang="ts" context="module">
</script>
<script lang="ts">
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { summary_defaults } from './type'
import {
defaults as user_defaults,
getName,
type UserObject,
} from '../users/type'
import Container from '../Container.svelte'
import Status from './Status.svelte'
import { ensureUser, logged_in_user } from '$lib/stores/users'
import type { Unsubscriber } from 'svelte/store'
import { onDestroy } from 'svelte'
import StatusSelector from './StatusSelector.svelte'
dayjs.extend(relativeTime)
export let type: 'proposal' | 'issue' = 'proposal'
export let {
title,
descritpion,
repo_a,
id,
comments,
status,
status_date,
author,
created_at,
loading,
} = summary_defaults
let short_title: string
let created_at_ago: string
let author_name = ''
let author_object: UserObject = {
...user_defaults,
}
let unsubscriber: Unsubscriber
$: {
if (typeof author === 'string') {
if (unsubscriber) unsubscriber()
unsubscriber = ensureUser(author).subscribe((u) => {
author_object = { ...u }
})
} else author_object = author
}
onDestroy(() => {
if (unsubscriber) unsubscriber()
})
$: {
author_name = getName(author_object)
}
$: {
if (title.length > 70) short_title = title.slice(0, 65) + '...'
else if (title.length == 0) short_title = 'Untitled'
else short_title = title
created_at_ago = created_at ? dayjs(created_at * 1000).fromNow() : ''
}
</script>
<div
class="grow border-b border-accent-content bg-base-200 pb-4 pt-2 text-xs text-neutral-content"
>
<Container>
{#if loading}
<div>
<div class="skeleton h-7 w-60 pt-1"></div>
<div class="">
<div class="skeleton mt-3 inline-block h-8 w-20 align-middle"></div>
<div
class="skeleton ml-3 mt-5 inline-block h-3 w-28 align-middle"
></div>
<div
class="skeleton ml-3 mt-5 inline-block h-3 w-28 align-middle"
></div>
</div>
</div>
{:else}
<div class="mb-2 text-lg text-base-content">
{short_title}
</div>
<div class="pt-1">
<div class="mr-3 inline align-middle">
{#if !$logged_in_user}
<Status {type} {status} edit_mode={false} />
{:else}
<StatusSelector {type} {status} proposal_or_issue_id={id} />{/if}
</div>
<div class="mr-3 inline align-middle">
opened {created_at_ago}
</div>
<div class="inline align-middle">
{#if author_object.loading}
<div class="skeleton inline-block h-3 w-20 pb-2"></div>
{:else}
{author_name}
{/if}
</div>
</div>
{/if}
</Container>
</div>

56
src/lib/components/proposals/ProposalsList.stories.svelte

@ -1,56 +0,0 @@
<script lang="ts" context="module">
import type { Meta } from '@storybook/svelte'
import ProposalsList from './ProposalsList.svelte'
import { Story, Template } from '@storybook/addon-svelte-csf'
import { ProposalsListItemArgsVectors as vectors } from './vectors'
export const meta: Meta<ProposalsList> = {
title: 'Proposals/List/List',
component: ProposalsList,
tags: ['autodocs'],
}
</script>
<Template let:args>
<ProposalsList {...args} />
</Template>
<Story
name="Default"
args={{
title: 'Open Proposals',
proposals_or_issues: [vectors.Short, vectors.Long, vectors.LongNoSpaces],
}}
/>
<Story
name="No Title"
args={{
proposals_or_issues: [vectors.Short, vectors.Long],
}}
/>
<Story
name="Empty"
args={{
title: 'Open Proposals',
proposals_or_issues: [],
}}
/>
<Story
name="Loading"
args={{
title: 'Open Proposals',
proposals_or_issues: [],
loading: true,
}}
/>
<Story
name="Partially Loaded"
args={{
title: 'Open Proposals',
proposals_or_issues: [vectors.Short, vectors.Long],
loading: true,
}}
/>

47
src/lib/components/proposals/ProposalsList.svelte

@ -1,47 +0,0 @@
<script lang="ts">
import ProposalsListItem from '$lib/components/proposals/ProposalsListItem.svelte'
import type { IssueSummary } from '../issues/type'
import type { ProposalSummary } from './type'
export let title: string = ''
export let proposals_or_issues: ProposalSummary[] | IssueSummary[] = []
export let repo_naddr_override: string | undefined = undefined
export let loading: boolean = false
export let show_repo: boolean = false
export let limit: number = 0
export let allow_more = true
export let sort_youngest_first = true
let current_limit = limit
</script>
<div class="">
{#if title.length > 0}
<div class="prose">
<h4>{title}</h4>
</div>
{/if}
{#if proposals_or_issues.length == 0 && !loading}
<p class="prose">None</p>
{/if}
<ul class=" divide-y divide-base-400">
{#each sort_youngest_first ? proposals_or_issues.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) : proposals_or_issues as proposal, index}
{#if current_limit === 0 || index + 1 <= current_limit}
<ProposalsListItem {...proposal} {repo_naddr_override} {show_repo} />
{/if}
{/each}
{#if loading}
<ProposalsListItem loading={true} />
{#if proposals_or_issues.length == 0}
<ProposalsListItem loading={true} />
<ProposalsListItem loading={true} />
{/if}
{:else if allow_more && limit !== 0 && proposals_or_issues.length > current_limit}
<button
on:click={() => {
current_limit = current_limit + 5
}}
class="btn mt-3 p-3 font-normal">more</button
>
{/if}
</ul>
</div>

34
src/lib/components/proposals/ProposalsListItem.stories.svelte

@ -1,34 +0,0 @@
<script lang="ts" context="module">
import type { Meta } from '@storybook/svelte'
import ProposalsListItem from './ProposalsListItem.svelte'
import { Story, Template } from '@storybook/addon-svelte-csf'
import { ProposalsListItemArgsVectors as vectors } from './vectors'
export const meta: Meta<ProposalsListItem> = {
title: 'Proposals/List/Item',
component: ProposalsListItem,
tags: ['autodocs'],
}
</script>
<Template let:args>
<ProposalsListItem {...args} />
</Template>
<Story name="Short Details" args={vectors.Short} />
<Story name="Long Details" args={vectors.Long} />
<Story name="Long and No Spaces" args={vectors.LongNoSpaces} />
<Story name="Author Loading" args={vectors.AuthorLoading} />
<Story name="Status Loading" args={vectors.StatusLoading} />
<Story name="Status Draft" args={vectors.StatusDraft} />
<Story name="Status Closed" args={vectors.StatusClosed} />
<Story name="Status Applied" args={vectors.StatusApplied} />
<Story name="loading" args={{ loading: true }} />

167
src/lib/components/proposals/ProposalsListItem.svelte

@ -1,167 +0,0 @@
<script lang="ts" context="module">
</script>
<script lang="ts">
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { summary_defaults } from './type'
import { proposal_icon_path } from './icons'
import UserHeader from '../users/UserHeader.svelte'
import {
proposal_status_applied,
proposal_status_closed,
proposal_status_draft,
proposal_status_open,
} from '$lib/kinds'
import { issue_icon_path } from '../issues/icons'
import { aToNaddr, naddrToPointer } from '../repo/utils'
import { nip19 } from 'nostr-tools'
dayjs.extend(relativeTime)
export let type: 'issue' | 'proposal' = 'proposal'
export let {
title,
descritpion,
id,
repo_a,
comments,
status,
status_date,
author,
created_at,
loading,
} = summary_defaults
export let show_repo: boolean = false
export let repo_naddr_override: string | undefined = undefined
let short_title: string
let created_at_ago: string
$: {
if (title.length > 70) short_title = title.slice(0, 65) + '...'
else if (title.length == 0) short_title = 'Untitled'
else short_title = title
created_at_ago = created_at ? dayjs(created_at * 1000).fromNow() : ''
}
let repo_naddr = ''
let repo_identifier = ''
$: {
if (repo_a.length > 0) {
repo_naddr = repo_naddr_override || aToNaddr(repo_a) || ''
if (repo_naddr_override) {
repo_identifier = naddrToPointer(repo_naddr)?.identifier || ''
}
}
}
</script>
<li class="flex p-2 pt-4 {!loading ? 'cursor-pointer hover:bg-base-200' : ''}">
<!-- <figure class="p-4 pl-0 text-color-primary"> -->
<!-- http://icon-sets.iconify.design/octicon/git-pull-request-16/ -->
{#if loading || !status}
<div class="skeleton h-5 w-5 flex-none pt-1"></div>
{:else if status === proposal_status_open}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="h-5 w-5 flex-none fill-success pt-1"
>
{#if type === 'proposal'}
<path d={proposal_icon_path.open_patch} />
{:else if type === 'issue'}
{#each issue_icon_path.open as p}
<path d={p} />
{/each}
{/if}
</svg>
{:else if status === proposal_status_closed}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="h-5 w-5 flex-none fill-neutral-content pt-1"
>
{#if type === 'proposal'}
<path d={proposal_icon_path.close} />
{:else if type === 'issue'}
{#each issue_icon_path.closed as p}
<path d={p} />
{/each}
{/if}
</svg>
{:else if status === proposal_status_draft}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="h-5 w-5 flex-none fill-neutral-content pt-1"
><path d={proposal_icon_path.draft} /></svg
>
{:else if status === proposal_status_applied}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="h-5 w-5 flex-none fill-primary pt-1"
>
{#if type === 'proposal'}
<path d={proposal_icon_path.applied} />
{:else if type === 'issue'}
{#each issue_icon_path.resolved as p}
<path d={p} />
{/each}
{/if}
</svg>
{/if}
<a
href="/r/{repo_naddr}/{type}s/{nip19.noteEncode(id) || ''}"
class="ml-3 grow overflow-hidden text-xs text-neutral-content"
class:pointer-events-none={loading}
>
{#if loading}
<div class="skeleton h-5 w-60 flex-none pt-1"></div>
<div class="skeleton mb-1 mt-3 h-3 w-40 flex-none"></div>
{:else}
<div class="text-sm text-base-content">
{short_title}
</div>
<!-- <div class="text-xs text-neutral-content">
{description}
</div> -->
<ul class="pt-2">
{#if comments > 0}
<li class="mr-3 inline align-middle">
<!-- http://icon-sets.iconify.design/octicon/comment-16/ -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="inline-block h-3 w-3 flex-none fill-base-content pt-0"
viewBox="0 0 16 16"
><path
d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"
/></svg
>
{comments}
</li>
{/if}
<li class="mr-3 inline">
opened {created_at_ago}
</li>
<li class="inline">
<UserHeader user={author} inline={true} size="xs" />
</li>
{#if show_repo && repo_identifier.length > 0}
<li class="ml-3 inline">
<a class="link-primary z-10" href="/r/{repo_naddr}">
{repo_identifier}
</a>
</li>
{/if}
</ul>
{/if}
</a>
<!-- <div class="flex-none text-xs pt-0 hidden md:block">
<div class="align-middle">
{#if loading}
<div class="skeleton w-10 h-10"></div>
{:else}
<Avatar />
{/if}
</div>
</div> -->
</li>

27
src/lib/components/proposals/Status.stories.svelte

@ -1,27 +0,0 @@
<script lang="ts" context="module">
import type { Meta } from '@storybook/svelte'
import Status from './Status.svelte'
import { Story, Template } from '@storybook/addon-svelte-csf'
export const meta: Meta<Status> = {
title: 'Proposals/Status',
component: Status,
tags: ['autodocs'],
}
</script>
<Template let:args>
<Status {...args} />
</Template>
<Story name="Open" args={{ status: 'Open' }} />
<Story name="Closed" args={{ status: 'Closed' }} />
<Story name="Draft" args={{ status: 'Draft' }} />
<Story name="Applied" args={{ status: 'Applied' }} />
<Story name="Open Edit Mode" args={{ edit_mode: true, status: 'Open' }} />
<Story name="Loading" args={{ status: undefined }} />

110
src/lib/components/proposals/Status.svelte

@ -1,110 +0,0 @@
<script lang="ts">
import {
proposal_status_applied,
proposal_status_closed,
proposal_status_draft,
proposal_status_open,
statusKindtoText,
} from '$lib/kinds'
import { issue_icon_path } from '../issues/icons'
import { proposal_icon_path } from './icons'
export let status: number | undefined = undefined
export let type: 'proposal' | 'issue' = 'proposal'
export let edit_mode = false
</script>
{#if !status}
<div class="skeleton inline-block h-8 w-24 rounded-md align-middle"></div>
{:else}
<div
tabIndex={0}
role="button"
class:btn-success={status && status === proposal_status_open}
class:btn-primary={status && status === proposal_status_applied}
class:btn-neutral={!status ||
status === proposal_status_draft ||
status === proposal_status_closed}
class:cursor-default={!edit_mode}
class:no-animation={!edit_mode}
class:hover:bg-success={!edit_mode &&
status &&
status === proposal_status_open}
class:hover:bg-primary={!edit_mode &&
status &&
status === proposal_status_applied}
class:hover:bg-neutral={(!edit_mode &&
status &&
status === proposal_status_draft) ||
status === proposal_status_closed}
class="btn btn-success btn-sm align-middle"
>
{#if status === proposal_status_open}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 18 18"
class="h-5 w-5 flex-none fill-success-content pt-1"
>
{#if type === 'proposal'}
<path d={proposal_icon_path.open_patch} />
{:else if type === 'issue'}
{#each issue_icon_path.open as p}
<path d={p} />
{/each}
{/if}
</svg>
{statusKindtoText(proposal_status_open, type)}
{:else if status === proposal_status_applied}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="h-5 w-5 flex-none fill-primary-content pt-1"
>
{#if type === 'proposal'}
<path d={proposal_icon_path.applied} />
{:else if type === 'issue'}
{#each issue_icon_path.resolved as p}
<path d={p} />
{/each}
{/if}
</svg>
{statusKindtoText(proposal_status_applied, type)}
{:else if status === proposal_status_closed}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="h-5 w-5 flex-none fill-neutral-content pt-1"
>
{#if type === 'proposal'}
<path d={proposal_icon_path.close} />
{:else if type === 'issue'}
{#each issue_icon_path.closed as p}
<path d={p} />
{/each}
{/if}
</svg>
{statusKindtoText(proposal_status_closed, type)}
{:else if status === proposal_status_draft}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="h-5 w-5 flex-none fill-neutral-content pt-1"
><path d={proposal_icon_path.draft} /></svg
>
{statusKindtoText(proposal_status_draft, type)}
{:else}
{status}
{/if}
{#if edit_mode}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-5 w-5 flex-none fill-success-content"
><path
fill="currentColor"
d="M11.646 15.146L5.854 9.354a.5.5 0 0 1 .353-.854h11.586a.5.5 0 0 1 .353.854l-5.793 5.792a.5.5 0 0 1-.707 0"
/></svg
>
{/if}
</div>
{/if}

141
src/lib/components/proposals/StatusSelector.svelte

@ -1,141 +0,0 @@
<script lang="ts">
import { ndk } from '$lib/stores/ndk'
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'
import {
selected_proposal_full,
selected_proposal_replies,
} from '$lib/stores/Proposal'
import {
proposal_status_applied,
proposal_status_closed,
proposal_status_draft,
proposal_status_open,
statusKindtoText,
} from '$lib/kinds'
import { getUserRelays, logged_in_user } from '$lib/stores/users'
import { selected_repo_event } from '$lib/stores/repo'
import Status from '$lib/components/proposals/Status.svelte'
export let status: number | undefined = undefined
export let type: 'proposal' | 'issue' = 'proposal'
export let proposal_or_issue_id: string = ''
let loading = false
let edit_mode = false
$: {
edit_mode = $logged_in_user !== undefined
}
async function changeStatus(new_status_kind: number) {
if (!$logged_in_user) return
let event = new NDKEvent(ndk)
event.kind = new_status_kind
// tag proposal event
event.tags.push(['e', proposal_or_issue_id, 'root'])
// tag proposal revision event
$selected_proposal_replies
.filter((reply) =>
reply.tags.some((t) => t.length > 1 && t[1] === 'revision-root')
)
.forEach((revision) => {
event.tags.push(['e', revision.id, 'mention'])
})
if ($selected_repo_event.unique_commit)
event.tags.push(['r', $selected_repo_event.unique_commit])
loading = true
let relays = [...$selected_repo_event.relays]
try {
event.sign()
} catch {
alert('failed to sign event')
}
try {
let user_relays = await getUserRelays($logged_in_user.hexpubkey)
relays = [
...relays,
...(user_relays.ndk_relays
? user_relays.ndk_relays.writeRelayUrls
: []),
// TODO: proposal event pubkey relays
]
} catch {
alert('failed to get user relays')
}
try {
let _ = await event.publish(NDKRelaySet.fromRelayUrls(relays, ndk))
selected_proposal_full.update((proposal_full) => {
if (proposal_full.summary.id !== proposal_or_issue_id)
return proposal_full
return {
...proposal_full,
summary: {
...proposal_full.summary,
status: new_status_kind,
status_date: event.created_at || 0,
},
}
})
loading = false
} catch {}
}
</script>
{#if loading || !status}
<Status {type} />
{:else}
<div class="dropdown">
<Status {type} {edit_mode} {status} />
{#if edit_mode}
<ul
tabIndex={0}
class="menu dropdown-content z-[1] ml-0 w-52 rounded-box bg-base-300 p-2 shadow"
>
{#if status !== proposal_status_draft && type !== 'issue'}
<li class="my-2 pl-0">
<button
on:click={() => {
changeStatus(proposal_status_draft)
}}
class="btn btn-neutral btn-sm mx-2 align-middle"
>{statusKindtoText(proposal_status_draft, type)}</button
>
</li>
{/if}
{#if status !== proposal_status_open}
<li class="my-2 pl-0">
<button
on:click={() => {
changeStatus(proposal_status_open)
}}
class="btn btn-success btn-sm mx-2 align-middle"
>{statusKindtoText(proposal_status_open, type)}</button
>
</li>
{/if}
{#if status !== proposal_status_applied}
<li class="my-2 pl-0">
<button
on:click={() => {
changeStatus(proposal_status_applied)
}}
class="btn btn-primary btn-sm mx-2 align-middle"
>{statusKindtoText(proposal_status_applied, type)}</button
>
</li>
{/if}
{#if status !== proposal_status_closed}
<li class="my-2 pl-0">
<button
on:click={() => {
changeStatus(proposal_status_closed)
}}
class="btn btn-neutral btn-sm mx-2 align-middle"
>{statusKindtoText(proposal_status_closed, type)}</button
>
</li>
{/if}
</ul>
{/if}
</div>
{/if}

22
src/lib/components/proposals/icons.ts

@ -1,22 +0,0 @@
// icon are MIT licenced
export const proposal_icon_path = {
// http://icon-sets.iconify.design/octicon/git-pull-request-16/
open_pull:
'M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25m5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354M3.75 2.5a.75.75 0 1 0 0 1.5a.75.75 0 0 0 0-1.5m0 9.5a.75.75 0 1 0 0 1.5a.75.75 0 0 0 0-1.5m8.25.75a.75.75 0 1 0 1.5 0a.75.75 0 0 0-1.5 0',
// https://icon-sets.iconify.design/octicon/git-pull-request-closed-16/
open_patch:
'M3.75 4.5a1.25 1.25 0 1 0 0-2.5a1.25 1.25 0 0 0 0 2.5M3 7.75a.75.75 0 0 1 1.5 0v2.878a2.251 2.251 0 1 1-1.5 0Zm.75 5.75a.75.75 0 1 0 0-1.5a.75.75 0 0 0 0 1.5m5-7.75a1.25 1.25 0 1 1-2.5 0a1.25 1.25 0 0 1 2.5 0m5.75 2.5a2.25 2.25 0 1 1-4.5 0a2.25 2.25 0 0 1 4.5 0m-1.5 0a.75.75 0 1 0-1.5 0a.75.75 0 0 0 1.5 0',
// https://icon-sets.iconify.design/octicon/git-pull-request-closed-16/
close:
'M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1m9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75m-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97l.97-.97a.748.748 0 0 1 1.265.332a.75.75 0 0 1-.205.729l-.97.97l.97.97a.751.751 0 0 1-.018 1.042a.751.751 0 0 1-1.042.018l-.97-.97l-.97.97a.749.749 0 0 1-1.275-.326a.749.749 0 0 1 .215-.734l.97-.97l-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0a.75.75 0 0 0-1.5 0M3.25 12a.75.75 0 1 0 0 1.5a.75.75 0 0 0 0-1.5m9.5 0a.75.75 0 1 0 0 1.5a.75.75 0 0 0 0-1.5',
// https://icon-sets.iconify.design/octicon/git-pull-request-draft-16/
draft:
'M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1m9.5 14a2.25 2.25 0 1 1 0-4.5a2.25 2.25 0 0 1 0 4.5M2.5 3.25a.75.75 0 1 0 1.5 0a.75.75 0 0 0-1.5 0M3.25 12a.75.75 0 1 0 0 1.5a.75.75 0 0 0 0-1.5m9.5 0a.75.75 0 1 0 0 1.5a.75.75 0 0 0 0-1.5M14 7.5a1.25 1.25 0 1 1-2.5 0a1.25 1.25 0 0 1 2.5 0m0-4.25a1.25 1.25 0 1 1-2.5 0a1.25 1.25 0 0 1 2.5 0',
// https://icon-sets.iconify.design/octicon/git-merge-16/
merge:
'M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218M4.25 13.5a.75.75 0 1 0 0-1.5a.75.75 0 0 0 0 1.5m8.5-4.5a.75.75 0 1 0 0-1.5a.75.75 0 0 0 0 1.5M5 3.25a.75.75 0 1 0 0 .005z',
// adapted from https://icon-sets.iconify.design/octicon/git-pull-request-closed-16/
// TODO: centre in icon frame
applied:
'M 3.25 1 A 2.25 2.25 0 0 1 4 5.372 v 5.256 a 2.251 2.251 0 1 1 -1.5 0 V 5.372 A 2.251 2.251 0 0 1 3.25 1 Z M 2.5 3.25 a 0.75 0.75 0 1 0 1.5 0 a 0.75 0.75 0 0 0 -1.5 0 M 3.25 12 a 0.75 0.75 0 1 0 0 1.5 a 0.75 0.75 0 0 0 0 -1.5',
}

60
src/lib/components/proposals/type.ts

@ -1,60 +0,0 @@
import type { User } from '../users/type'
import { defaults as user_defaults } from '../users/type'
import type { Event } from '../events/type'
import type { NDKEvent } from '@nostr-dev-kit/ndk'
export interface ProposalSummary {
type: 'proposal'
title: string
descritpion: string
repo_a: string
id: string
comments: number
status: undefined | number
status_date: number
author: User
created_at: number | undefined
loading: boolean
}
export const summary_defaults: ProposalSummary = {
type: 'proposal',
title: '',
descritpion: '',
repo_a: '',
id: '',
comments: 0,
status: undefined,
status_date: 0,
author: { ...user_defaults },
created_at: 0,
loading: true,
}
export interface ProposalSummaries {
repo_a: string | undefined
summaries: ProposalSummary[]
loading: boolean
}
export const summaries_defaults: ProposalSummaries = {
repo_a: '',
summaries: [],
loading: true,
}
export interface ProposalFull {
summary: ProposalSummary
proposal_event: NDKEvent | undefined
labels: string[]
events: Event[]
loading: boolean
}
export const full_defaults: ProposalFull = {
summary: { ...summary_defaults },
proposal_event: undefined,
labels: [],
events: [],
loading: true,
}

66
src/lib/components/proposals/vectors.ts

@ -1,66 +0,0 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import type { ProposalSummary } from './type'
import { UserVectors } from '../users/vectors'
import {
proposal_status_applied,
proposal_status_draft,
proposal_status_open,
} from '$lib/kinds'
dayjs.extend(relativeTime)
const Short = {
title: 'short title',
author: { ...UserVectors.default },
created_at: dayjs().subtract(7, 'days').unix(),
comments: 2,
status: proposal_status_open,
loading: false,
} as ProposalSummary
export const ProposalsListItemArgsVectors = {
Short,
Long: {
title:
'rather long title that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on',
author: { ...UserVectors.default },
created_at: dayjs().subtract(1, 'minute').unix(),
comments: 0,
status: proposal_status_open,
loading: false,
} as ProposalSummary,
LongNoSpaces: {
title:
'LongNameLongNameLongNameLongNameLongNameLongNameLongNameLongNameLongNameLongNameLongNameLongNameLongNameLongName',
author: { ...UserVectors.default },
created_at: dayjs().subtract(3, 'month').subtract(3, 'days').unix(),
comments: 1,
status: proposal_status_open,
loading: false,
} as ProposalSummary,
AuthorLoading: {
title: 'short title',
author: { ...UserVectors.loading },
created_at: dayjs().subtract(3, 'month').subtract(3, 'days').unix(),
comments: 1,
status: proposal_status_open,
loading: false,
} as ProposalSummary,
StatusLoading: {
...Short,
status: undefined,
} as ProposalSummary,
StatusDraft: {
...Short,
status: proposal_status_draft,
} as ProposalSummary,
StatusClosed: {
...Short,
status: proposal_status_draft,
} as ProposalSummary,
StatusApplied: {
...Short,
status: proposal_status_applied,
} as ProposalSummary,
}

55
src/lib/components/repo/RepoDetails.stories.svelte

@ -1,55 +0,0 @@
<script lang="ts" context="module">
import type { Meta } from '@storybook/svelte'
import RepoDetails from './RepoDetails.svelte'
import { Story, Template } from '@storybook/addon-svelte-csf'
import { RepoDetailsArgsVectors as vectors } from './vectors'
export const meta: Meta<RepoDetails> = {
title: 'Repo/Details',
component: RepoDetails,
tags: ['autodocs'],
}
</script>
<Template let:args>
<RepoDetails {...args} />
</Template>
<Story name="Short Details" args={vectors.NoMaintainers} />
<Story name="Long Details" args={vectors.Long} />
<Story name="Long and No Spaces" args={vectors.LongNoSpaces} />
<Story name="No Name or Description" args={vectors.NoNameOrDescription} />
<Story name="No Description" args={vectors.NoDescription} />
<Story name="No Tags" args={vectors.NoTags} />
<Story name="No Clone" args={vectors.NoGitServer} />
<Story name="No Web" args={vectors.NoWeb} />
<Story name="No Maintainers" args={vectors.NoMaintainers} />
<Story
name="One Maintainer's Profile Not Loaded"
args={vectors.MaintainersOneProfileNotLoaded}
/>
<Story
name="One Maintainer's Profile Only Has displayName But No Name"
args={vectors.MaintainersOneProfileDisplayNameWithoutName}
/>
<Story
name="One Maintainer's Profile Has No displayName or Name"
args={vectors.MaintainersOneProfileNoNameOrDisplayNameBeingPresent}
/>
<Story name="No Relays" args={vectors.NoRelays} />
<Story name="No Maintainers or Relays" args={vectors.NoMaintainersOrRelays} />
<Story name="loading" args={{ loading: true }} />

365
src/lib/components/repo/RepoDetails.svelte

@ -1,365 +0,0 @@
<script lang="ts">
import UserHeader from '$lib/components/users/UserHeader.svelte'
import { nip19 } from 'nostr-tools'
import AlertWarning from '../AlertWarning.svelte'
import { icons_misc } from '../icons'
import InstallNgit from '../InstallNgit.svelte'
import { event_defaults } from './type'
export let {
event_id,
naddr,
identifier,
author,
unique_commit,
name,
description,
clone,
web,
tags,
maintainers,
relays,
referenced_by,
most_recent_reference_timestamp,
created_at,
loading,
} = event_defaults
$: short_descrption =
!description && description.length > 500
? description.slice(0, 450) + '...'
: description
let naddr_copied = false
const create_nostr_url = (
maintainers: string[],
identifier: string,
relays: string[]
) => {
if (identifier.length > 0 && maintainers.length > 0) {
let npub = nip19.npubEncode(maintainers[0])
if (relays.length > 0) {
let relay = relays[0]
// remove trailing slash(es)
.replace(/\/+$/, '')
if (/^[a-zA-Z0-9.]+$/.test(relay.replace('wss://', ''))) {
return `nostr://${npub}/${relay.replace('wss://', '')}/${identifier}`
}
return `nostr://${npub}/${encodeURIComponent(relay)}/${identifier}`
}
return `nostr://${npub}/${identifier}`
}
return ''
}
$: nostr_url = create_nostr_url(maintainers, identifier, relays)
let nostr_url_copied = false
let git_url_copied: false | string = false
let maintainer_copied: false | string = false
$: event_not_found = !loading && created_at == 0
</script>
<div class="prose w-full max-w-md">
{#if event_not_found}
<h4 class="mt-0 pt-1">identifier</h4>
<p class="my-2 break-words text-sm">{identifier}</p>
{:else}
{#if name == identifier}
{#if loading}
<div class="skeleton my-3 h-5 w-20"></div>
<div class="skeleton my-2 h-4"></div>
<div class="skeleton my-2 mb-3 h-4 w-2/3"></div>
{:else if !name || name.length == 0}
<h4 class="mt-0 pt-1">name / identifier</h4>
<div>none</div>
{:else}
<h4 class="mt-0 pt-1">name / identifier</h4>
<p class="my-2 break-words text-sm">{name}</p>
{/if}
{:else}
{#if loading}
<div class="skeleton my-3 h-5 w-20"></div>
<div class="skeleton my-2 h-4"></div>
<div class="skeleton my-2 mb-3 h-4 w-2/3"></div>
{:else if !name || name.length == 0}
<h4>name</h4>
<div>none</div>
{:else}
<h4>name</h4>
<p class="my-2 break-words text-sm">{name}</p>
{/if}
{#if loading}
<div class="skeleton my-3 h-5 w-20"></div>
<div class="skeleton my-2 h-4"></div>
<div class="skeleton my-2 mb-3 h-4 w-2/3"></div>
{:else if !identifier || identifier.length == 0}
<h4>identifier</h4>
<div>none</div>
{:else}
<h4>identifier</h4>
<p class="my-2 break-words text-sm">{identifier}</p>
{/if}
{/if}
{#if !loading}
<div class="dropdown dropdown-end mt-3">
<div tabIndex={0} class="btn btn-success btn-sm text-base-400">
clone
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-5 w-5 flex-none fill-success-content"
><path
fill="currentColor"
d="M11.646 15.146L5.854 9.354a.5.5 0 0 1 .353-.854h11.586a.5.5 0 0 1 .353.854l-5.793 5.792a.5.5 0 0 1-.707 0"
/></svg
>
</div>
<ul
tabIndex={0}
class="w-md menu dropdown-content z-[1] ml-0 rounded-box bg-base-300 p-2 shadow"
>
<li class="prose">
<div>
<div>
<h4 class="mt-0">1. install ngit and git-remote-nostr</h4>
<InstallNgit size="sm" />
</div>
</div>
</li>
<li class="m-0 p-0">
<!-- eslint-disable-next-line svelte/valid-compile -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
on:click={async () => {
try {
await navigator.clipboard.writeText(nostr_url)
nostr_url_copied = true
setTimeout(() => {
nostr_url_copied = false
}, 2000)
} catch {}
}}
class="group cursor-pointer rounded-md"
>
<div>
<h4 class="mt-0 pt-0">
2. copy git clone url
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="ml-1 inline h-4 w-4 flex-none fill-base-content opacity-50 group-hover:opacity-100"
class:fill-base-content={!nostr_url_copied}
class:fill-success={nostr_url_copied}
>
{#each icons_misc.copy as d}
<path {d} />
{/each}
</svg>
{#if nostr_url_copied}<span
class="text-sm text-success opacity-50"
>
(copied to clipboard)</span
>{/if}
</h4>
<p class="my-2 break-words border p-2 text-xs">{nostr_url}</p>
</div>
</div>
</li>
</ul>
</div>
{/if}
{#if loading}
<div class="skeleton my-3 h-5 w-20"></div>
<div class="skeleton my-2 h-4"></div>
<div class="skeleton my-2 mb-3 h-4 w-2/3"></div>
{:else if !short_descrption || description.length == 0}
<h4>description</h4>
<div>none</div>
{:else}
<h4>description</h4>
<p class="my-2 break-words text-sm">{short_descrption}</p>
{/if}
<div>
{#if loading}
<div class="badge skeleton w-20"></div>
<div class="badge skeleton w-20"></div>
{:else}
{#each tags as tag}
<div class="badge badge-secondary mr-2">{tag}</div>
{/each}
{/if}
</div>
<div>
{#if loading}
<div class="skeleton my-3 h-5 w-20"></div>
<div class="badge skeleton my-2 block w-60"></div>
{:else if clone.length == 0}
<div />
{:else}
<h4>
git servers {#if git_url_copied}<span
class="text-sm text-success opacity-50"
>
(copied to clipboard)</span
>{/if}
</h4>
{#each clone as git_url}
<!-- eslint-disable-next-line svelte/valid-compile -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
on:click={async () => {
try {
await navigator.clipboard.writeText(git_url)
git_url_copied = git_url
setTimeout(() => {
git_url_copied = false
}, 2000)
} catch {}
}}
class="group my-2 mt-3 cursor-pointer break-words text-xs"
class:text-success={git_url_copied === git_url}
class:opacity-50={git_url_copied === git_url}
>
{git_url}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="ml-1 inline h-4 w-4 flex-none fill-base-content opacity-50"
class:group-hover:opacity-100={git_url_copied !== git_url}
class:fill-base-content={git_url_copied !== git_url}
class:fill-success={git_url_copied === git_url}
class:opacity-100={git_url_copied === git_url}
>
{#each icons_misc.copy as d}
<path {d} />
{/each}
</svg>
</div>
{/each}
{/if}
</div>
<div>
{#if loading}
<div class="skeleton my-3 h-5 w-20"></div>
<div class="badge skeleton my-2 block w-60"></div>
<div class="badge skeleton my-2 block w-40"></div>
{:else if web.length == 0}
<h4>websites</h4>
<div>none</div>
{:else}
<h4>websites</h4>
{#each web as site}
<a
href={site}
target="_blank"
class="link link-primary my-2 break-words text-sm"
>
{site}
</a>
{/each}
{/if}
</div>
{/if}
<div>
{#if loading}
<div class="skeleton my-3 h-5 w-20"></div>
<div class="badge skeleton my-2 block w-60"></div>
<div class="badge skeleton my-2 block w-40"></div>
{:else if maintainers.length == 0}
<div />
{:else}
<h4>
{#if event_not_found}author{:else}maintainers{/if}
{#if maintainer_copied}<span class="text-sm text-success opacity-50">
(copied to clipboard)</span
>{/if}
</h4>
{#each maintainers as maintainer}
<div class="my-2 mt-3 break-words text-xs">
<UserHeader user={maintainer} />
</div>
{/each}
{/if}
</div>
{#if !event_not_found}
<div>
{#if loading}
<div class="skeleton my-3 h-5 w-20"></div>
<div class="badge skeleton my-2 block w-60"></div>
<div class="badge skeleton my-2 block w-40"></div>
{:else if relays.length == 0}
<h4>relays</h4>
<div>none</div>
{:else}
<h4>relays</h4>
{#each relays as relay}
<div class="badge badge-secondary badge-sm my-2 block">{relay}</div>
{/each}
{/if}
</div>
{#if loading}
<div class="skeleton my-3 h-5 w-20"></div>
<div class="skeleton my-2 h-4"></div>
<div class="skeleton my-2 mb-3 h-4 w-2/3"></div>
{:else if !unique_commit || unique_commit.length == 0}
<h4>earliest unique commit</h4>
<p class="my-2 break-words text-xs">not specified</p>
{:else}
<h4>earliest unique commit</h4>
<p class="my-2 break-words text-xs">{unique_commit}</p>
{/if}
{/if}
{#if loading}
<div class="skeleton my-3 h-5 w-20"></div>
<div class="skeleton my-2 h-4"></div>
<div class="skeleton my-2 mb-3 h-4 w-2/3"></div>
{:else if naddr && naddr.length > 0}
<!-- eslint-disable-next-line svelte/valid-compile -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
on:click={async () => {
try {
await navigator.clipboard.writeText(naddr)
naddr_copied = true
setTimeout(() => {
naddr_copied = false
}, 2000)
} catch {}
}}
class="group -ml-3 mt-3 cursor-pointer rounded-md p-3 hover:bg-base-300"
>
<h4 class="mt-0 pt-0">
naddr
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="ml-1 inline h-4 w-4 flex-none fill-base-content opacity-50 group-hover:opacity-100"
class:fill-base-content={!naddr_copied}
class:fill-success={naddr_copied}
>
{#each icons_misc.copy as d}
<path {d} />
{/each}
</svg>
{#if naddr_copied}<span class="text-sm text-success opacity-50">
(copied to clipboard)</span
>{/if}
</h4>
<p class="my-2 break-words text-xs">{naddr}</p>
</div>
{/if}
{#if event_not_found}
<div class="text-xs">
<AlertWarning>
<div class="pb-1 font-semibold">missing repository details</div>
<div>cannot find referenced repository event</div>
</AlertWarning>
</div>
{/if}
</div>

24
src/lib/components/repo/RepoHeader.stories.svelte

@ -1,24 +0,0 @@
<script lang="ts" context="module">
import type { Meta } from '@storybook/svelte'
import RepoHeader from './RepoHeader.svelte'
import { Story, Template } from '@storybook/addon-svelte-csf'
import { RepoDetailsArgsVectors as vectors } from './vectors'
export const meta: Meta<RepoHeader> = {
title: 'Repo/Header',
component: RepoHeader,
tags: ['autodocs'],
}
</script>
<Template let:args>
<RepoHeader {...args} />
</Template>
<Story name="Short Name" args={vectors.NoMaintainers} />
<Story name="Long Name" args={vectors.Long} />
<Story name="No Name" args={vectors.NoNameOrDescription} />
<Story name="loading" args={{ loading: true }} />

61
src/lib/components/repo/RepoHeader.svelte

@ -1,61 +0,0 @@
<script lang="ts">
import RepoMenu from '$lib/wrappers/RepoMenu.svelte'
import UserHeader from '$lib/components/users/UserHeader.svelte'
import Container from '../Container.svelte'
import { event_defaults, type RepoPage } from './type'
export let {
event_id,
identifier,
naddr,
unique_commit,
name,
author,
description,
clone,
web,
tags,
maintainers,
relays,
referenced_by,
created_at,
most_recent_reference_timestamp,
loading,
} = event_defaults
export let selected_tab: RepoPage = 'about'
let short_name: string
$: {
if (name && name.length > 45) short_name = name.slice(0, 45) + '...'
else if (name && name.length >= 0) short_name = name
else if (identifier && identifier.length > 45)
short_name = identifier.slice(0, 45) + '...'
else if (identifier && identifier.length >= 0) short_name = identifier
else short_name = 'Untitled'
}
</script>
<div class="border-b border-accent-content bg-base-300">
<Container no_wrap={true}>
{#if loading}
<div class="p-3">
<div class="skeleton h-6 w-28 bg-base-200"></div>
</div>
{:else}
<a
href={`/r/${naddr}`}
class="strong btn btn-ghost mb-0 mt-0 break-words px-3 text-sm"
>{short_name}</a
>
{#if created_at === 0 && name.length === 0}
<span class="text-xs text-warning">
cannot find referenced repository event by <div
class="badge bg-base-400 text-warning"
>
<UserHeader user={author} inline size="xs" />
</div>
</span>
{/if}
{/if}
<RepoMenu {selected_tab} />
</Container>
</div>

129
src/lib/components/repo/type.ts

@ -1,129 +0,0 @@
import {
defaults as user_defaults,
type User,
type UserObject,
} from '../users/type'
export interface RepoEventBase {
event_id: string
naddr: string
author: string // pubkey
identifier: string
unique_commit: string | undefined
name: string
description: string
clone: string[]
web: string[]
tags: string[]
maintainers: string | User[]
relays: string[]
referenced_by: string[]
// this is unreliable as relays dont return youngest first
most_recent_reference_timestamp: number
created_at: number
loading: boolean
}
export interface RepoEvent extends RepoEventBase {
maintainers: string[]
}
export interface RepoEventWithMaintainersMetadata extends RepoEventBase {
maintainers: UserObject[]
}
export const event_defaults: RepoEvent = {
event_id: '',
naddr: '',
author: '',
identifier: '',
unique_commit: '',
name: '',
description: '',
clone: [],
web: [],
tags: [],
maintainers: [],
relays: [],
referenced_by: [],
most_recent_reference_timestamp: 0,
created_at: 0,
loading: true,
}
export interface RepoCollectionBase {
selected_a: string // <kind>:<pubkeyhex>:<identifier>
most_recent_index: number
maintainers: string | User[]
events: RepoEvent[]
loading: boolean
}
export interface RepoCollection extends RepoCollectionBase {
maintainers: string[]
}
export interface RepoCollectionWithMaintainersMetadata
extends RepoCollectionBase {
maintainers: UserObject[]
}
export const collection_defaults: RepoCollection = {
selected_a: '',
most_recent_index: -1,
maintainers: [],
events: [],
loading: true,
}
export interface RepoSummary {
name: string
description: string
identifier: string
naddr: string
unique_commit: string | undefined
maintainers: User[]
loading?: boolean
created_at: number
most_recent_reference_timestamp: number
}
export const summary_defaults: RepoSummary = {
name: '',
identifier: '',
naddr: '',
unique_commit: undefined,
description: '',
maintainers: [{ ...user_defaults }],
loading: false,
created_at: 0,
most_recent_reference_timestamp: 0,
}
export interface SelectedPubkeyRepoCollections {
pubkey: string
collections: RepoCollection[]
}
export interface RepoDIdentiferCollection {
d: string
events: RepoEvent[]
loading: boolean
}
export interface RepoRecentCollection {
events: RepoEvent[]
loading: boolean
}
export type RepoPage = 'about' | 'issues' | 'proposals'
export interface RepoReadme {
md: string
loading: boolean
failed: boolean
}
export const readme_defaults: RepoReadme = {
md: '',
loading: true,
failed: false,
}

100
src/lib/components/repo/utils.spec.ts

@ -1,100 +0,0 @@
import { describe, expect, test } from 'vitest'
import { cloneArrayToReadMeUrls } from './utils'
describe('cloneArrayToReadMeUrls', () => {
test('for each clone url returns url to /raw/HEAD/README.md and /raw/HEAD/readme.md', () => {
expect(
cloneArrayToReadMeUrls([
'https://gitea.com/orgname/reponame',
'https://gitlab.com/orgname/reponame',
])
).toEqual([
'https://gitea.com/orgname/reponame/raw/HEAD/README.md',
'https://gitea.com/orgname/reponame/raw/HEAD/readme.md',
'https://gitlab.com/orgname/reponame/raw/HEAD/README.md',
'https://gitlab.com/orgname/reponame/raw/HEAD/readme.md',
])
})
test('for github link use raw.githubusercontent.com/HEAD', () => {
expect(
cloneArrayToReadMeUrls(['https://github.com/orgname/reponame'])
).toEqual([
'https://raw.githubusercontent.com/orgname/reponame/HEAD/README.md',
'https://raw.githubusercontent.com/orgname/reponame/HEAD/readme.md',
])
})
test('for sr.hr link to /blob/HEAD', () => {
expect(cloneArrayToReadMeUrls(['https://sr.ht/~orgname/reponame'])).toEqual(
[
'https://sr.ht/~orgname/reponame/blob/HEAD/README.md',
'https://sr.ht/~orgname/reponame/blob/HEAD/readme.md',
]
)
})
test('for git.launchpad.net link to /plain', () => {
expect(
cloneArrayToReadMeUrls(['https://git.launchpad.net/orgname/reponame'])
).toEqual([
'https://git.launchpad.net/orgname/reponame/plain/README.md',
'https://git.launchpad.net/orgname/reponame/plain/readme.md',
])
})
test('for git.savannah.gnu.org link to /plain', () => {
expect(
cloneArrayToReadMeUrls(['https://git.savannah.gnu.org/orgname/reponame'])
).toEqual([
'https://git.savannah.gnu.org/orgname/reponame/plain/README.md',
'https://git.savannah.gnu.org/orgname/reponame/plain/readme.md',
])
})
describe('transform clone address to url', () => {
test('strips trailing / from address', () => {
expect(
cloneArrayToReadMeUrls(['https://codeberg.org/orgname/reponame/'])
).toEqual([
'https://codeberg.org/orgname/reponame/raw/HEAD/README.md',
'https://codeberg.org/orgname/reponame/raw/HEAD/readme.md',
])
})
test('strips .git from address', () => {
expect(
cloneArrayToReadMeUrls(['https://codeberg.org/orgname/reponame.git'])
).toEqual([
'https://codeberg.org/orgname/reponame/raw/HEAD/README.md',
'https://codeberg.org/orgname/reponame/raw/HEAD/readme.md',
])
})
test('git@codeberg.org:orgname/reponame.git to address', () => {
expect(
cloneArrayToReadMeUrls(['git@codeberg.org:orgname/reponame.git'])
).toEqual([
'https://codeberg.org/orgname/reponame/raw/HEAD/README.md',
'https://codeberg.org/orgname/reponame/raw/HEAD/readme.md',
])
})
test('ssh://codeberg.org/orgname/reponame to address', () => {
expect(
cloneArrayToReadMeUrls(['ssh://codeberg.org/orgname/reponame'])
).toEqual([
'https://codeberg.org/orgname/reponame/raw/HEAD/README.md',
'https://codeberg.org/orgname/reponame/raw/HEAD/readme.md',
])
})
test('strips port eg ssh://git@git.v0l.io:2222/Kieran/snort.git to address', () => {
expect(
cloneArrayToReadMeUrls(['ssh://git@git.v0l.io:2222/Kieran/snort.git'])
).toEqual([
'https://git.v0l.io/Kieran/snort/raw/HEAD/README.md',
'https://git.v0l.io/Kieran/snort/raw/HEAD/readme.md',
])
})
test('https://custom.com/deep/deeper/deeper to address', () => {
expect(
cloneArrayToReadMeUrls(['https://custom.com/deep/deeper/deeper'])
).toEqual([
'https://custom.com/deep/deeper/deeper/raw/HEAD/README.md',
'https://custom.com/deep/deeper/deeper/raw/HEAD/readme.md',
])
})
})
})

135
src/lib/components/repo/utils.ts

@ -1,135 +0,0 @@
import type { RepoCollection, RepoEvent } from './type'
import { nip19 } from 'nostr-tools'
import { repo_kind } from '$lib/kinds'
import type { NDKEvent } from '@nostr-dev-kit/ndk'
import type { AddressPointer } from 'nostr-tools/nip19'
export const selectRepoFromCollection = (
collection: RepoCollection
): RepoEvent | undefined => {
return collection.events[collection.most_recent_index]
}
/** most servers will produce a CORS error so a proxy should be used */
export const cloneArrayToReadMeUrls = (clone: string[]): string[] => {
const addresses = clone.map(extractRepoAddress)
/**
* at the time of this commit these urls work for:
* self-hosted gitea (or forgejo), gitlab
* github.com
* bitbucket.org
* gitlab.org
* gitea.com
* codeberg.org (forgejo instance)
* sourcehut (git.sr.ht)
* launchpad.net
* It doesnt work for:
* self-hosted gogs (requires branch name repo/raw/master/README.md)
* sourceforge.net (https://sourceforge.net/p/mingw/catgets/ci/master/tree/README?format=raw)
* notabug.org (requires branch name notabug.org/org/repo/raw/master/README.md)
*/
return [
...addresses.flatMap((address) => {
let prefix = 'raw/HEAD'
if (address.includes('sr.ht')) prefix = 'blob/HEAD'
if (
address.includes('git.launchpad.net') ||
address.includes('git.savannah.gnu.org')
)
prefix = 'plain'
if (address.includes('github.com')) {
// raw.githubusercontent.com can be used without CORS error
address = address.replace('github.com', 'raw.githubusercontent.com')
prefix = 'HEAD'
}
return ['README.md', 'readme.md'].map(
(filename) => `https://${address}/${prefix}/${filename}`
)
}),
]
}
const extractRepoAddress = (clone_string: string): string => {
let s = clone_string
// remove trailing slash
if (s.endsWith('/')) s = s.substring(0, s.length - 1)
// remove trailing .git
if (s.endsWith('.git')) s = s.substring(0, s.length - 4)
// remove :// and anything before
if (s.includes('://')) s = s.split('://')[1]
// remove @ and anything before
if (s.includes('@')) s = s.split('@')[1]
// replace : with /
s = s.replace(/\s|:[0-9]+/g, '')
s = s.replace(':', '/')
return s
}
export const naddrToPointer = (s: string): AddressPointer | undefined => {
const decoded = nip19.decode(s)
if (
typeof decoded.data === 'string' ||
!Object.keys(decoded.data).includes('identifier')
)
return undefined
return decoded.data as AddressPointer
}
export const extractAReference = (a: string): AddressPointer | undefined => {
if (a.split(':').length !== 3) return undefined
const [k, pubkey, identifier] = a.split(':')
return { kind: Number(k), pubkey, identifier }
}
export const naddrToRepoA = (s: string): string | undefined => {
const pointer = naddrToPointer(s)
if (pointer && pointer.kind === repo_kind)
return `${repo_kind}:${pointer.pubkey}:${pointer.identifier}`
return undefined
}
export const aToNaddr = (
a: string | AddressPointer
): `naddr1${string}` | undefined => {
const a_ref = typeof a === 'string' ? extractAReference(a) : a
if (!a_ref) return undefined
return nip19.naddrEncode(a_ref)
}
export const neventOrNoteToHexId = (s: string): string | undefined => {
try {
const decoded = nip19.decode(s)
if (decoded.type === 'note') return decoded.data
else if (decoded.type === 'nevent') return decoded.data.id
} catch {}
return undefined
}
/** this functoin can be removed when ndk.encode includes kind in nevent */
export const ndkEventToNeventOrNaddr = (
event: NDKEvent
): string | undefined => {
let relays: string[] = []
if (event.onRelays.length > 0) {
relays = event.onRelays.map((relay) => relay.url)
} else if (event.relay) {
relays = [event.relay.url]
}
if (event.kind && event.isParamReplaceable()) {
return nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: event.replaceableDTag(),
relays,
})
} else if (relays.length > 0) {
return nip19.neventEncode({
kind: event.kind,
id: event.tagId(),
relays,
author: event.pubkey,
})
} else {
return nip19.noteEncode(event.tagId())
}
}

127
src/lib/components/repo/vectors.ts

@ -1,127 +0,0 @@
import { UserVectors, withName } from '../users/vectors'
import type { RepoEventWithMaintainersMetadata, RepoSummary } from './type'
export const RepoSummaryCardArgsVectors = {
Short: {
name: 'Short Name',
description: 'short description',
maintainers: [withName(UserVectors.default, 'Will')],
} as RepoSummary,
Long: {
name: 'Long Name that goes on and on and on and on and on and on and on and on and on',
description:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis quis nisl eget turpis congue molestie. Nulla vitae purus nec augue accumsan facilisis sed sed ligula. Vestibulum sed risus lacinia risus lacinia molestie. Ut lorem quam, consequat eget tempus in, rhoncus vel nunc. Duis efficitur a leo vel sodales. Nam id fermentum lacus. Etiam nec placerat velit. Praesent ac consectetur est. Aenean iaculis commodo enim.',
maintainers: [withName(UserVectors.default, 'Rather Long Display Name')],
} as RepoSummary,
LongNoSpaces: {
name: 'LongNameLongNameLongNameLongNameLongNameLongNameLongNameLongName',
description:
'LoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsum>',
maintainers: [
{
...UserVectors.default,
},
],
} as RepoSummary,
MulipleMaintainers: {
name: 'Short Name',
description: 'short description',
maintainers: [
withName(UserVectors.default, 'Will'),
withName(UserVectors.default, 'DanConwayDev'),
withName(UserVectors.default, 'sectore'),
],
} as RepoSummary,
}
const base: RepoEventWithMaintainersMetadata = {
identifier: '9ee507fc4357d7ee16a5d8901bedcd103f23c17d',
unique_commit: '9ee507fc4357d7ee16a5d8901bedcd103f23c17d',
author: '',
name: 'Short Name',
description: 'short description',
clone: ['github.com/example/example'],
tags: ['svelte', 'nostr', 'code-collaboration', 'git'],
relays: ['relay.damus.io', 'relay.snort.social', 'relayable.org'],
maintainers: [
withName(UserVectors.default, 'carole'),
withName(UserVectors.default, 'bob'),
withName(UserVectors.default, 'steve'),
],
loading: false,
event_id: '',
naddr: '',
web: ['https://gitcitadel.eu/repo/example', 'https://example.com'],
referenced_by: [],
most_recent_reference_timestamp: 0,
created_at: 0,
}
export const RepoDetailsArgsVectors = {
Short: { ...base } as RepoEventWithMaintainersMetadata,
Long: {
...base,
name: 'Long Name that goes on and on and on and on and on and on and on and on and on',
description:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis quis nisl eget turpis congue molestie. Nulla vitae purus nec augue accumsan facilisis sed sed ligula. Vestibulum sed risus lacinia risus lacinia molestie. Ut lorem quam, consequat eget tempus in, rhoncus vel nunc. Duis efficitur a leo vel sodales. Nam id fermentum lacus. Etiam nec placerat velit. Praesent ac consectetur est. Aenean iaculis commodo enim.\n Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis quis nisl eget turpis congue molestie.',
} as RepoEventWithMaintainersMetadata,
LongNoSpaces: {
...base,
name: 'LongNameLongNameLongNameLongNameLongNameLongNameLongNameLongName',
description:
'LoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsumLoremipsum',
} as RepoEventWithMaintainersMetadata,
NoNameOrDescription: {
...base,
name: '',
description: '',
} as RepoEventWithMaintainersMetadata,
NoDescription: {
...base,
description: '',
} as RepoEventWithMaintainersMetadata,
NoTags: { ...base, tags: [] } as RepoEventWithMaintainersMetadata,
NoGitServer: { ...base, clone: [''] } as RepoEventWithMaintainersMetadata,
NoWeb: { ...base, web: [] } as RepoEventWithMaintainersMetadata,
MaintainersOneProfileNotLoaded: {
...base,
maintainers: [
{ ...base.maintainers[0] },
{ ...UserVectors.loading },
{ ...base.maintainers[2] },
],
} as RepoEventWithMaintainersMetadata,
MaintainersOneProfileDisplayNameWithoutName: {
...base,
maintainers: [
{ ...base.maintainers[0] },
{ ...UserVectors.display_name_only },
{ ...base.maintainers[2] },
],
} as RepoEventWithMaintainersMetadata,
MaintainersOneProfileNameAndDisplayNamePresent: {
...base,
maintainers: [
{ ...base.maintainers[0] },
{ ...UserVectors.display_name_and_name },
{ ...base.maintainers[2] },
],
} as RepoEventWithMaintainersMetadata,
MaintainersOneProfileNoNameOrDisplayNameBeingPresent: {
...base,
maintainers: [
{ ...base.maintainers[0] },
{ ...UserVectors.no_profile },
{ ...base.maintainers[2] },
],
} as RepoEventWithMaintainersMetadata,
NoMaintainers: {
...base,
maintainers: [],
} as RepoEventWithMaintainersMetadata,
NoRelays: { ...base, relays: [] } as RepoEventWithMaintainersMetadata,
NoMaintainersOrRelays: {
...base,
maintainers: [],
relays: [],
} as RepoEventWithMaintainersMetadata,
}

141
src/lib/components/users/UserHeader.stories.svelte

@ -1,141 +0,0 @@
<script lang="ts" context="module">
import type { Meta } from '@storybook/svelte'
import UserHeader from '$lib/components/users/UserHeader.svelte'
import { Story, Template } from '@storybook/addon-svelte-csf'
import { UserVectors as vectors } from './vectors'
export const meta: Meta<UserHeader> = {
title: 'Users/Header',
component: UserHeader,
tags: ['autodocs'],
}
</script>
<Template let:args>
<UserHeader {...args} />
</Template>
<Story
name="default"
args={{
user: {
...vectors.default,
},
}}
/>
<Story
name="small"
args={{
user: {
...vectors.default,
},
size: 'sm',
}}
/>
<Story
name="extra small"
args={{
user: {
...vectors.default,
},
size: 'xs',
}}
/>
<Story
name="no image"
args={{
user: {
...vectors.no_image,
},
}}
/>
<Story
name="long name truncated"
args={{
user: {
...vectors.long_name,
},
}}
/>
<Story name="loading" args={{ user: { ...vectors.loading } }} />
<Story name="not found" args={{ user: { ...vectors.no_profile } }} />
<Story
name="displayName without name"
args={{ user: { ...vectors.display_name_only } }}
/>
<Story
name="name and displayName shows name"
args={{ user: { ...vectors.display_name_and_name } }}
/>
<Story
name="inline"
args={{
user: {
...vectors.default,
},
inline: true,
}}
/>
<Story
name="inline loading"
args={{
user: {
...vectors.loading,
},
inline: true,
}}
/>
<Story
name="inline small"
args={{
user: {
...vectors.default,
},
inline: true,
size: 'sm',
}}
/>
<Story
name="inline small loading"
args={{
user: {
...vectors.loading,
},
inline: true,
size: 'sm',
}}
/>
<Story
name="inline extra small"
args={{
user: {
...vectors.default,
},
inline: true,
size: 'xs',
}}
/>
<Story
name="inline extra small loading"
args={{
user: {
...vectors.loading,
},
inline: true,
size: 'xs',
}}
/>

166
src/lib/components/users/UserHeader.svelte

@ -1,166 +0,0 @@
<script lang="ts">
import { ensureUser } from '$lib/stores/users'
import type { Unsubscriber } from 'svelte/store'
import { defaults, getName, type User, type UserObject } from './type'
import { onDestroy } from 'svelte'
import { goto } from '$app/navigation'
import ParsedContent from '../events/content/ParsedContent.svelte'
import CopyField from '../CopyField.svelte'
import { icons_misc } from '../icons'
export let user: User = {
...defaults,
}
export let inline = false
export let size: 'xs' | 'sm' | 'md' | 'full' = 'md'
export let avatar_only = false
export let in_event_header = false
export let link_to_profile = true
let user_object: UserObject = {
...defaults,
}
let unsubscriber: Unsubscriber
$: {
if (typeof user === 'string') {
if (unsubscriber) unsubscriber()
unsubscriber = ensureUser(user).subscribe((u) => {
user_object = { ...u }
})
} else user_object = user
}
onDestroy(() => {
if (unsubscriber) unsubscriber()
})
$: ({ profile, loading } = user_object)
$: display_name = getName(user_object)
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class:inline-block={inline}
class:cursor-pointer={link_to_profile}
on:click={() => {
if (link_to_profile) goto(`/p/${user_object.npub}`)
}}
>
<div
class:my-2={!inline}
class:text-xs={size === 'xs'}
class:text-sm={size === 'sm'}
class:text-md={size === 'md'}
class:align-middle={inline}
class:flex={!inline}
class:items-center={!inline}
>
<div
class="avatar"
class:inline-block={inline}
class:align-middle={inline}
class:flex-none={!inline}
>
<div
class:inline-block={inline}
class:h-32={!inline && size === 'full'}
class:w-32={!inline && size === 'full'}
class:h-8={!inline && size === 'md'}
class:w-8={!inline && size === 'md'}
class:h-4={!inline && size === 'sm'}
class:w-4={!inline && size === 'sm'}
class:h-5={inline && size === 'md'}
class:w-5={inline && size === 'md'}
class:h-3.5={(inline && size === 'sm') || size === 'xs'}
class:w-3.5={(inline && size === 'sm') || size === 'xs'}
class="rounded"
class:skeleton={!profile && loading}
class:bg-neutral={!loading && (!profile || !profile.image)}
>
{#if profile && profile?.image}
<img class="my-0" src={profile?.image} alt={display_name} />
{/if}
</div>
</div>
<div
class:text-xl={size === 'full'}
class:width-max-prose={size === 'full'}
class:pl-4={!inline && size === 'full'}
class:pl-3={!inline && size === 'md'}
class:pl-2={!inline && (size === 'sm' || size === 'xs')}
class:pl-0={inline}
class:flex-auto={!inline}
class:m-auto={!inline}
class:inline-block={inline}
class:hidden={avatar_only}
class:opacity-40={in_event_header}
>
{#if loading}
<div
class="skeleton w-24"
class:h-4={size === 'md'}
class:h-3={size === 'sm'}
class:h-2.5={size === 'xs'}
></div>
{:else}
<span class:font-bold={in_event_header || size === 'full'}
>{display_name}</span
>
{/if}
{#if size === 'full'}
<CopyField
icon={icons_misc.key}
content={user_object.npub}
no_border
truncate={[10, 10]}
/>
{#if profile && profile.lud16}
<CopyField
icon={icons_misc.lightning}
content={profile.lud16}
no_border
/>
{/if}
{#if profile && profile.website}
<a
href={profile.website}
target="_blank"
class="items items-top mt-1 flex w-full opacity-60"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="mr-1 inline h-4 w-4 flex-none fill-base-content opacity-50"
>
{#each icons_misc.link as d}
<path {d} />
{/each}
</svg>
<div class="link-secondary text-sm">{profile.website}</div>
</a>
{/if}
{#if size === 'full' && profile && profile.about}
<div class="items items-top flex max-w-md opacity-60">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="mr-1 mt-1 inline h-4 w-4 flex-none fill-base-content opacity-50"
>
{#each icons_misc.info as d}
<path {d} />
{/each}
</svg>
{#if loading}
<div class="w.max-lg skeleton h-3"></div>
{:else}
<div class="text-sm">
<ParsedContent content={profile?.about} />
</div>
{/if}
</div>
{/if}
{/if}
</div>
</div>
</div>

38
src/lib/components/users/type.ts

@ -1,38 +0,0 @@
import type { NDKUserProfile } from '@nostr-dev-kit/ndk'
export interface UserObject {
loading: boolean
hexpubkey: string
npub: string
profile?: NDKUserProfile
}
export const defaults: UserObject = {
loading: true,
hexpubkey: '',
npub: '',
}
export type User = UserObject | string
export function getName(user: UserObject, truncate_above = 25): string {
return truncate(
user.profile
? user.profile.name
? user.profile.name
: user.profile.displayName
? user.profile.displayName
: truncateNpub(user.npub)
: truncateNpub(user.npub),
truncate_above
)
}
function truncateNpub(npub: string): string {
return `${npub.substring(0, 9)}...`
}
function truncate(s: string, truncate_above = 20): string {
if (s.length < truncate_above || truncate_above < 5) return s
return `${s.substring(0, truncate_above - 3)}...`
}

39
src/lib/components/users/vectors.ts

@ -1,39 +0,0 @@
import type { UserObject } from './type'
// nsec1rg53qfv09az39dlw6j64ange3cx8sh5p8np29qcxtythplvplktsv93tnr
const base: UserObject = {
hexpubkey: '3eb45c6f15752d796fa5465d0530a5a5feb79fb6f08c0a4176be9d73cc28c40d',
npub: 'npub18669cmc4w5khjma9gews2v995hlt08ak7zxq5stkh6wh8npgcsxslt2xjn',
loading: false,
}
const image = '../test-profile-image.jpg'
export const UserVectors = {
loading: { ...base, loading: true } as UserObject,
default: { ...base, profile: { name: 'DanConwayDev', image } } as UserObject,
display_name_only: {
...base,
profile: { displayName: 'DanConwayDev', image },
} as UserObject,
display_name_and_name: {
...base,
profile: { name: 'Dan', displayName: 'DanConwayDev', image },
} as UserObject,
no_image: { ...base, profile: { name: 'DanConwayDev' } } as UserObject,
no_profile: { ...base } as UserObject,
long_name: {
...base,
profile: { name: 'Really Really Long Long Name', image },
} as UserObject,
}
export function withName(base: UserObject, name: string): UserObject {
return {
...base,
profile: {
...base.profile,
name,
},
} as UserObject
}

29
src/lib/kinds.ts

@ -1,29 +0,0 @@
export const reply_kind: number = 1
export const proposal_status_open: number = 1630
export const proposal_status_applied: number = 1631
export const proposal_status_closed: number = 1632
export const proposal_status_draft: number = 1633
export const proposal_status_kinds: number[] = [
proposal_status_open,
proposal_status_applied,
proposal_status_closed,
proposal_status_draft,
]
export function statusKindtoText(
kind: number,
type: 'proposal' | 'issue'
): string {
if (kind === proposal_status_open) return 'Open'
if (type === 'proposal' && kind === proposal_status_applied) return 'Applied'
if (type === 'issue' && kind === proposal_status_applied) return 'Resolved'
if (kind === proposal_status_closed) return 'Closed'
return 'Draft'
}
export const repo_kind: number = 30617
export const patch_kind: number = 1617
export const issue_kind: number = 1621

204
src/lib/stores/Issue.ts

@ -1,204 +0,0 @@
import { NDKRelaySet, NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'
import { writable, type Writable } from 'svelte/store'
import { base_relays, ndk } from './ndk'
import { type IssueFull, full_defaults } from '$lib/components/issues/type'
import { proposal_status_kinds, proposal_status_open } from '$lib/kinds'
import { awaitSelectedRepoCollection } from './repo'
import {
extractIssueDescription,
extractIssueTitle,
} from '$lib/components/events/content/utils'
import { selectRepoFromCollection } from '$lib/components/repo/utils'
import { ignore_kinds } from './utils'
export const selected_issue_full: Writable<IssueFull> = writable({
...full_defaults,
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let selected_issue_repo_a: string = ''
let selected_issue_id: string = ''
export const selected_issue_replies: Writable<NDKEvent[]> = writable([])
let selected_issue_status_date = 0
let sub: NDKSubscription
let sub_replies: NDKSubscription
const sub_replies_to_replies: NDKSubscription[] = []
export const ensureIssueFull = (
repo_a: string,
issue_id_or_event: string | NDKEvent
) => {
const issue_id =
typeof issue_id_or_event === 'string'
? issue_id_or_event
: issue_id_or_event.id
if (selected_issue_id == issue_id) return
if (issue_id == '') {
selected_issue_full.set({ ...full_defaults })
selected_issue_replies.set([])
return
}
if (sub) sub.stop()
if (sub_replies) sub_replies.stop()
sub_replies_to_replies.forEach((sub) => sub.stop())
selected_issue_repo_a = repo_a
selected_issue_id = issue_id
selected_issue_status_date = 0
selected_issue_replies.set([])
selected_issue_full.set({
...full_defaults,
summary: {
...full_defaults.summary,
id: issue_id,
repo_a,
loading: true,
},
loading: true,
})
new Promise(async (r) => {
const repo_collection = await awaitSelectedRepoCollection(repo_a)
const repo = selectRepoFromCollection(repo_collection)
const relays_to_use =
repo && repo.relays.length > 3
? repo.relays
: [...base_relays].concat(repo ? repo.relays : [])
const setEvent = (event: NDKEvent) => {
try {
selected_issue_full.update((full) => {
return {
...full,
issue_event: event,
summary: {
...full.summary,
title: extractIssueTitle(event),
descritpion: extractIssueDescription(event.content),
created_at: event.created_at,
comments: 0,
author: event.pubkey,
loading: false,
},
}
})
} catch {}
}
if (typeof issue_id_or_event !== 'string') {
setEvent(issue_id_or_event)
} else {
sub = ndk.subscribe(
{
ids: [issue_id],
limit: 100,
},
{
closeOnEose: false,
},
NDKRelaySet.fromRelayUrls(relays_to_use, ndk)
)
sub.on('event', (event: NDKEvent) => {
if (event.id == issue_id) setEvent(event)
})
sub.on('eose', () => {
selected_issue_full.update((full) => {
const updated = {
...full,
summary: {
...full.summary,
loading: false,
},
}
if (full.loading === false) {
r({ ...updated })
}
return updated
})
})
}
sub_replies = ndk.subscribe(
{
'#e': [issue_id],
},
{
closeOnEose: false,
},
NDKRelaySet.fromRelayUrls(relays_to_use, ndk)
)
const process_replies = (event: NDKEvent) => {
if (event.kind && ignore_kinds.includes(event.kind)) return false
if (
event.kind &&
proposal_status_kinds.includes(event.kind) &&
event.created_at &&
selected_issue_status_date < event.created_at
) {
selected_issue_status_date = event.created_at
selected_issue_full.update((full) => {
return {
...full,
summary: {
...full.summary,
status: event.kind,
// this wont be 0 as we are ensuring it is not undefined above
status_date: event.created_at || 0,
},
}
})
}
selected_issue_replies.update((replies) => {
if (!replies.some((e) => e.id === event.id)) {
const sub_replies_to_reply = ndk.subscribe(
{
'#e': [event.id],
},
{
groupable: true,
groupableDelay: 300,
closeOnEose: false,
},
NDKRelaySet.fromRelayUrls(relays_to_use, ndk)
)
sub_replies_to_reply.on('event', (event: NDKEvent) => {
process_replies(event)
})
sub_replies_to_replies.push(sub_replies_to_reply)
return [...replies, event]
}
return [...replies]
})
}
sub_replies.on('event', (event: NDKEvent) => {
process_replies(event)
})
sub_replies.on('eose', () => {
selected_issue_full.update((full) => {
const updated = {
...full,
summary: {
...full.summary,
status: full.summary.status || proposal_status_open,
},
loading: false,
}
if (full.summary.loading === false) {
r({ ...updated })
}
return updated
})
})
})
}

203
src/lib/stores/Issues.ts

@ -1,203 +0,0 @@
import {
NDKRelaySet,
type NDKEvent,
NDKSubscription,
type NDKFilter,
} from '@nostr-dev-kit/ndk'
import { writable, type Writable } from 'svelte/store'
import { base_relays, ndk } from './ndk'
import { awaitSelectedRepoCollection } from './repo'
import {
issue_kind,
proposal_status_kinds,
proposal_status_open,
repo_kind,
} from '$lib/kinds'
import {
extractIssueDescription,
extractIssueTitle,
} from '$lib/components/events/content/utils'
import { selectRepoFromCollection } from '$lib/components/repo/utils'
import {
summary_defaults,
type IssueSummaries,
} from '$lib/components/issues/type'
export const issue_summaries: Writable<IssueSummaries> = writable({
repo_a: '',
summaries: [],
loading: false,
})
let selected_repo_a: string | undefined = ''
let sub: NDKSubscription
export const ensureIssueSummaries = async (repo_a: string | undefined) => {
if (selected_repo_a == repo_a) return
issue_summaries.set({
repo_a,
summaries: [],
loading: repo_a !== '',
})
if (sub) sub.stop()
if (sub_statuses) sub_statuses.stop()
selected_repo_a = repo_a
setTimeout(() => {
issue_summaries.update((summaries) => {
return {
...summaries,
loading: false,
}
})
}, 6000)
let relays_to_use = [...base_relays]
let filter: NDKFilter = {
kinds: [issue_kind],
limit: 100,
}
if (repo_a) {
const repo_collection = await awaitSelectedRepoCollection(repo_a)
const repo = selectRepoFromCollection(repo_collection)
if (!repo) {
// TODO: display error info bar
return
}
relays_to_use =
repo.relays.length > 3
? repo.relays
: [...base_relays].concat(repo.relays)
filter = {
kinds: [issue_kind],
'#a': repo.maintainers.map((m) => `${repo_kind}:${m}:${repo.identifier}`),
limit: 100,
}
}
sub = ndk.subscribe(
filter,
{
closeOnEose: false,
},
NDKRelaySet.fromRelayUrls(relays_to_use, ndk)
)
sub.on('event', (event: NDKEvent) => {
try {
if (event.kind == issue_kind) {
if (!extractRepoIdentiferFromIssueEvent(event) && !repo_a) {
// link to issue will not work as it requires an identifier
return
}
issue_summaries.update((issues) => {
return {
...issues,
summaries: [
...issues.summaries,
{
...summary_defaults,
id: event.id,
repo_a:
extractRepoIdentiferFromIssueEvent(event) || repo_a || '',
title: extractIssueTitle(event),
descritpion: extractIssueDescription(event.content),
created_at: event.created_at,
comments: 0,
author: event.pubkey,
loading: false,
},
],
}
})
}
} catch {}
})
sub.on('eose', () => {
issue_summaries.update((issues) => {
getAndUpdateIssueStatus(issues, relays_to_use)
return {
...issues,
loading: false,
}
})
})
}
let sub_statuses: NDKSubscription
function getAndUpdateIssueStatus(
issues: IssueSummaries,
relays: string[]
): void {
if (sub_statuses) sub_statuses.stop()
sub_statuses = ndk.subscribe(
{
kinds: proposal_status_kinds,
'#e': issues.summaries.map((issue) => issue.id),
},
{
closeOnEose: false,
},
NDKRelaySet.fromRelayUrls(relays, ndk)
)
sub_statuses.on('event', (event: NDKEvent) => {
const tagged_issue_event = event.tagValue('e')
if (
event.kind &&
proposal_status_kinds.includes(event.kind) &&
tagged_issue_event &&
event.created_at
) {
issue_summaries.update((issues) => {
return {
...issues,
summaries: issues.summaries.map((o) => {
if (
o.id === tagged_issue_event &&
event.created_at &&
o.status_date < event.created_at
) {
return {
...o,
status: event.kind as number,
status_date: event.created_at,
}
}
return o
}),
}
})
}
})
sub_statuses.on('eose', () => {
issue_summaries.update((issues) => {
return {
...issues,
summaries: issues.summaries.map((o) => ({
...o,
status: o.status || proposal_status_open,
})),
}
})
})
}
export const extractRepoIdentiferFromIssueEvent = (
event: NDKEvent
): string | undefined => {
const value = event.tagValue('a')
if (!value) return undefined
const split = value.split(':')
if (split.length < 3) return undefined
return value
}

210
src/lib/stores/Proposal.ts

@ -1,210 +0,0 @@
import { NDKRelaySet, type NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'
import { writable, type Writable } from 'svelte/store'
import { base_relays, ndk } from './ndk'
import {
type ProposalFull,
full_defaults,
} from '$lib/components/proposals/type'
import { proposal_status_kinds, proposal_status_open } from '$lib/kinds'
import { awaitSelectedRepoCollection } from './repo'
import { extractPatchMessage } from '$lib/components/events/content/utils'
import { selectRepoFromCollection } from '$lib/components/repo/utils'
import { ignore_kinds } from './utils'
export const selected_proposal_full: Writable<ProposalFull> = writable({
...full_defaults,
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let selected_proposal_repo_a: string = ''
let selected_proposal_id: string = ''
export const selected_proposal_replies: Writable<NDKEvent[]> = writable([])
let selected_proposal_status_date = 0
let sub: NDKSubscription
let sub_replies: NDKSubscription
const sub_replies_to_replies: NDKSubscription[] = []
export const ensureProposalFull = (
repo_a: string,
proposal_id_or_event: string | NDKEvent
) => {
const proposal_id =
typeof proposal_id_or_event === 'string'
? proposal_id_or_event
: proposal_id_or_event.id
if (selected_proposal_id == proposal_id) return
if (proposal_id == '') {
selected_proposal_full.set({ ...full_defaults })
selected_proposal_replies.set([])
return
}
if (sub) sub.stop()
if (sub_replies) sub_replies.stop()
sub_replies_to_replies.forEach((sub) => sub.stop())
selected_proposal_repo_a = repo_a
selected_proposal_id = proposal_id
selected_proposal_status_date = 0
selected_proposal_replies.set([])
selected_proposal_full.set({
...full_defaults,
summary: {
...full_defaults.summary,
id: proposal_id,
repo_a,
loading: true,
},
loading: true,
})
new Promise(async (r) => {
const repo_collection = await awaitSelectedRepoCollection(repo_a)
const repo = selectRepoFromCollection(repo_collection)
const relays_to_use =
repo && repo.relays.length > 3
? repo.relays
: [...base_relays].concat(repo ? repo.relays : [])
const setEvent = (event: NDKEvent) => {
try {
selected_proposal_full.update((full) => {
return {
...full,
proposal_event: event,
summary: {
...full.summary,
title: (
event.tagValue('name') ||
event.tagValue('description') ||
extractPatchMessage(event.content) ||
''
).split('\n')[0],
descritpion: event.tagValue('description') || '',
created_at: event.created_at,
comments: 0,
author: event.pubkey,
loading: false,
},
}
})
} catch {}
}
if (typeof proposal_id_or_event !== 'string') {
setEvent(proposal_id_or_event)
} else {
sub = ndk.subscribe(
{
ids: [proposal_id],
limit: 100,
},
{
closeOnEose: false,
},
NDKRelaySet.fromRelayUrls(relays_to_use, ndk)
)
sub.on('event', (event: NDKEvent) => {
if (event.id == proposal_id) setEvent(event)
})
sub.on('eose', () => {
selected_proposal_full.update((full) => {
const updated = {
...full,
summary: {
...full.summary,
loading: false,
},
}
if (full.loading === false) {
r({ ...updated })
}
return updated
})
})
}
sub_replies = ndk.subscribe(
{
'#e': [proposal_id],
},
{
closeOnEose: false,
},
NDKRelaySet.fromRelayUrls(relays_to_use, ndk)
)
const process_replies = (event: NDKEvent) => {
if (event.kind && ignore_kinds.includes(event.kind)) return false
if (
event.kind &&
proposal_status_kinds.includes(event.kind) &&
event.created_at &&
selected_proposal_status_date < event.created_at
) {
selected_proposal_status_date = event.created_at
selected_proposal_full.update((full) => {
return {
...full,
summary: {
...full.summary,
status: event.kind,
// this wont be 0 as we are ensuring it is not undefined above
status_date: event.created_at || 0,
},
}
})
}
selected_proposal_replies.update((replies) => {
if (!replies.some((e) => e.id === event.id)) {
const sub_replies_to_reply = ndk.subscribe(
{
'#e': [event.id],
},
{
groupable: true,
groupableDelay: 300,
closeOnEose: false,
},
NDKRelaySet.fromRelayUrls(relays_to_use, ndk)
)
sub_replies_to_reply.on('event', (event: NDKEvent) => {
process_replies(event)
})
sub_replies_to_replies.push(sub_replies_to_reply)
return [...replies, event]
}
return [...replies]
})
}
sub_replies.on('event', (event: NDKEvent) => {
process_replies(event)
})
sub_replies.on('eose', () => {
selected_proposal_full.update((full) => {
const updated = {
...full,
summary: {
...full.summary,
status: full.summary.status || proposal_status_open,
},
loading: false,
}
if (full.summary.loading === false) {
r({ ...updated })
}
return updated
})
})
})
}

251
src/lib/stores/Proposals.ts

@ -1,251 +0,0 @@
import {
NDKRelaySet,
type NDKEvent,
NDKSubscription,
type NDKFilter,
} from '@nostr-dev-kit/ndk'
import { writable, type Writable } from 'svelte/store'
import { base_relays, ndk } from './ndk'
import { summary_defaults } from '$lib/components/proposals/type'
import type { ProposalSummaries } from '$lib/components/proposals/type'
import { awaitSelectedRepoCollection } from './repo'
import {
patch_kind,
proposal_status_kinds,
proposal_status_open,
repo_kind,
} from '$lib/kinds'
import { extractPatchMessage } from '$lib/components/events/content/utils'
import { selectRepoFromCollection } from '$lib/components/repo/utils'
import { returnRepoCollection } from './repos'
export const proposal_summaries: Writable<ProposalSummaries> = writable({
repo_a: '',
summaries: [],
loading: false,
})
let selected_a: string | undefined = ''
let sub: NDKSubscription
export const ensureProposalSummaries = async (repo_a: string | undefined) => {
if (selected_a == repo_a) return
proposal_summaries.set({
repo_a,
summaries: [],
loading: repo_a !== '',
})
if (sub) sub.stop()
if (sub_statuses) sub_statuses.stop()
selected_a = repo_a
setTimeout(() => {
proposal_summaries.update((summaries) => {
return {
...summaries,
loading: false,
}
})
}, 6000)
let relays_to_use = [...base_relays]
let filter: NDKFilter = {
kinds: [patch_kind],
limit: 100,
}
if (repo_a) {
const repo_collection = await awaitSelectedRepoCollection(repo_a)
const repo = selectRepoFromCollection(repo_collection)
if (!repo) {
// TODO: display error info bar
return
}
relays_to_use =
repo.relays.length > 3
? repo.relays
: [...base_relays].concat(repo.relays)
const without_root_tag = !repo.unique_commit
if (without_root_tag) {
filter = {
kinds: [patch_kind],
'#a': repo.maintainers.map(
(m) => `${repo_kind}:${m}:${repo.identifier}`
),
limit: 100,
}
} else {
filter = {
kinds: [patch_kind],
'#a': repo.maintainers.map(
(m) => `${repo_kind}:${m}:${repo.identifier}`
),
'#t': ['root'],
limit: 100,
}
}
}
sub = ndk.subscribe(
filter,
{
closeOnEose: false,
},
NDKRelaySet.fromRelayUrls(relays_to_use, ndk)
)
sub.on('event', async (event: NDKEvent) => {
try {
if (
event.kind == patch_kind &&
event.content.length > 0 &&
!event.tags.some((t) => t.length > 1 && t[1] === 'revision-root')
) {
if (!extractRepoAFromProposalEvent(event) && !repo_a) {
// link to proposal will not work as it requires an identifier
return
}
proposal_summaries.update((proposals) => {
return {
...proposals,
summaries: [
...proposals.summaries,
{
...summary_defaults,
id: event.id,
repo_a: extractRepoAFromProposalEvent(event) || repo_a || '',
title: (
event.tagValue('name') ||
event.tagValue('description') ||
extractPatchMessage(event.content) ||
''
).split('\n')[0],
descritpion: event.tagValue('description') || '',
created_at: event.created_at,
comments: 0,
author: event.pubkey,
loading: false,
},
],
}
})
// filter out non root proposals if repo event supports nip34+ features
if (repo_a && repo_a.length > 0) {
const repo_collection = await returnRepoCollection(repo_a)
if (
selected_a === repo_a &&
repo_collection.events[repo_collection.most_recent_index]
.unique_commit
) {
proposal_summaries.update((proposals) => {
return {
...proposals,
summaries: [
...proposals.summaries.filter(
(summary) =>
(event.tags.some(
(t) => t.length > 1 && t[1] === 'root'
) &&
!event.tags.some(
(t) => t.length > 1 && t[1] === 'revision-root'
)) ||
event.id !== summary.id
),
],
}
})
}
}
}
} catch {}
})
sub.on('eose', () => {
proposal_summaries.update((proposals) => {
getAndUpdateProposalStatus(proposals, relays_to_use)
return {
...proposals,
loading: false,
}
})
})
}
let sub_statuses: NDKSubscription
function getAndUpdateProposalStatus(
proposals: ProposalSummaries,
relays: string[]
): void {
if (sub_statuses) sub_statuses.stop()
sub_statuses = ndk.subscribe(
{
kinds: proposal_status_kinds,
'#e': proposals.summaries.map((proposal) => proposal.id),
},
{
closeOnEose: false,
},
NDKRelaySet.fromRelayUrls(relays, ndk)
)
sub_statuses.on('event', (event: NDKEvent) => {
const tagged_proposal_event = event.tagValue('e')
if (
event.kind &&
proposal_status_kinds.includes(event.kind) &&
tagged_proposal_event &&
event.created_at
) {
proposal_summaries.update((proposals) => {
return {
...proposals,
summaries: proposals.summaries.map((o) => {
if (
o.id === tagged_proposal_event &&
event.created_at &&
o.status_date < event.created_at
) {
return {
...o,
status: event.kind as number,
status_date: event.created_at,
}
}
return o
}),
}
})
}
})
sub_statuses.on('eose', () => {
proposal_summaries.update((proposals) => {
return {
...proposals,
summaries: proposals.summaries.map((o) => ({
...o,
status: o.status || proposal_status_open,
})),
}
})
})
}
export const extractRepoAFromProposalEvent = (
event: NDKEvent
): string | undefined => {
const value = event.tagValue('a')
if (!value) return undefined
const split = value.split(':')
if (split.length < 3) return undefined
return value
}

56
src/lib/stores/ReposIdentifier.ts

@ -1,56 +0,0 @@
import type { RepoDIdentiferCollection } from '$lib/components/repo/type'
import { writable, type Writable } from 'svelte/store'
import { ensureRepo, eventToRepoEvent } from './repos'
import { base_relays, ndk } from './ndk'
import { repo_kind } from '$lib/kinds'
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'
export const repos_identifer: {
[d: string]: Writable<RepoDIdentiferCollection>
} = {}
export const ensureIdentifierRepoCollection = (
identifier: string
): Writable<RepoDIdentiferCollection> => {
if (!Object.keys(repos_identifer).includes(identifier)) {
repos_identifer[identifier] = writable({
d: '',
events: [],
loading: true,
})
const sub = ndk.subscribe(
{ kinds: [repo_kind], '#d': [identifier] },
{ closeOnEose: true },
NDKRelaySet.fromRelayUrls(base_relays, ndk)
)
sub.on('event', (event: NDKEvent) => {
const repo_event = eventToRepoEvent(event)
if (repo_event && repo_event.identifier === identifier) {
ensureRepo(event).subscribe((repo_event) => {
repos_identifer[identifier].update((collection) => {
let events = collection.events
let exists = false
events.map((e) => {
if (e.author === repo_event.author) {
exists = true
return repo_event
} else return e
})
if (!exists) events = [...events, repo_event]
return {
...collection,
events,
}
})
})
}
})
sub.on('eose', () => {
repos_identifer[identifier].update((collection) => ({
...collection,
loading: false,
}))
})
}
return repos_identifer[identifier]
}

78
src/lib/stores/ReposPubkey.ts

@ -1,78 +0,0 @@
import type { SelectedPubkeyRepoCollections } from '$lib/components/repo/type'
import { get, writable, type Unsubscriber, type Writable } from 'svelte/store'
import { ensureRepoCollection, eventToRepoEvent } from './repos'
import { base_relays, ndk } from './ndk'
import { repo_kind } from '$lib/kinds'
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'
import { extractAReference } from '$lib/components/repo/utils'
export const selected_npub_repo_collections: Writable<SelectedPubkeyRepoCollections> =
writable({
pubkey: '',
collections: [],
})
const unsubscribers: Unsubscriber[] = []
export const ensureSelectedPubkeyRepoCollection = (
pubkey: string
): Writable<SelectedPubkeyRepoCollections> => {
const collections = get(selected_npub_repo_collections)
if (collections.pubkey === pubkey) return selected_npub_repo_collections
// TODO call unsubscribers
selected_npub_repo_collections.set({
pubkey,
collections: [],
})
const sub = ndk.subscribe(
{ kinds: [repo_kind], authors: [pubkey] },
{ closeOnEose: true },
NDKRelaySet.fromRelayUrls(base_relays, ndk)
)
const identifiers: string[] = []
sub.on('event', (event: NDKEvent) => {
const repo_event = eventToRepoEvent(event)
if (
repo_event &&
repo_event.author === pubkey &&
!identifiers.includes(repo_event.identifier)
)
identifiers.push(repo_event.identifier)
})
sub.on('eose', () => {
identifiers.forEach((identifier) => {
unsubscribers.push(
ensureRepoCollection(`${repo_kind}:${pubkey}:${identifier}`).subscribe(
(c) => {
if (!c.maintainers.includes(pubkey)) return
selected_npub_repo_collections.update((selected_collections) => {
if (selected_collections.pubkey !== pubkey)
return { ...selected_collections }
let collection_in_selected_collections = false
const collections = selected_collections.collections.map(
(old_c) => {
const ref = extractAReference(old_c.selected_a)
if (ref && ref.identifier === identifier) {
collection_in_selected_collections = true
return {
...c,
}
}
return { ...old_c }
}
)
if (!collection_in_selected_collections) collections.push(c)
return {
...selected_collections,
collections,
}
})
}
)
)
})
})
return selected_npub_repo_collections
}

55
src/lib/stores/ReposRecent.ts

@ -1,55 +0,0 @@
import type { RepoRecentCollection } from '$lib/components/repo/type'
import { writable, type Writable } from 'svelte/store'
import { ensureRepo, eventToRepoEvent } from './repos'
import { base_relays, ndk } from './ndk'
import { repo_kind } from '$lib/kinds'
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'
export const recent_repos: Writable<RepoRecentCollection> = writable({
events: [],
loading: true,
})
let started = false
export const ensureRecentRepos = (): Writable<RepoRecentCollection> => {
if (started) return recent_repos
started = true
const sub = ndk.subscribe(
{ kinds: [repo_kind] },
{ closeOnEose: true },
NDKRelaySet.fromRelayUrls(base_relays, ndk)
)
sub.on('event', (event: NDKEvent) => {
const repo_event = eventToRepoEvent(event)
if (repo_event) {
ensureRepo(event).subscribe((repo_event) => {
recent_repos.update((collection) => {
let events = collection.events
let exists = false
events.map((e) => {
if (
e.author === repo_event.author &&
e.identifier === repo_event.identifier
) {
exists = true
return repo_event
} else return e
})
if (!exists) events = [...events, repo_event]
return {
...collection,
events,
}
})
})
}
})
sub.on('eose', () => {
recent_repos.update((collection) => ({
...collection,
loading: false,
}))
})
return recent_repos
}

21
src/lib/stores/ndk.ts

@ -1,21 +0,0 @@
import NDKSvelte from '@nostr-dev-kit/ndk-svelte'
// export let base_relays = import.meta.env.DEV
// ? ["ws://localhost:8080"]
// : [
export const base_relays = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://purplerelay.com', // reliability untested
// 'wss://relayable.org', // free but not so reliable
]
// TODO: fallback_relays for if profile cannot be found
export const ndk = new NDKSvelte({
explicitRelayUrls: [...base_relays],
})
ndk.connect(5000)

128
src/lib/stores/repo.ts

@ -1,128 +0,0 @@
import { writable, type Unsubscriber, type Writable } from 'svelte/store'
import type {
RepoCollection,
RepoEvent,
RepoReadme,
} from '$lib/components/repo/type'
import {
collection_defaults,
event_defaults,
readme_defaults,
} from '$lib/components/repo/type'
import { ensureRepoCollection } from './repos'
import {
cloneArrayToReadMeUrls,
selectRepoFromCollection,
} from '$lib/components/repo/utils'
import { get } from 'svelte/store'
export const selected_repo_collection: Writable<RepoCollection> = writable({
...collection_defaults,
})
export const selected_repo_event: Writable<RepoEvent> = writable({
...event_defaults,
})
selected_repo_collection.subscribe((collection) => {
const selected_from_collection = selectRepoFromCollection(collection)
if (selected_from_collection)
selected_repo_event.set({ ...selected_from_collection })
})
let selected_repo_a: string = ''
let selected_unsubscriber: Unsubscriber
export const ensureSelectedRepoCollection = (
a: string,
naddr_relays: string[] | undefined = undefined
): Writable<RepoCollection> => {
if (selected_repo_a !== a) {
let loading = true
selected_repo_a = a
if (selected_unsubscriber) selected_unsubscriber()
selected_unsubscriber = ensureRepoCollection(a, naddr_relays).subscribe(
(repo_collection) => {
selected_repo_collection.set({ ...repo_collection })
if (loading && !repo_collection.loading) {
loading = false
const repo_event = selectRepoFromCollection(repo_collection)
if (repo_event)
ensureRepoReadme(repo_event.clone, repo_collection.selected_a)
}
}
)
}
return selected_repo_collection
}
export const awaitSelectedRepoCollection = async (
a: string
): Promise<RepoCollection> => {
return new Promise((r) => {
const unsubscriber = ensureSelectedRepoCollection(a).subscribe(
(repo_collection) => {
if (selected_repo_a === a && !repo_collection.loading) {
setTimeout(() => {
if (unsubscriber) unsubscriber()
}, 5)
r({ ...repo_collection })
}
}
)
})
}
export const selected_repo_readme: Writable<RepoReadme> = writable({
...readme_defaults,
})
const ensureRepoReadme = async (clone: string[], a: string): Promise<void> => {
selected_repo_readme.set({ ...readme_defaults })
/** update writable unless selected readme has changed */
const update = (md: string | undefined = undefined): void => {
const latest_collection = get(selected_repo_collection)
if (
[latest_collection.selected_a, latest_collection.selected_a].includes(a)
) {
selected_repo_readme.set({
md: md || '',
loading: false,
failed: !md,
})
}
}
let text: string | undefined
try {
let readme_urls = cloneArrayToReadMeUrls(clone)
// prioritise using github as it doesn't require a proxy
readme_urls = [
...readme_urls.filter((url) => url.includes('raw.githubusercontent.com')),
...readme_urls.filter(
(url) => !url.includes('raw.githubusercontent.com')
),
]
for (let i = 0; i < readme_urls.length; i++) {
try {
const res = await fetch(
readme_urls[i]
// readme_urls[i].includes('raw.githubusercontent.com')
// ? readme_urls[i]
// : // use proxy as most servers produce a CORS error
// `/git_proxy/readme/${encodeURIComponent(readme_urls[i])}`
)
if (res.ok) {
text = await res.text()
break
} else {
continue
}
} catch {
continue
}
}
} catch {}
update(text)
}

367
src/lib/stores/repos.ts

@ -1,367 +0,0 @@
import {
event_defaults,
collection_defaults,
type RepoCollection,
type RepoEvent,
type RepoSummary,
} from '$lib/components/repo/type'
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk'
import { get, writable, type Writable } from 'svelte/store'
import { base_relays, ndk } from './ndk'
import { repo_kind } from '$lib/kinds'
import {
aToNaddr,
extractAReference,
selectRepoFromCollection,
} from '$lib/components/repo/utils'
import { nip19 } from 'nostr-tools'
export const repos: {
[a: string]: Writable<RepoEvent>
} = {}
export const repo_collections: {
[a: string]: Writable<RepoCollection>
} = {}
export const ensureRepo = (
a: string | NDKEvent,
naddr_relays: string[] | undefined = undefined
): Writable<RepoEvent> => {
if (typeof a !== 'string') {
const repo_event = eventToRepoEvent(a)
if (repo_event) {
const a = repoEventToARef(repo_event)
repos[a] = writable({ ...repo_event, loading: true })
fetchReferencedBy(repo_event)
return repos[a]
}
return repos['']
}
if (!repos[a]) {
const base: RepoEvent = {
...event_defaults,
}
const a_ref = extractAReference(a)
if (!a_ref) return writable(base)
const { pubkey, identifier } = a_ref
repos[a] = writable({
...base,
identifier,
author: pubkey,
naddr: aToNaddr(a_ref) || '',
maintainers: [pubkey],
})
const sub = ndk.subscribe(
{ kinds: [repo_kind], '#d': [identifier], authors: [pubkey] },
{
groupable: true,
// default 100
groupableDelay: 200,
closeOnEose: false,
},
NDKRelaySet.fromRelayUrls([...base_relays, ...(naddr_relays || [])], ndk)
)
sub.on('event', (event: NDKEvent) => {
const repo_event = eventToRepoEvent(event)
if (repo_event) {
if (
identifier === repo_event.identifier &&
pubkey === repo_event.author
)
repos[a].update(() => {
return {
...repo_event,
}
})
fetchReferencedBy(repo_event)
// TODO fetch stargazers
}
})
sub.on('eose', () => {
// still awaiting reference_by at this point
repos[a].update((repo_event) => {
return {
...repo_event,
loading: false,
}
})
})
}
setTimeout(() => {
repos[a].update((repo_event) => {
return {
...repo_event,
loading: false,
}
})
}, 5000)
return repos[a]
}
export const returnRepo = async (
a: string,
naddr_relays: string[] | undefined = undefined
): Promise<RepoEvent> => {
return new Promise((r) => {
const unsubscriber = ensureRepo(a, naddr_relays).subscribe((c) => {
if (!c.loading) {
setTimeout(() => {
if (unsubscriber) unsubscriber()
}, 5)
r(c)
}
})
})
}
export const ensureRepoCollection = (
a: string,
naddr_relays: string[] | undefined = undefined
): Writable<RepoCollection> => {
if (!repo_collections[a]) {
const base: RepoCollection = {
...collection_defaults,
selected_a: a,
}
repo_collections[a] = writable(base)
const a_ref = extractAReference(a)
if (!a_ref) return repo_collections[a]
const { pubkey, identifier } = a_ref
returnRepo(a, naddr_relays).then(async (repo_event) => {
if (get(repo_collections[a]).events.length > 0) return
repo_collections[a].update((collection) => {
return {
...collection,
events: [repo_event],
maintainers: repo_event.maintainers,
most_recent_index: 0,
}
})
const new_maintainers: string[] = []
const addMaintainers = async (m: string) => {
const m_repo_event = await returnRepo(`${repo_kind}:${m}:${identifier}`)
repo_collections[a].update((collection) => {
m_repo_event.maintainers.forEach((m) => {
if (
![pubkey, ...collection.maintainers, ...new_maintainers].includes(
m
)
)
new_maintainers.push(m)
})
const events = [...collection.events, m_repo_event]
const most_recent = events.sort(
(a, b) => b.created_at - a.created_at
)[0]
return {
...collection,
events,
most_recent_index: events.findIndex(
(e) => e.author === most_recent.author
),
maintainers: [...collection.maintainers, ...new_maintainers],
}
})
}
// add maintainer events
await Promise.all(
repo_event.maintainers
.filter((m) => m !== pubkey)
.map((m) => addMaintainers(m))
)
// also add maintainers included in their maintainer events
while (new_maintainers.length > 0) {
await Promise.all(new_maintainers.map((m) => addMaintainers(m)))
}
repo_collections[a].update((repo_collection) => {
return {
...repo_collection,
loading: false,
}
})
})
}
setTimeout(() => {
repo_collections[a].update((repo_collection) => {
return {
...repo_collection,
loading: false,
}
})
}, 5000)
return repo_collections[a]
}
export const returnRepoCollection = async (
a: string
): Promise<RepoCollection> => {
return new Promise((r) => {
const unsubscriber = ensureRepoCollection(a).subscribe((c) => {
if (!c.loading) {
setTimeout(() => {
if (unsubscriber) unsubscriber()
}, 5)
r(c)
}
})
})
}
const repoEventToARef = (repo_event: RepoEvent): string =>
`${repo_kind}:${repo_event.author}:${repo_event.identifier}`
const fetchReferencedBy = (repo_event: RepoEvent) => {
const relays_to_use =
repo_event.relays.length < 3
? repo_event.relays
: [...base_relays].concat(repo_event.relays)
const ref_sub = ndk.subscribe(
{
'#a': [repoEventToARef(repo_event)],
limit: 10,
},
{
groupable: true,
// default 100
groupableDelay: 200,
closeOnEose: true,
},
NDKRelaySet.fromRelayUrls(relays_to_use, ndk)
)
ref_sub.on('event', (ref_event: NDKEvent) => {
repos[repoEventToARef(repo_event)].update((repo_event) => {
return {
...repo_event,
referenced_by: repo_event.referenced_by.includes(ref_event.id)
? [...repo_event.referenced_by]
: [...repo_event.referenced_by, ref_event.id],
most_recent_reference_timestamp:
ref_event.created_at &&
repo_event.most_recent_reference_timestamp < ref_event.created_at
? ref_event.created_at
: repo_event.most_recent_reference_timestamp,
}
})
})
ref_sub.on('eose', () => {
repos[repoEventToARef(repo_event)].update((repo_event) => {
return {
...repo_event,
// finished loading repo_event as we have all referenced_by events
loading: false,
}
})
})
}
export const eventToRepoEvent = (event: NDKEvent): RepoEvent | undefined => {
if (event.kind !== repo_kind) return undefined
const maintainers = [event.pubkey]
event.getMatchingTags('maintainers').forEach((t: string[]) => {
t.forEach((v, i) => {
if (i > 0 && v !== maintainers[0]) {
try {
nip19.npubEncode(v) // will throw if invalid hex pubkey
maintainers.push(v)
} catch {}
}
})
})
const relays: string[] = []
event.getMatchingTags('relays').forEach((t: string[]) => {
t.forEach((v, i) => {
if (i > 0) {
relays.push(v)
}
})
})
const web: string[] = []
event.getMatchingTags('web').forEach((t: string[]) => {
t.forEach((v, i) => {
if (i > 0) {
web.push(v)
}
})
})
const clone: string[] = []
event.getMatchingTags('clone').forEach((t: string[]) => {
t.forEach((v, i) => {
if (i > 0) {
clone.push(v)
}
})
})
return {
event_id: event.id,
naddr: event.encode(),
author: event.pubkey,
identifier: event.replaceableDTag(),
unique_commit: event.tagValue('r') || undefined,
name: event.tagValue('name') || '',
description: event.tagValue('description') || '',
clone,
web,
tags: event.getMatchingTags('t').map((t) => t[1]) || [],
maintainers,
relays,
referenced_by: [],
most_recent_reference_timestamp: event.created_at || 0,
created_at: event.created_at || 0,
loading: true, // loading until references fetched
}
}
export const repoCollectionToSummary = (
collection: RepoCollection
): RepoSummary | undefined => {
const selected = selectRepoFromCollection(collection)
if (!selected) return undefined
return {
name: selected.name,
identifier: selected.identifier,
naddr: selected.naddr,
unique_commit: selected.unique_commit,
description: selected.description,
maintainers: selected.maintainers,
loading: collection.loading,
created_at: selected.created_at,
most_recent_reference_timestamp: Math.max.apply(
0,
collection.events.map((e) => e.most_recent_reference_timestamp)
),
} as RepoSummary
}
export const repoEventToSummary = (event: RepoEvent): RepoSummary => {
return {
name: event.name,
identifier: event.identifier,
naddr: event.naddr,
unique_commit: event.unique_commit,
description: event.description,
maintainers: event.maintainers,
loading: event.loading,
created_at: event.created_at,
most_recent_reference_timestamp: event.most_recent_reference_timestamp,
} as RepoSummary
}

184
src/lib/stores/users.ts

@ -1,184 +0,0 @@
import {
defaults as user_defaults,
type UserObject,
} from '$lib/components/users/type'
import {
getRelayListForUser,
NDKNip07Signer,
NDKRelayList,
} from '@nostr-dev-kit/ndk'
import { get, writable, type Unsubscriber, type Writable } from 'svelte/store'
import { ndk } from './ndk'
export const users: { [hexpubkey: string]: Writable<UserObject> } = {}
const empty_user: Writable<UserObject> = writable({
loading: true,
hexpubkey: '',
npub: 'npub...',
})
export const ensureUser = (hexpubkey: string): Writable<UserObject> => {
if (hexpubkey === '') return empty_user
if (!users[hexpubkey]) {
const u = ndk.getUser({ hexpubkey })
const base: UserObject = {
loading: false,
hexpubkey,
npub: u.npub,
}
users[hexpubkey] = writable(base)
getUserRelays(hexpubkey)
const getProfile = () => {
u.fetchProfile({
closeOnEose: true,
groupable: true,
// default 100
groupableDelay: 200,
}).then(
(p) => {
users[hexpubkey].update((u) => ({
...u,
loading: false,
profile: p === null ? undefined : p,
}))
},
() => {
users[hexpubkey].update((u) => ({
...u,
loading: false,
}))
}
)
}
let attempts = 1
const tryAgainin3s = () => {
setTimeout(
() => {
if (!get(users[hexpubkey]).profile) {
getProfile()
attempts++
if (attempts < 5) tryAgainin3s()
}
},
(attempts ^ 2) * 1000
)
}
getProfile()
tryAgainin3s()
}
return users[hexpubkey]
}
export const returnUser = async (hexpubkey: string): Promise<UserObject> => {
return new Promise((r) => {
const unsubscriber = ensureUser(hexpubkey).subscribe((u) => {
if (!u.loading) {
setTimeout(() => {
if (unsubscriber) unsubscriber()
}, 5)
r(u)
}
})
})
}
// nip07_plugin is set in Navbar component
export const nip07_plugin: Writable<undefined | boolean> = writable(undefined)
export const checkForNip07Plugin = () => {
if (window.nostr) {
nip07_plugin.set(true)
if (localStorage.getItem('nip07pubkey')) login()
} else {
let timerId: NodeJS.Timeout | undefined = undefined
const intervalId = setInterval(() => {
if (window.nostr) {
clearTimeout(timerId)
clearInterval(intervalId)
nip07_plugin.set(true)
if (localStorage.getItem('nip07pubkey')) login()
}
}, 100)
timerId = setTimeout(() => {
clearInterval(intervalId)
nip07_plugin.set(false)
}, 5000)
}
}
const signer = new NDKNip07Signer(2000)
export const logged_in_user: Writable<undefined | UserObject> =
writable(undefined)
export const login = async (): Promise<void> => {
return new Promise(async (res, rej) => {
const user = get(logged_in_user)
if (user) return res()
if (get(nip07_plugin)) {
try {
const ndk_user = await signer.blockUntilReady()
localStorage.setItem('nip07pubkey', ndk_user.pubkey)
logged_in_user.set({
...user_defaults,
hexpubkey: ndk_user.pubkey,
})
ndk.signer = signer
ensureUser(ndk_user.pubkey).subscribe((user) => {
logged_in_user.set({ ...user })
})
return res()
} catch (e) {
alert(e)
rej()
}
} else {
rej()
}
})
}
export const logout = async (): Promise<void> => {
logged_in_user.set(undefined)
localStorage.removeItem('nip07pubkey')
ndk.signer = undefined
}
interface UserRelays {
loading: boolean
ndk_relays: NDKRelayList | undefined
}
export const user_relays: { [hexpubkey: string]: Writable<UserRelays> } = {}
export const getUserRelays = async (hexpubkey: string): Promise<UserRelays> => {
return new Promise(async (res, _) => {
if (user_relays[hexpubkey]) {
const unsubscriber: Unsubscriber = user_relays[hexpubkey].subscribe(
(querying_user_relays) => {
if (querying_user_relays && !querying_user_relays.loading) {
res(querying_user_relays)
setTimeout(() => {
if (unsubscriber) unsubscriber()
}, 5)
}
}
)
} else {
user_relays[hexpubkey] = writable({
loading: true,
ndk_relays: undefined,
})
const relay_list = await getRelayListForUser(hexpubkey, ndk)
const querying_user_relays = {
loading: false,
ndk_relays: relay_list,
}
user_relays[hexpubkey].set({ ...querying_user_relays })
res(querying_user_relays)
}
})
}

4
src/lib/stores/utils.ts

@ -1,4 +0,0 @@
export const ignore_kinds = [
31234, // amethyst draft kind
9978, // confidence scoring event
]

136
src/lib/wrappers/ComposeIssue.svelte

@ -1,136 +0,0 @@
<script lang="ts">
import { base_relays, ndk } from '$lib/stores/ndk'
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'
import { issue_kind, repo_kind } from '$lib/kinds'
import { getUserRelays, logged_in_user, login } from '$lib/stores/users'
import type { RepoEvent } from '$lib/components/repo/type'
import { goto } from '$app/navigation'
import { nip19 } from 'nostr-tools'
export let repo_event: RepoEvent
let submitting = false
let submitted = false
let edit_mode = false
let title = ''
let content = ''
$: {
edit_mode = !submitted
}
let submit_attempted = false
async function sendIssue(title: string, content: string) {
submit_attempted = true
if (title.length < 10) return
if (!$logged_in_user) await login()
if (!$logged_in_user) return
let event = new NDKEvent(ndk)
event.kind = issue_kind
event.tags.push(['subject', title])
event.tags.push(['alt', `git repository issue: ${title}`])
if (repo_event.unique_commit) {
event.tags.push(['r', repo_event.unique_commit])
}
event.tags.push([
'a',
`${repo_kind}:${repo_event.maintainers[0]}:${repo_event.identifier}`,
repo_event.relays[0] || '',
'root',
])
repo_event.maintainers.forEach((m) => event.tags.push(['p', m]))
event.content = `${content}`
submitting = true
let relays = [
...(repo_event.relays.length > 3
? repo_event.relays
: [...base_relays].concat(repo_event.relays)),
]
try {
event.sign()
} catch {
alert('failed to sign event')
}
try {
let user_relays = await getUserRelays($logged_in_user.hexpubkey)
relays = [
...relays,
...(user_relays.ndk_relays
? user_relays.ndk_relays.writeRelayUrls
: []),
]
} catch {
alert('failed to get user relays')
}
try {
let _ = await event.publish(NDKRelaySet.fromRelayUrls(relays, ndk))
submitting = false
submitted = true
setTimeout(() => {
goto(`/r/${repo_event.naddr}/issues/${nip19.noteEncode(event.id)}`)
}, 2000)
} catch {}
}
</script>
{#if edit_mode}
<div class="flex">
<div class="flex-grow">
<label class="form-control w-full">
<div class="label">
<span class="label-text text-sm">Title</span>
</div>
<input
type="text"
bind:value={title}
class="input-neutral input input-sm input-bordered mb-3 w-full border-warning"
class:border-warning={submit_attempted && title.length < 10}
placeholder="title"
/>
{#if submit_attempted && title.length < 10}
<div class="pr-3 align-middle text-sm text-warning">
title must be at least 10 characters
</div>
{/if}
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-textarea text-sm">Description</span>
</div>
<textarea
disabled={submitting}
bind:value={content}
class="textarea textarea-secondary w-full"
placeholder="description"
rows="10"
></textarea>
</label>
<div class="mt-2 flex items-center">
<div class="flex-auto"></div>
{#if submit_attempted && title.length < 10}
<div class="pr-3 align-middle text-sm text-warning">
title must be at least 10 characters
</div>
{/if}
<button
on:click={() => sendIssue(title, content)}
disabled={submitting || (submit_attempted && title.length < 10)}
class="btn btn-primary btn-sm"
>
{#if submitting}
Sending
{:else if !$logged_in_user}
Login before Sending
{:else}
Send
{/if}
</button>
</div>
</div>
</div>
{/if}
{#if submitted}
<div>sent going to issue!</div>
{/if}

159
src/lib/wrappers/ComposeReply.svelte

@ -1,159 +0,0 @@
<script lang="ts">
import { base_relays, ndk } from '$lib/stores/ndk'
import { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk'
import { reply_kind, repo_kind } from '$lib/kinds'
import { getUserRelays, logged_in_user, user_relays } from '$lib/stores/users'
import {
selected_repo_collection,
selected_repo_event,
} from '$lib/stores/repo'
import Compose from '$lib/components/events/Compose.svelte'
import { selected_proposal_full } from '$lib/stores/Proposal'
import { selected_issue_full } from '$lib/stores/Issue'
import type { IssueFull } from '$lib/components/issues/type'
import type { ProposalFull } from '$lib/components/proposals/type'
import { get } from 'svelte/store'
export let type: 'proposal' | 'issue' = 'proposal'
export let event: NDKEvent
export let sentFunction = () => {}
let repo_identifier: string
let selected_proposal_or_issue: IssueFull | ProposalFull
let submitting = false
let submitted = false
let edit_mode = false
$: {
repo_identifier = $selected_repo_event.identifier
selected_proposal_or_issue =
type === 'proposal' ? $selected_proposal_full : $selected_issue_full
edit_mode =
repo_identifier.length > 0 &&
selected_proposal_or_issue.summary.id.length > 0 &&
!submitted
}
/** to get the proposal revision id rather than the root proposal */
const getRootId = (event: NDKEvent): string => {
// exclude 'a' references to repo events
let root_tag = event.tags.find(
(t) => t[0] === 'e' && t.length === 4 && t[3] === 'root'
)
if (root_tag) return root_tag[1]
if (event.tags.some((t) => t[0] === 't' && t[1] === 'root')) return event.id
return selected_proposal_or_issue.summary.id
}
async function sendReply(content: string) {
if (!$logged_in_user) return
let new_event = new NDKEvent(ndk)
new_event.kind = reply_kind
if (reply_kind !== 1) event.tags.push(['alt', `git reply`])
new_event.tags.push([
'e',
getRootId(event),
$selected_repo_event.relays[0] || '',
'root',
])
if (event.id.length > 0) {
new_event.tags.push([
'e',
event.id,
$selected_repo_event.relays[0] || '',
'reply',
])
}
if ($selected_repo_event.unique_commit) {
new_event.tags.push(['r', $selected_repo_event.unique_commit])
}
$selected_repo_collection.maintainers.forEach((m) => {
new_event.tags.push(['a', `${repo_kind}:${m}:${repo_identifier}`])
})
let parent_event_user_relay = user_relays[event.pubkey]
? get(user_relays[event.pubkey]).ndk_relays?.writeRelayUrls[0]
: undefined
if (event.pubkey !== $logged_in_user?.hexpubkey)
new_event.tags.push(
parent_event_user_relay
? ['p', event.pubkey, parent_event_user_relay]
: ['p', event.pubkey]
)
event.tags
.filter((tag) => tag[0] === 'p')
.forEach((tag) => {
if (
// not duplicate
!new_event.tags.some((t) => t[1] === tag[1]) &&
// not current user (dont tag self)
tag[1] !== $logged_in_user?.hexpubkey
)
new_event.tags.push(tag)
})
new_event.content = content
submitting = true
let relays = [
...($selected_repo_event.relays.length > 3
? $selected_repo_event.relays
: [...base_relays].concat($selected_repo_event.relays)),
]
try {
new_event.sign()
} catch {
alert('failed to sign event')
}
try {
let user_relays = await getUserRelays($logged_in_user.hexpubkey)
relays = [
...relays,
...(user_relays.ndk_relays
? user_relays.ndk_relays.writeRelayUrls
: []),
]
} catch {}
try {
let root_event_user_relays = await getUserRelays(event.pubkey)
relays = [
...relays,
...(root_event_user_relays.ndk_relays
? root_event_user_relays.ndk_relays.writeRelayUrls
: []),
]
} catch {}
// TODO root event user relays
try {
let _ = await new_event.publish(
NDKRelaySet.fromRelayUrls([...new Set(relays)], ndk)
)
submitting = false
submitted = true
setTimeout(() => {
submitted = false
sentFunction()
}, 3000)
} catch {}
}
</script>
{#if edit_mode}
<Compose {sendReply} {submitting} logged_in={!!$logged_in_user} />
{/if}
{#if submitted}
<div role="alert" class="alert mt-6">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-info"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path></svg
>
<div>
<h3 class="prose mb-2 text-sm font-bold">reply sent</h3>
</div>
</div>
{/if}

89
src/lib/wrappers/EventCard.svelte

@ -1,89 +0,0 @@
<script lang="ts">
import EventWrapper from '$lib/components/events/EventWrapper.svelte'
import EventWrapperLite from '$lib/components/events/EventWrapperLite.svelte'
import Status from '$lib/components/events/content/Status.svelte'
import Patch from '$lib/components/events/content/Patch.svelte'
import ParsedContent from '$lib/components/events/content/ParsedContent.svelte'
import { defaults as user_defaults } from '$lib/components/users/type'
import {
issue_kind,
patch_kind,
proposal_status_kinds,
repo_kind,
} from '$lib/kinds'
import { ensureUser } from '$lib/stores/users'
import type { NDKEvent } from '@nostr-dev-kit/ndk'
import { onDestroy } from 'svelte'
import { writable, type Unsubscriber } from 'svelte/store'
import {
extractPatchMessage,
isCoverLetter,
} from '$lib/components/events/content/utils'
import Repo from '$lib/components/events/content/Repo.svelte'
import IssuePreview from '$lib/components/events/content/IssuePreview.svelte'
export let event: NDKEvent
export let type: 'proposal' | 'issue' = 'proposal'
export let preview = false
let author = writable({ ...user_defaults })
let author_unsubsriber: Unsubscriber
$: {
if (event && event.pubkey.length > 0)
author_unsubsriber = ensureUser(event.pubkey).subscribe((u) => {
if (u.hexpubkey == event.pubkey) author.set({ ...u })
})
}
onDestroy(() => {
if (author_unsubsriber) author_unsubsriber()
})
const getDtag = (event: NDKEvent): undefined | string => {
try {
const tag = event.replaceableDTag()
return tag
} catch {}
}
</script>
{#if event.kind && [6, 16].includes(event.kind)}
<EventWrapperLite author={$author} created_at={event.created_at}>
reposted by
</EventWrapperLite>
{:else if event.kind && event.kind === 5}
<EventWrapperLite author={$author} created_at={event.created_at}>
deletion requested by
</EventWrapperLite>
{:else if event.kind && event.kind === 30001}
<EventWrapperLite author={$author} created_at={event.created_at}>
added to '{getDtag(event) || 'unknown'}' list by
</EventWrapperLite>
{:else if event.kind && event.kind == repo_kind}
<EventWrapperLite author={$author} created_at={event.created_at}>
<Repo {event} />
</EventWrapperLite>
{:else if preview && event.kind && event.kind === patch_kind}
<EventWrapperLite author={$author} created_at={event.created_at}>
<Patch {event} {preview} />
</EventWrapperLite>
{:else if preview && event.kind && event.kind === issue_kind}
<EventWrapperLite author={$author} created_at={event.created_at}>
<IssuePreview {event} />
</EventWrapperLite>
{:else}
<EventWrapper {type} author={$author} created_at={event.created_at} {event}>
{#if event.kind == patch_kind}
{#if isCoverLetter(event.content)}
<ParsedContent
content={extractPatchMessage(event.content)}
tags={event.tags}
/>
{:else}
<Patch {event} />
{/if}
{:else if event.kind && proposal_status_kinds.includes(event.kind)}
<Status {type} status={event.kind} />
{:else}
<ParsedContent content={event.content} tags={event.tags} />
{/if}
</EventWrapper>
{/if}

69
src/lib/wrappers/EventPreview.svelte

@ -1,69 +0,0 @@
<script lang="ts">
import { NDKRelaySet, type NDKEvent } from '@nostr-dev-kit/ndk'
import { onMount } from 'svelte'
import { get, writable, type Writable } from 'svelte/store'
import { base_relays, ndk } from '$lib/stores/ndk'
import EventCard from './EventCard.svelte'
import type { AddressPointer, EventPointer } from 'nostr-tools/nip19'
import { repo_kind } from '$lib/kinds'
import { ensureRepo } from '$lib/stores/repos'
import EventWrapperLite from '$lib/components/events/EventWrapperLite.svelte'
import Repo from '$lib/components/events/content/Repo.svelte'
export let pointer: AddressPointer | EventPointer
let cannot_find_event = false
let event: Writable<undefined | NDKEvent> = writable(undefined)
const isAddressPointer = (
pointer: AddressPointer | EventPointer
): pointer is AddressPointer => {
return Object.keys(pointer).includes('identifier')
}
let is_repo = isAddressPointer(pointer) && pointer.kind == repo_kind
let repo =
is_repo && isAddressPointer(pointer)
? ensureRepo(`${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`)
: undefined
onMount(() => {
if (!is_repo) {
let sub = ndk.subscribe(
isAddressPointer(pointer)
? {
'#a': [`${pointer.kind}:${pointer.pubkey}:${pointer.identifier}`],
}
: { ids: [pointer.id] },
{ closeOnEose: true },
NDKRelaySet.fromRelayUrls(
pointer.relays ? [...base_relays, ...pointer.relays] : base_relays,
ndk
)
)
sub.on('event', (e: NDKEvent) => {
event.set(e)
})
sub.on('eose', () => {
if (!get(event)) cannot_find_event = true
})
}
})
</script>
<div class="card my-3 border border-base-400 shadow-xl">
{#if repo && $repo}
<EventWrapperLite author={$repo?.author} created_at={$repo?.created_at}>
<Repo event={$repo} />
</EventWrapperLite>
{:else if $event && $event.pubkey}
<div class="p-2 pt-0">
<EventCard event={$event} preview={true} />
</div>
{:else if cannot_find_event}
<div class="m-3 text-center text-sm">cannot find event</div>
{:else}
<div class="m-3 text-center text-sm">loading...</div>
{/if}
</div>

23
src/lib/wrappers/Navbar.svelte

@ -1,23 +0,0 @@
<script lang="ts">
import {
checkForNip07Plugin,
logged_in_user,
login,
nip07_plugin,
} from '$lib/stores/users'
import { onMount } from 'svelte'
import Navbar from '$lib/components/Navbar.svelte'
let singup_function = () => {
alert('a NIP-07 browser extension is required. currently no signup page')
}
onMount(checkForNip07Plugin)
</script>
<Navbar
logged_in_user={$logged_in_user}
nip07_plugin={$nip07_plugin}
login_function={login}
{singup_function}
/>

13
src/lib/wrappers/RepoDetails.svelte

@ -1,13 +0,0 @@
<script lang="ts">
import RepoDetails from '$lib/components/repo/RepoDetails.svelte'
import {
ensureSelectedRepoCollection,
selected_repo_event,
} from '$lib/stores/repo'
export let a = ''
ensureSelectedRepoCollection(a)
</script>
<RepoDetails {...$selected_repo_event} />

76
src/lib/wrappers/RepoMenu.svelte

@ -1,76 +0,0 @@
<script lang="ts">
import { issue_icon_path } from '$lib/components/issues/icons'
import { proposal_icon_path } from '$lib/components/proposals/icons'
import type { RepoPage } from '$lib/components/repo/type'
import { proposal_status_open } from '$lib/kinds'
import { issue_summaries } from '$lib/stores/Issues'
import { proposal_summaries } from '$lib/stores/Proposals'
import { selected_repo_event, selected_repo_readme } from '$lib/stores/repo'
export let selected_tab: RepoPage = 'about'
</script>
<div class="flex border-b border-base-400">
<div role="tablist" class="tabs tabs-bordered flex-none">
{#if !$selected_repo_readme.failed}
<a
href={`/r/${$selected_repo_event.naddr}`}
class="tab"
class:tab-active={selected_tab === 'about'}
>
About
</a>
{/if}
<a
href={`/r/${$selected_repo_event.naddr}/proposals`}
class="tab"
class:tab-active={selected_tab === 'proposals'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="mb-1 mr-1 h-4 w-4 flex-none fill-base-content pt-1 opacity-50"
>
<path d={proposal_icon_path.open_pull} />
</svg>
Proposals
{#if $proposal_summaries.loading}
<span class="loading loading-spinner loading-xs ml-2 text-neutral"
></span>
{:else if $proposal_summaries.summaries.filter((s) => s.status === proposal_status_open).length > 0}
<span class="badge badge-neutral badge-sm ml-2">
{$proposal_summaries.summaries.filter(
(s) => s.status === proposal_status_open
).length}
</span>
{/if}
</a>
<a
href={`/r/${$selected_repo_event.naddr}/issues`}
class="tab"
class:tab-active={selected_tab === 'issues'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
class="mb-1 mr-1 h-4 w-4 flex-none fill-base-content pt-1 opacity-50"
>
{#each issue_icon_path.open as p}
<path d={p} />
{/each}
</svg>
Issues
{#if $issue_summaries.loading}
<span class="loading loading-spinner loading-xs ml-2 text-neutral"
></span>
{:else if $issue_summaries.summaries.filter((s) => s.status === proposal_status_open).length > 0}
<span class="badge badge-neutral badge-sm ml-2">
{$issue_summaries.summaries.filter(
(s) => s.status === proposal_status_open
).length}
</span>
{/if}
</a>
</div>
<div class="flex-grow"></div>
</div>

70
src/lib/wrappers/RepoPageWrapper.svelte

@ -1,70 +0,0 @@
<script lang="ts">
import RepoDetails from '$lib/wrappers/RepoDetails.svelte'
import {
ensureSelectedRepoCollection,
selected_repo_event,
} from '$lib/stores/repo'
import RepoHeader from '$lib/components/repo/RepoHeader.svelte'
import Container from '$lib/components/Container.svelte'
import { ensureProposalSummaries } from '$lib/stores/Proposals'
import { ensureIssueSummaries } from '$lib/stores/Issues'
import type { RepoPage } from '$lib/components/repo/type'
import { naddrToPointer, naddrToRepoA } from '$lib/components/repo/utils'
import AlertError from '$lib/components/AlertError.svelte'
export let repo_naddr = ''
export let selected_tab: RepoPage = 'about'
export let with_side_bar = true
export let show_details_on_mobile = false
let invalid_naddr = false
let a = ''
$: {
const a_result = naddrToRepoA(repo_naddr)
if (a_result) {
a = a_result
invalid_naddr = false
ensureSelectedRepoCollection(a, naddrToPointer(repo_naddr)?.relays)
ensureProposalSummaries(a)
ensureIssueSummaries(a)
} else {
invalid_naddr = true
}
}
</script>
<RepoHeader {...$selected_repo_event} {selected_tab} />
{#if invalid_naddr}
<Container>
<AlertError>
<div>Error! invalid naddr in url:</div>
<div class="break-all">{repo_naddr}</div>
</AlertError>
</Container>
<Container>
<slot />
</Container>
{/if}
{#if with_side_bar}
<Container>
<div class="mt-2 md:flex">
<div class="md:mr-2 md:w-2/3">
<slot />
</div>
<div
class:hidden={!show_details_on_mobile}
class=" rounded-lg border border-base-400 md:flex md:w-1/3 md:border-none"
>
<div class="border-b border-base-400 bg-base-300 px-6 py-3 md:hidden">
<h4 class="">Repository Details</h4>
</div>
<div class="prose my-3 w-full px-6 md:ml-2 md:px-0">
<RepoDetails {a} />
</div>
</div>
</div>
</Container>
{:else}
<slot />
{/if}

24
src/lib/wrappers/Thread.svelte

@ -1,24 +0,0 @@
<script lang="ts">
import type { NDKEvent } from '@nostr-dev-kit/ndk'
import ThreadTree from './ThreadTree.svelte'
import { getThreadTrees } from './thread_tree'
export let event: NDKEvent
export let type: 'proposal' | 'issue' = 'proposal'
export let show_compose = true
export let replies: NDKEvent[] | undefined = undefined
$: thread_trees = getThreadTrees(type, event, replies)
</script>
{#each thread_trees as tree, i}
{#if i > 0}
<div class="divider">new revision</div>
{/if}
<ThreadTree
{type}
{tree}
show_compose={show_compose && thread_trees.length - 1 === i}
/>
{/each}

138
src/lib/wrappers/ThreadTree.svelte

@ -1,138 +0,0 @@
<script lang="ts">
import EventCard from './EventCard.svelte'
import ThreadWrapper from '$lib/components/events/ThreadWrapper.svelte'
import type { ThreadTreeNode } from '$lib/components/events/type'
import ComposeReply from './ComposeReply.svelte'
export let tree: ThreadTreeNode | undefined
export let type: 'proposal' | 'issue' = 'proposal'
export let show_compose = false
const countReplies = (tree: ThreadTreeNode, starting: number = 0): number => {
return (
tree.child_nodes.length +
tree.child_nodes.reduce((a, c) => a + countReplies(c), starting)
)
}
</script>
{#if tree}
<EventCard {type} event={tree.event} />
<ThreadWrapper num_replies={countReplies(tree)}>
{#each tree.child_nodes as layer1}
<EventCard {type} event={layer1.event} />
<ThreadWrapper num_replies={countReplies(layer1)}>
{#each layer1.child_nodes as layer2}
<EventCard {type} event={layer2.event} />
<ThreadWrapper num_replies={countReplies(layer2)}>
{#each layer2.child_nodes as layer3}
<EventCard {type} event={layer3.event} />
<ThreadWrapper num_replies={countReplies(layer3)}>
{#each layer3.child_nodes as layer4}
<EventCard {type} event={layer4.event} />
<ThreadWrapper num_replies={countReplies(layer4)}>
{#each layer4.child_nodes as layer5}
<EventCard {type} event={layer5.event} />
<ThreadWrapper num_replies={countReplies(layer5)}>
{#each layer5.child_nodes as layer6}
<EventCard {type} event={layer6.event} />
<ThreadWrapper num_replies={countReplies(layer6)}>
{#each layer6.child_nodes as layer7}
<EventCard {type} event={layer7.event} />
<ThreadWrapper num_replies={countReplies(layer7)}>
{#each layer7.child_nodes as layer8}
<EventCard {type} event={layer8.event} />
<ThreadWrapper
num_replies={countReplies(layer8)}
>
{#each layer8.child_nodes as layer9}
<EventCard {type} event={layer9.event} />
<ThreadWrapper
num_replies={countReplies(layer9)}
>
{#each layer9.child_nodes as layer10}
<EventCard
{type}
event={layer10.event}
/>
<ThreadWrapper
num_replies={countReplies(layer10)}
>
{#each layer10.child_nodes as layer11}
<EventCard
{type}
event={layer11.event}
/>
<ThreadWrapper
num_replies={countReplies(
layer11
)}
>
{#each layer11.child_nodes as layer12}
<EventCard
{type}
event={layer12.event}
/>
<ThreadWrapper
num_replies={countReplies(
layer12
)}
>
{#each layer12.child_nodes as layer13}
<EventCard
{type}
event={layer13.event}
/>
<ThreadWrapper
num_replies={countReplies(
layer13
)}
>
{#each layer13.child_nodes as layer14}
<EventCard
{type}
event={layer14.event}
/>
<ThreadWrapper
num_replies={countReplies(
layer14
)}
>
{#each layer14.child_nodes as layer15}
<EventCard
{type}
event={layer15.event}
/>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
</ThreadWrapper>
{/each}
{#if show_compose}
<ComposeReply {type} event={tree.event} />
{/if}
</ThreadWrapper>
{/if}

231
src/lib/wrappers/thread_tree.spec.ts

@ -1,231 +0,0 @@
import { describe, expect, test } from 'vitest'
import { createThreadTree, getParentId, getThreadTrees } from './thread_tree'
import NDK, {
NDKEvent,
NDKPrivateKeySigner,
type NDKTag,
} from '@nostr-dev-kit/ndk'
import { reply_kind } from '$lib/kinds'
const ndk = new NDK()
ndk.signer = new NDKPrivateKeySigner(
'08608a436aee4c07ea5c36f85cb17c58f52b3ad7094f9318cc777771f0bf218b'
)
const generateEventWithTags = async (tags: NDKTag[]): Promise<NDKEvent> => {
const event = new NDKEvent(ndk)
event.kind = reply_kind
event.content = Math.random().toFixed(10)
tags.forEach((tag) => {
event.tags.push(tag)
})
await event.sign()
return event
}
describe('getParentId', () => {
describe('when all types of e tag are present', () => {
test('returns id of e reply tag', async () => {
expect(
getParentId(
await generateEventWithTags([
['e', '012'],
['e', '123', '', 'root'],
['e', '789', '', 'mention'],
['e', '456', '', 'reply'],
])
)
).toEqual('456')
})
})
describe('when all types of e tag are present except reply', () => {
test('returns id of e root tag', async () => {
expect(
getParentId(
await generateEventWithTags([
['e', '012'],
['e', '123', '', 'root'],
['e', '789', '', 'mention'],
])
)
).toEqual('123')
})
})
describe('when only mention and unmarked e tags are present', () => {
test('returns id of unmarked e tag', async () => {
expect(
getParentId(
await generateEventWithTags([
['e', '012'],
['e', '789', '', 'mention'],
])
)
).toEqual('012')
})
})
describe('when only mention e tag are present', () => {
test('return undefined', async () => {
expect(
getParentId(await generateEventWithTags([['e', '789', '', 'mention']]))
).toBeUndefined()
})
})
})
describe('createThreadTree', () => {
describe('only events without parents are returned as top level array items', () => {
describe('1 parent, 1 child', () => {
test('returns array with only parent at top level', async () => {
const root = await generateEventWithTags([])
const reply_to_root = await generateEventWithTags([
['e', root.id, '', 'reply'],
])
const tree = createThreadTree([root, reply_to_root])
expect(tree).to.have.length(1)
expect(tree[0].event.id).to.eq(root.id)
})
test('parent has child in child_nodes, child has empty child nodes', async () => {
const root = await generateEventWithTags([])
const reply_to_root = await generateEventWithTags([
['e', root.id, '', 'reply'],
])
const tree = createThreadTree([root, reply_to_root])
expect(tree[0].child_nodes).to.have.length(1)
expect(tree[0].child_nodes[0].event.id).to.eq(reply_to_root.id)
expect(tree[0].child_nodes[0].child_nodes).to.be.length(0)
})
})
describe('1 grand parent, 1 parent, 1 child - out of order', () => {
test('returns array with only grand parent at top level with parent as its child, and child as parents child', async () => {
const grand_parent = await generateEventWithTags([])
const parent = await generateEventWithTags([
['e', grand_parent.id, '', 'reply'],
])
const child = await generateEventWithTags([
['e', parent.id, '', 'reply'],
])
const tree = createThreadTree([grand_parent, child, parent])
expect(tree).to.have.length(1)
expect(tree[0].event.id).to.eq(grand_parent.id)
expect(tree[0].child_nodes).to.have.length(1)
expect(tree[0].child_nodes[0].event.id).to.eq(parent.id)
expect(tree[0].child_nodes[0].child_nodes).to.have.length(1)
expect(tree[0].child_nodes[0].child_nodes[0].event.id).to.eq(child.id)
expect(
tree[0].child_nodes[0].child_nodes[0].child_nodes
).to.have.length(0)
})
})
describe('2 roots, 1 child', () => {
test('returns array with 2 roots at top level', async () => {
const root = await generateEventWithTags([])
const root2 = await generateEventWithTags([])
const reply_to_root = await generateEventWithTags([
['e', root.id, '', 'reply'],
])
const tree = createThreadTree([root, reply_to_root, root2])
expect(tree).to.have.length(2)
expect(tree[0].event.id).to.eq(root.id)
expect(tree[1].event.id).to.eq(root2.id)
expect(tree[1].child_nodes).to.have.length(0)
expect(tree[0].child_nodes).to.have.length(1)
expect(tree[0].child_nodes[0].event.id).to.eq(reply_to_root.id)
expect(tree[0].child_nodes[0].child_nodes).to.be.length(0)
})
})
})
})
describe('getThreadTrees', () => {
describe('issue', () => {
describe('2 roots, 1 child', () => {
test('array only contains node related to specified event and children', async () => {
const root = await generateEventWithTags([])
const root2 = await generateEventWithTags([])
const reply_to_root = await generateEventWithTags([
['e', root.id, '', 'reply'],
])
const trees = getThreadTrees('issue', root, [
root,
reply_to_root,
root2,
])
expect(trees).to.have.length(1)
expect(trees[0].event.id).to.eq(root.id)
expect(trees[0].child_nodes).to.have.length(1)
expect(trees[0].child_nodes[0].event.id).to.eq(reply_to_root.id)
expect(trees[0].child_nodes[0].child_nodes).to.be.length(0)
})
})
})
describe('proposal', () => {
describe('2 roots, 1 child', () => {
test('array only contains node related to specified event and children', async () => {
const root = await generateEventWithTags([])
const root2 = await generateEventWithTags([])
const reply_to_root = await generateEventWithTags([
['e', root.id, '', 'reply'],
])
const trees = getThreadTrees('proposal', root, [
root,
reply_to_root,
root2,
])
expect(trees).to.have.length(1)
expect(trees[0].event.id).to.eq(root.id)
expect(trees[0].child_nodes).to.have.length(1)
expect(trees[0].child_nodes[0].event.id).to.eq(reply_to_root.id)
expect(trees[0].child_nodes[0].child_nodes).to.be.length(0)
})
})
describe('2 roots, 1 reply, 1 revision', () => {
test('array contains node related to specified event with reply, and revision', async () => {
const root = await generateEventWithTags([])
const root2 = await generateEventWithTags([])
const reply_to_root = await generateEventWithTags([
['e', root.id, '', 'reply'],
])
const revision_of_root = await generateEventWithTags([
['e', root.id, '', 'reply'],
['t', 'revision-root'],
])
const trees = getThreadTrees('proposal', root, [
root,
reply_to_root,
root2,
revision_of_root,
])
expect(trees).to.have.length(2)
expect(trees[0].event.id).to.eq(root.id)
expect(trees[0].child_nodes).to.have.length(1)
expect(trees[0].child_nodes[0].event.id).to.eq(reply_to_root.id)
expect(trees[1].event.id).to.eq(revision_of_root.id)
})
})
})
describe('issue', () => {
describe('2 roots, 1 reply, 1 revision', () => {
test('array contains only node related to specified event with reply and revision as children', async () => {
const root = await generateEventWithTags([])
const root2 = await generateEventWithTags([])
const reply_to_root = await generateEventWithTags([
['e', root.id, '', 'reply'],
])
const revision_of_root = await generateEventWithTags([
['e', root.id, '', 'reply'],
['t', 'revision-root'],
])
const trees = getThreadTrees('issue', root, [
root,
reply_to_root,
root2,
revision_of_root,
])
expect(trees).to.have.length(1)
expect(trees[0].event.id).to.eq(root.id)
expect(trees[0].child_nodes).to.have.length(2)
expect(trees[0].child_nodes[0].event.id).to.eq(reply_to_root.id)
expect(trees[0].child_nodes[1].event.id).to.eq(revision_of_root.id)
})
})
})
})

70
src/lib/wrappers/thread_tree.ts

@ -1,70 +0,0 @@
import type { ThreadTreeNode } from '$lib/components/events/type'
import type { NDKEvent } from '@nostr-dev-kit/ndk'
export const getParentId = (reply: NDKEvent): string | undefined => {
const t =
reply.tags.find((tag) => tag.length === 4 && tag[3] === 'reply') ||
reply.tags.find((tag) => tag.length === 4 && tag[3] === 'root') ||
// include events that don't use nip 10 markers
reply.tags.find((tag) => tag.length < 4 && tag[0] === 'e')
return t ? t[1] : undefined
}
export const createThreadTree = (replies: NDKEvent[]): ThreadTreeNode[] => {
const hashTable: { [key: string]: ThreadTreeNode } = Object.create(null)
replies.forEach(
(reply) => (hashTable[reply.id] = { event: reply, child_nodes: [] })
)
const thread_tree: ThreadTreeNode[] = []
replies.forEach((reply) => {
const reply_parent_id = getParentId(reply)
if (reply_parent_id && hashTable[reply_parent_id]) {
hashTable[reply_parent_id].child_nodes.push(hashTable[reply.id])
hashTable[reply_parent_id].child_nodes.sort(
(a, b) => (a.event.created_at || 0) - (b.event.created_at || 0)
)
} else thread_tree.push(hashTable[reply.id])
})
return thread_tree
}
export const splitIntoRevisionThreadTrees = (
tree: ThreadTreeNode
): ThreadTreeNode[] => {
const thread_revision_trees: ThreadTreeNode[] = [
{
...tree,
child_nodes: [...tree?.child_nodes],
},
]
thread_revision_trees[0].child_nodes = [
...thread_revision_trees[0].child_nodes.filter((n) => {
if (n.event.tags.some((t) => t.length > 1 && t[1] === 'revision-root')) {
thread_revision_trees.push(n)
return false
}
return true
}),
]
return thread_revision_trees.sort(
(a, b) => (a.event.created_at || 0) - (b.event.created_at || 0)
)
}
export const getThreadTrees = (
type: 'proposal' | 'issue',
event: NDKEvent | undefined,
replies: NDKEvent[] | undefined
): ThreadTreeNode[] => {
if (event) {
const all_trees = createThreadTree(replies ? [event, ...replies] : [event])
const event_tree = all_trees.find((t) => t.event.id === event.id)
if (event_tree) {
// TODO: add 'mentions' and secondary references with a 'metioned event wrapper'
if (type === 'proposal') return splitIntoRevisionThreadTrees(event_tree)
return [event_tree]
}
}
return []
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save