├── .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 |
    7 |
    10 |
    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 | 2 | 3 | 14 | 15 | 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 |
    29 |
    30 | 31 |
    32 |
    33 | {/if} 34 | 35 |
    36 | 37 |
    38 | 39 | 40 |
    41 | 42 |