├── .node-version
├── .npmrc
├── static
├── ogp.png
├── favicon.png
├── apple-touch-icon.png
└── favicon.svg
├── assets
└── logo.afdesign
├── src
├── lib
│ ├── index.ts
│ ├── errors.ts
│ ├── helpers
│ │ ├── jsonLdTag.ts
│ │ ├── buildQuery.ts
│ │ ├── parseQuery.ts
│ │ └── search.ts
│ ├── components
│ │ ├── SearchResultNoteList.svelte
│ │ ├── ExternalLink.svelte
│ │ ├── FooterItem.svelte
│ │ ├── LoadingSpinner.svelte
│ │ ├── JsonLd.svelte
│ │ ├── Alert.svelte
│ │ ├── NoteList.svelte
│ │ ├── Header.svelte
│ │ ├── Footer.svelte
│ │ ├── HeaderMenu.svelte
│ │ ├── NoteListItemMenu.svelte
│ │ ├── AdvancedSearchModal.svelte
│ │ └── NoteListItem.svelte
│ ├── types.ts
│ ├── stores
│ │ └── profileStore.ts
│ └── actions
│ │ ├── inlineImage.ts
│ │ ├── linkify.ts
│ │ └── autocomplete.ts
├── app.postcss
├── routes
│ ├── +error.svelte
│ ├── +page.server.ts
│ ├── +layout.svelte
│ └── +page.svelte
├── app.d.ts
└── app.html
├── postcss.config.cjs
├── .gitignore
├── .eslintignore
├── .prettierignore
├── .prettierrc
├── vite.config.ts
├── .github
└── dependabot.yml
├── tsconfig.json
├── tailwind.config.ts
├── .eslintrc.cjs
├── svelte.config.js
├── README.md
└── package.json
/.node-version:
--------------------------------------------------------------------------------
1 | 20.2.0
2 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 | resolution-mode=highest
3 |
--------------------------------------------------------------------------------
/static/ogp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akiomik/nosey/HEAD/static/ogp.png
--------------------------------------------------------------------------------
/static/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akiomik/nosey/HEAD/static/favicon.png
--------------------------------------------------------------------------------
/assets/logo.afdesign:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akiomik/nosey/HEAD/assets/logo.afdesign
--------------------------------------------------------------------------------
/src/lib/index.ts:
--------------------------------------------------------------------------------
1 | // place files you want to import through the `$lib` alias in this folder.
2 |
--------------------------------------------------------------------------------
/static/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akiomik/nosey/HEAD/static/apple-touch-icon.png
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/src/lib/errors.ts:
--------------------------------------------------------------------------------
1 | export class HttpBadGatewayError extends Error {}
2 |
3 | export class HttpBadRequestError extends Error {}
4 |
--------------------------------------------------------------------------------
/src/lib/helpers/jsonLdTag.ts:
--------------------------------------------------------------------------------
1 | export function jsonLdTag(json: object): string {
2 | return ``;
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /build
4 | /.svelte-kit
5 | /package
6 | .env
7 | .env.*
8 | !.env.example
9 | vite.config.js.timestamp-*
10 | vite.config.ts.timestamp-*
11 |
--------------------------------------------------------------------------------
/src/app.postcss:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 | @tailwind variants;
5 |
6 | html,
7 | body {
8 | @apply h-full overflow-hidden;
9 | }
10 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /build
4 | /.svelte-kit
5 | /package
6 | .env
7 | .env.*
8 | !.env.example
9 |
10 | # Ignore files for PNPM, NPM and YARN
11 | pnpm-lock.yaml
12 | package-lock.json
13 | yarn.lock
14 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /build
4 | /.svelte-kit
5 | /package
6 | .env
7 | .env.*
8 | !.env.example
9 |
10 | # Ignore files for PNPM, NPM and YARN
11 | pnpm-lock.yaml
12 | package-lock.json
13 | yarn.lock
14 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "useTabs": false,
3 | "singleQuote": true,
4 | "trailingComma": "es5",
5 | "printWidth": 100,
6 | "plugins": ["prettier-plugin-svelte"],
7 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
8 | }
9 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { purgeCss } from 'vite-plugin-tailwind-purgecss';
2 | import { sveltekit } from '@sveltejs/kit/vite';
3 | import { defineConfig } from 'vite';
4 |
5 | export default defineConfig({
6 | plugins: [sveltekit(), purgeCss()],
7 | });
8 |
--------------------------------------------------------------------------------
/src/lib/components/SearchResultNoteList.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 | {#each result.data as record}
8 |
{record.content}
9 | {/each}
10 |
--------------------------------------------------------------------------------
/src/routes/+error.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 | Error
8 | {JSON.stringify($page.error)}
9 |
10 |
--------------------------------------------------------------------------------
/src/lib/components/ExternalLink.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/app.d.ts:
--------------------------------------------------------------------------------
1 | // See https://kit.svelte.dev/docs/types#app
2 | // for information about these interfaces
3 | declare global {
4 | namespace App {
5 | // interface Error {}
6 | // interface Locals {}
7 | // interface PageData {}
8 | // interface Platform {}
9 | }
10 | }
11 |
12 | export {};
13 |
--------------------------------------------------------------------------------
/src/lib/components/FooterItem.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/lib/components/LoadingSpinner.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
11 |
--------------------------------------------------------------------------------
/src/lib/components/JsonLd.svelte:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 | {@html jsonLdTag(jsonLd)}
19 |
--------------------------------------------------------------------------------
/src/lib/helpers/buildQuery.ts:
--------------------------------------------------------------------------------
1 | export function buildQuery(form: {
2 | keyword: string;
3 | from: string;
4 | since: string;
5 | until: string;
6 | }): string {
7 | let query = '';
8 | if (form.keyword) {
9 | query = form.keyword.trim();
10 | }
11 | if (form.from) {
12 | query = `${query} from:${form.from}`.trim();
13 | }
14 | if (form.since) {
15 | query = `${query} since:${form.since}`.trim();
16 | }
17 | if (form.until) {
18 | query = `${query} until:${form.until}`.trim();
19 | }
20 |
21 | return query;
22 | }
23 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: 'npm' # See documentation for possible values
9 | directory: '/' # Location of package manifests
10 | schedule:
11 | interval: 'weekly'
12 |
--------------------------------------------------------------------------------
/src/lib/components/Alert.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
23 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./.svelte-kit/tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true,
5 | "checkJs": true,
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "resolveJsonModule": true,
9 | "skipLibCheck": true,
10 | "sourceMap": true,
11 | "strict": true,
12 | "moduleResolution": "bundler"
13 | }
14 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
15 | //
16 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
17 | // from the referenced tsconfig.json - TypeScript does not merge them in
18 | }
19 |
--------------------------------------------------------------------------------
/static/favicon.svg:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import { join } from 'path';
2 | import type { Config } from 'tailwindcss';
3 | import forms from '@tailwindcss/forms';
4 | import { skeleton } from '@skeletonlabs/tw-plugin';
5 |
6 | export default {
7 | darkMode: 'class',
8 | content: [
9 | './src/**/*.{html,js,svelte,ts}',
10 | join(require.resolve('@skeletonlabs/skeleton'), '../**/*.{html,js,svelte,ts}'),
11 | ],
12 | theme: {
13 | extend: {},
14 | },
15 | plugins: [
16 | forms,
17 | skeleton({
18 | themes: {
19 | preset: [
20 | {
21 | name: 'skeleton',
22 | enhancements: false,
23 | },
24 | ],
25 | },
26 | }),
27 | ],
28 | } satisfies Config;
29 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: [
4 | 'eslint:recommended',
5 | 'plugin:@typescript-eslint/recommended',
6 | 'plugin:svelte/recommended',
7 | 'prettier',
8 | ],
9 | parser: '@typescript-eslint/parser',
10 | plugins: ['@typescript-eslint'],
11 | parserOptions: {
12 | sourceType: 'module',
13 | ecmaVersion: 2020,
14 | extraFileExtensions: ['.svelte'],
15 | },
16 | env: {
17 | browser: true,
18 | es2017: true,
19 | node: true,
20 | },
21 | overrides: [
22 | {
23 | files: ['*.svelte'],
24 | parser: 'svelte-eslint-parser',
25 | parserOptions: {
26 | parser: '@typescript-eslint/parser',
27 | },
28 | },
29 | ],
30 | };
31 |
--------------------------------------------------------------------------------
/svelte.config.js:
--------------------------------------------------------------------------------
1 | import adapter from '@sveltejs/adapter-auto';
2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
3 |
4 | /** @type {import('@sveltejs/kit').Config} */
5 | const config = {
6 | extensions: ['.svelte'],
7 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors
8 | // for more information about preprocessors
9 | preprocess: [vitePreprocess()],
10 |
11 | kit: {
12 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
13 | // If your environment is not supported or you settled on a specific environment, switch out the adapter.
14 | // See https://kit.svelte.dev/docs/adapters for more information about adapters.
15 | adapter: adapter(),
16 | },
17 | };
18 | export default config;
19 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # nosey
2 |
3 | Nosey is a search app for [nostr](https://nostr.com).
4 | Focus on supporting features similar to Twitter search directives.
5 |
6 | - https://nosey.vercel.app
7 |
8 | ## Features
9 |
10 | - `from:npub...` directive
11 | - `from:@...` for npub completion (using NIP-50)
12 | - `since:YYYY-MM-DD` and `until:YYYY-MM-DD` directives (limited support)
13 |
14 | ## Developing
15 |
16 | Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
17 |
18 | ```bash
19 | npm run dev
20 |
21 | # or start the server and open the app in a new browser tab
22 | npm run dev -- --open
23 | ```
24 |
25 | ## Building
26 |
27 | To create a production version of your app:
28 |
29 | ```bash
30 | npm run build
31 | ```
32 |
33 | You can preview the production build with `npm run preview`.
34 |
--------------------------------------------------------------------------------
/src/lib/types.ts:
--------------------------------------------------------------------------------
1 | import type * as Nostr from 'nostr-typedef';
2 |
3 | export type SearchQuery = {
4 | query: string;
5 | kind: number;
6 | pubkey: string;
7 | since: Date;
8 | until: Date;
9 | sort: 'time' | 'relevance';
10 | limit: number;
11 | page: number;
12 | };
13 |
14 | export type SearchResultPagination = {
15 | last_page: boolean;
16 | limit: number;
17 | next_url: string;
18 | page: number;
19 | total_pages: number;
20 | total_records: number;
21 | };
22 |
23 | export type SearchResult = {
24 | data: Nostr.Event[];
25 | pagination: SearchResultPagination;
26 | };
27 |
28 | export type Encoded = {
29 | [key in keyof A]: string;
30 | };
31 |
32 | export type PageData = {
33 | q: string;
34 | page: number;
35 | result: SearchResult;
36 | };
37 |
38 | export type Profile = Partial<{
39 | display_name: string;
40 | name: string;
41 | }>;
42 |
--------------------------------------------------------------------------------
/src/lib/components/NoteList.svelte:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 | {#each notes as note, i (`${note.id}-${i}`)}
25 |
26 | {:else}
27 |
28 | No data found 👀
29 |
30 | {/each}
31 |
32 |
--------------------------------------------------------------------------------
/src/lib/components/Header.svelte:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 | nosey
19 |
20 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/lib/components/Footer.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 | © 2023 nosey
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/lib/components/HeaderMenu.svelte:
--------------------------------------------------------------------------------
1 |
28 |
29 |
30 |
31 | -
32 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/src/lib/helpers/parseQuery.ts:
--------------------------------------------------------------------------------
1 | import { nip19 } from 'nostr-tools';
2 | import type { SearchQuery } from '$lib/types';
3 |
4 | const filters = ['since', 'until', 'from'];
5 |
6 | export function parseQuery(query: string): Partial {
7 | return query.split(' ').reduce((acc: Partial, keyword: string) => {
8 | const [filter, ...parameters] = keyword.split(':');
9 |
10 | const parameter = parameters.join(':');
11 | if (filters.includes(filter) && parameter !== '') {
12 | switch (filter) {
13 | case 'since':
14 | return { ...acc, since: new Date(parameter) };
15 | case 'until':
16 | return { ...acc, until: new Date(parameter) };
17 | case 'from': {
18 | try {
19 | const { data } = nip19.decode(parameter);
20 | return { ...acc, pubkey: data as string };
21 | } catch {
22 | return acc; // NOTE: ignore invalid pubkey
23 | }
24 | }
25 | default:
26 | throw new Error('unexpected');
27 | }
28 | }
29 |
30 | if (acc.query) {
31 | return { ...acc, query: `${acc.query} ${keyword}` };
32 | }
33 |
34 | return { ...acc, query: keyword };
35 | }, {} as Partial);
36 | }
37 |
--------------------------------------------------------------------------------
/src/routes/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { error } from '@sveltejs/kit';
2 | import type { RequestEvent } from '@sveltejs/kit';
3 | import { search } from '$lib/helpers/search';
4 | import { parseQuery } from '$lib/helpers/parseQuery';
5 | import { HttpBadRequestError, HttpBadGatewayError } from '$lib/errors';
6 |
7 | export async function load({ url }: RequestEvent) {
8 | const q = url.searchParams.get('q')?.trim();
9 | if (!q) {
10 | return;
11 | }
12 |
13 | const pageString = url.searchParams.get('page') ?? '0';
14 | let page = Number.parseInt(pageString, 10);
15 | if (Number.isNaN(page)) {
16 | page = 0;
17 | }
18 |
19 | try {
20 | const result = await search({
21 | ...parseQuery(q),
22 | kind: 1,
23 | limit: 100,
24 | sort: 'time',
25 | page: page + 1,
26 | });
27 |
28 | return { q, page, result };
29 | } catch (e) {
30 | // TODO: Improve error message
31 |
32 | if (e instanceof HttpBadRequestError) {
33 | throw error(400, JSON.parse(e.message));
34 | }
35 |
36 | if (e instanceof HttpBadGatewayError) {
37 | console.error('HttpBadGatewayError', url, e);
38 | throw error(502, JSON.parse(e.message));
39 | }
40 |
41 | console.error('Unknown Error', url, e);
42 | throw e;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/app.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
25 | %sveltekit.head%
26 |
27 |
28 | %sveltekit.body%
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/lib/stores/profileStore.ts:
--------------------------------------------------------------------------------
1 | import { createRxNostr, createRxOneshotReq, filterBy, verify, latestEach } from 'rx-nostr';
2 | import { readable, type Readable } from 'svelte/store';
3 | import { map } from 'rxjs';
4 | import type * as Nostr from 'nostr-typedef';
5 | import { browser } from '$app/environment';
6 |
7 | export const profileStore = (pubkeys: string[]): Readable> => {
8 | if (!browser) {
9 | return readable({});
10 | }
11 |
12 | return readable({}, (_, update) => {
13 | const rxNostr = createRxNostr();
14 | rxNostr.switchRelays(['wss://relay.damus.io', 'wss://nos.lol', 'wss://yabu.me']).then(() => {
15 | const req = createRxOneshotReq({
16 | filters: [{ kinds: [0], authors: pubkeys, limit: pubkeys.length }],
17 | });
18 |
19 | const sub = rxNostr.use(req).pipe(
20 | filterBy({ kinds: [0], authors: pubkeys }),
21 | verify(),
22 | latestEach(({ event }) => event.pubkey),
23 | map(({ event }) => event)
24 | );
25 |
26 | sub.subscribe((event) => {
27 | update(($profileMap: Record) => {
28 | if ($profileMap[event.pubkey]) {
29 | return { ...$profileMap };
30 | }
31 |
32 | return { ...$profileMap, ...Object.fromEntries([[event.pubkey, event]]) };
33 | });
34 | });
35 | });
36 |
37 | return () => rxNostr.dispose();
38 | });
39 | };
40 |
--------------------------------------------------------------------------------
/src/routes/+layout.svelte:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | {#if $navigating}
28 |
33 | {/if}
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/src/lib/helpers/search.ts:
--------------------------------------------------------------------------------
1 | import type { SearchQuery, SearchResult, Encoded } from '$lib/types';
2 | import { HttpBadRequestError, HttpBadGatewayError } from '$lib/errors';
3 |
4 | function encode(query: Partial): Encoded> {
5 | const entries = Object.entries(query)
6 | .map(([key, value]) => {
7 | if (value == null || value == '') {
8 | return [key, ''];
9 | }
10 |
11 | if (value instanceof Date) {
12 | if (Number.isNaN(value.getTime())) {
13 | return [key, ''];
14 | }
15 |
16 | const timestamp = Math.floor(value.getTime() / 1000);
17 | return [key, timestamp.toString()];
18 | }
19 |
20 | return [key, value.toString()];
21 | })
22 | .filter(([, value]) => value !== '');
23 |
24 | return Object.fromEntries(entries);
25 | }
26 |
27 | export async function search(query: Partial): Promise {
28 | // TODO: Don't search when query is empty
29 | const params = new URLSearchParams(encode(query));
30 | const url = `https://api.nostr.wine/search?${params}`;
31 | console.debug(url, query);
32 |
33 | const res = await fetch(url);
34 | const data = await res.json();
35 |
36 | if (!res.ok) {
37 | if (res.status < 500) {
38 | throw new HttpBadRequestError(JSON.stringify(data.error));
39 | } else {
40 | throw new HttpBadGatewayError(JSON.stringify(data.error));
41 | }
42 | }
43 |
44 | return data;
45 | }
46 |
--------------------------------------------------------------------------------
/src/lib/components/NoteListItemMenu.svelte:
--------------------------------------------------------------------------------
1 |
20 |
21 |
22 |
23 | -
24 |
27 |
28 | -
29 |
32 |
33 | -
34 |
37 |
38 | -
39 |
40 |
41 | -
42 | Open author with njump.me
43 |
44 | -
45 | Open note with njump.me
46 |
47 | -
48 |
51 | See posts around this on Nosaray
52 |
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/src/lib/actions/inlineImage.ts:
--------------------------------------------------------------------------------
1 | import urlRegexSafe from 'url-regex-safe';
2 | // import type { Action } from 'svelte/types/runtime/action';
3 |
4 | export type Opts = {
5 | extPattern: RegExp;
6 | validate: (value: string, extensions: RegExp) => boolean;
7 | attributes: object;
8 | tagName: string;
9 | className: string;
10 | render: (tagName: string, attributes: object) => string;
11 | };
12 |
13 | const defaultOpts: Opts = {
14 | extPattern: /(jpe?g|png|gif|webp)$/,
15 | validate: (value: string, extPattern: RegExp) => {
16 | try {
17 | return new URL(value.toLowerCase()).pathname.match(extPattern) !== null;
18 | } catch {
19 | return false;
20 | }
21 | },
22 | attributes: {},
23 | tagName: 'img',
24 | className: '',
25 | render: (tagName: string, attributes: object) => {
26 | const encodedAttrs = Object.entries(attributes)
27 | .map(([key, value]) => `${key}="${value}"`)
28 | .join(' ');
29 | return `<${tagName} ${encodedAttrs}>`;
30 | },
31 | };
32 |
33 | function uniq(xs: T[]): T[] {
34 | return [...new Set(xs)];
35 | }
36 |
37 | // TODO: Add `Action` type
38 | export const inlineImage = (node: HTMLElement, opts: Partial) => {
39 | const mergedOpts = { ...defaultOpts, ...opts };
40 |
41 | const matches = node.innerHTML.matchAll(urlRegexSafe());
42 | if (matches === null) {
43 | return;
44 | }
45 |
46 | // TODO: traverse innerText of children
47 | let text = node.innerHTML;
48 | uniq([...matches].map((m) => m[0])).forEach((match) => {
49 | const urlString = match;
50 | if (!mergedOpts.validate(urlString, mergedOpts.extPattern)) {
51 | return;
52 | }
53 |
54 | const attributes = {
55 | ...mergedOpts.attributes,
56 | src: urlString,
57 | class: mergedOpts.className,
58 | };
59 | const tag = mergedOpts.render(mergedOpts.tagName, attributes);
60 | text = text.replaceAll(urlString, tag);
61 | });
62 |
63 | node.innerHTML = text;
64 | };
65 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nosey",
3 | "version": "0.0.1",
4 | "private": true,
5 | "scripts": {
6 | "dev": "vite dev",
7 | "build": "vite build",
8 | "preview": "vite preview",
9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
11 | "lint": "prettier --check . && eslint .",
12 | "format": "prettier --write ."
13 | },
14 | "devDependencies": {
15 | "@skeletonlabs/skeleton": "^2.10.3",
16 | "@skeletonlabs/tw-plugin": "^0.4.0",
17 | "@sveltejs/adapter-auto": "^3.3.1",
18 | "@sveltejs/kit": "^2.8.3",
19 | "@sveltejs/vite-plugin-svelte": "^3.1.1",
20 | "@tailwindcss/forms": "^0.5.9",
21 | "@types/node": "^22.7.4",
22 | "@types/url-regex-safe": "^1.0.0",
23 | "@typescript-eslint/eslint-plugin": "^7.0.0",
24 | "@typescript-eslint/parser": "^6.18.1",
25 | "autoprefixer": "^10.4.20",
26 | "eslint": "^8.57.0",
27 | "eslint-config-prettier": "^9.1.0",
28 | "eslint-plugin-svelte": "^2.46.1",
29 | "nostr-typedef": "^0.9.0",
30 | "postcss": "^8.4.43",
31 | "prettier": "^3.3.3",
32 | "prettier-plugin-svelte": "^3.3.2",
33 | "prettier-plugin-tailwindcss": "^0.6.9",
34 | "svelte": "^4.2.19",
35 | "svelte-check": "^4.0.5",
36 | "tailwindcss": "^3.4.14",
37 | "tslib": "^2.6.3",
38 | "typescript": "^5.7.2",
39 | "vite": "^5.4.14",
40 | "vite-plugin-tailwind-purgecss": "^0.3.3"
41 | },
42 | "type": "module",
43 | "dependencies": {
44 | "@floating-ui/dom": "^1.6.12",
45 | "@fortawesome/free-brands-svg-icons": "^6.7.2",
46 | "@fortawesome/free-solid-svg-icons": "^6.6.0",
47 | "@fortawesome/svelte-fontawesome": "^0.2.3",
48 | "html-entities": "^2.5.2",
49 | "linkify-html": "^4.2.0",
50 | "linkify-plugin-mention": "^4.1.3",
51 | "linkifyjs": "^4.1.1",
52 | "nostr-tools": "^2.10.4",
53 | "rx-nostr": "^1.3.1",
54 | "tributejs": "^5.1.3",
55 | "url-regex-safe": "^4.0.0"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/lib/actions/linkify.ts:
--------------------------------------------------------------------------------
1 | import linkifyHtml from 'linkify-html';
2 | import 'linkify-plugin-mention';
3 | // import type { Action } from 'svelte/runtime/action/public';
4 | import type { Opts } from 'linkifyjs';
5 |
6 | export const linkifyOpts = {
7 | className: 'underline',
8 | target: '_blank',
9 | rel: 'external noreferrer',
10 | format: (value: string, type: string) => {
11 | if (type === 'mention') {
12 | return `${value.substring(0, 9)}:${value.substring(value.length - 8, value.length)}`;
13 | }
14 |
15 | return value;
16 | },
17 | formatHref: (href: string, type: string) => {
18 | if (type === 'hashtag') {
19 | return `https://snort.social/t/${href.substring(1)}`;
20 | } else if (type === 'mention' && (href.startsWith('/npub1') || href.startsWith('/nprofile1'))) {
21 | return `https://njump.me/${href.substring(1)}`;
22 | } else if (type === 'mention' && (href.startsWith('/note1') || href.startsWith('/nevent1'))) {
23 | return `https://njump.me/${href.substring(1)}`;
24 | } else {
25 | return href;
26 | }
27 | },
28 | truncate: 54,
29 | validate: (value: string, type: string) => {
30 | if (type === 'url' && value.startsWith('http')) {
31 | return true;
32 | }
33 |
34 | if (type === 'mention' && value.startsWith('@npub1') && value.length === 64) {
35 | return true;
36 | }
37 |
38 | if (type === 'mention' && value.startsWith('@nprofile1') && value.length >= 71) {
39 | return true;
40 | }
41 |
42 | if (type === 'mention' && value.startsWith('@note1') && value.length === 64) {
43 | return true;
44 | }
45 |
46 | if (type === 'mention' && value.startsWith('@nevent1') && value.length >= 78) {
47 | return true;
48 | }
49 |
50 | if (type === 'tag') {
51 | return true;
52 | }
53 |
54 | return false;
55 | },
56 | nl2br: true,
57 | };
58 |
59 | // TODO: Add `Action` type
60 | export const linkify = (element: HTMLElement, opts: Opts) => {
61 | element.innerHTML = linkifyHtml(element.innerHTML, opts);
62 | };
63 |
--------------------------------------------------------------------------------
/src/lib/components/AdvancedSearchModal.svelte:
--------------------------------------------------------------------------------
1 |
29 |
30 | {#if $modalStore[0]}
31 |
32 |
33 |
34 |
73 |
74 |
80 |
81 | {/if}
82 |
--------------------------------------------------------------------------------
/src/lib/components/NoteListItem.svelte:
--------------------------------------------------------------------------------
1 |
35 |
36 |
37 |
38 |
39 |
46 |
47 |
48 | {nameOrPubkey}
49 |
50 |
51 |
54 |
55 |
56 |
57 |
58 |
66 | {noteContent}
67 |
68 |
69 |
70 |
71 |
72 |
79 |
80 |
--------------------------------------------------------------------------------
/src/lib/actions/autocomplete.ts:
--------------------------------------------------------------------------------
1 | import { nip19 } from 'nostr-tools';
2 | import { createRxNostr, createRxOneshotReq, filterBy, verify, latestEach } from 'rx-nostr';
3 | import { map, toArray } from 'rxjs';
4 | import { encode } from 'html-entities';
5 | import { browser } from '$app/environment';
6 |
7 | export type Opts = {
8 | containerElement: HTMLElement;
9 | relays: string[];
10 | prefix: string;
11 | };
12 |
13 | export type Item = {
14 | pubkey: string;
15 | content: string;
16 | name: string;
17 | picture: string;
18 | nip05: string;
19 | };
20 |
21 | export const autocomplete = (node: HTMLElement, opts: Partial) => {
22 | if (!browser || !node || !opts.containerElement || !opts.relays) {
23 | return;
24 | }
25 |
26 | const asyncDisposer = import('tributejs')
27 | .then(({ default: Tribute }) => {
28 | const rxNostr = createRxNostr();
29 | rxNostr.switchRelays(opts.relays ?? []);
30 |
31 | const tribute = new Tribute({
32 | trigger: `${opts.prefix}@`,
33 | menuContainer: opts.containerElement,
34 | requireLeadingSpace: false,
35 | containerClass: 'list-nav card p-4 z-[300] mt-2',
36 | itemClass: 'flex justify-start items-center gap-2',
37 | selectTemplate: (item: { original: Item }) =>
38 | `${opts.prefix}${nip19.npubEncode(item.original.pubkey)}`,
39 | menuItemTemplate: (item: { original: Item }) => {
40 | const picture = item.original.picture
41 | ? `
`
42 | : ``;
43 | const name = `${item.original.name}`;
44 | const nip05 = item.original.nip05
45 | ? `${item.original.nip05}`
46 | : '';
47 | return `${picture}${name}${nip05}`;
48 | },
49 | lookup: 'content',
50 | fillAttr: 'name',
51 | searchOpts: {
52 | pre: '',
53 | post: '',
54 | skip: true,
55 | },
56 | menuItemLimit: 10,
57 | values: (text, callback) => {
58 | if (text === '') {
59 | return callback([]);
60 | }
61 |
62 | const req = createRxOneshotReq({ filters: [{ kinds: [0], search: text, limit: 10 }] });
63 |
64 | rxNostr
65 | .use(req)
66 | .pipe(
67 | filterBy({ kinds: [0], search: text }),
68 | verify(),
69 | latestEach(({ event }) => event.pubkey),
70 | map(({ event }) => event),
71 | toArray()
72 | )
73 | .subscribe((events) => {
74 | // NOTE: Tribute has an XSS issue (https://github.com/zurb/tribute/issues/833)
75 | // TODO: filter value if validName is blank
76 | const values = events.map(({ pubkey, content }) => {
77 | const { name, display_name: displayName, picture, nip05 } = JSON.parse(content);
78 | const validName = encode(name ?? displayName ?? pubkey);
79 |
80 | return { pubkey, content, name: validName, picture, nip05 };
81 | });
82 | callback(values);
83 | });
84 | },
85 | });
86 |
87 | tribute.attach(node);
88 |
89 | return () => {
90 | tribute.detach(node);
91 | rxNostr.dispose();
92 | };
93 | })
94 | .catch((e) => {
95 | console.error('failed to execute autocomplete', e);
96 | });
97 |
98 | return {
99 | destroy() {
100 | asyncDisposer
101 | .then((disposer) => {
102 | if (typeof disposer === 'function') {
103 | disposer();
104 | }
105 | })
106 | .catch((e) => {
107 | console.error('failed to execute destroy', e);
108 | });
109 | },
110 | };
111 | };
112 |
--------------------------------------------------------------------------------
/src/routes/+page.svelte:
--------------------------------------------------------------------------------
1 |
46 |
47 |
48 | {#if isInitial}
49 | nosey | A Nostr searcher
50 | {:else}
51 | {q} - nosey
52 | {/if}
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
118 |
119 | {#if data.result}
120 |
121 |
130 | {/if}
131 |
--------------------------------------------------------------------------------