1 Commits

Author SHA1 Message Date
dependabot[bot]
f532cbe6ff chore(deps): bump nanoid from 3.3.7 to 3.3.8
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-17 07:47:57 +00:00
618 changed files with 14063 additions and 59332 deletions

View File

@@ -1,33 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
dev-dist
*.local
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Git
.gitignore
#Docker files
docker-compose.yml
Dockerfile

9
.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

4
.eslintignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
out
.gitignore

14
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,14 @@
module.exports = {
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'@electron-toolkit/eslint-config-ts/recommended',
'@electron-toolkit/eslint-config-prettier'
],
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-explicit-any': 'off'
}
}

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: [CodyTseng]

57
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Build/release
on:
push:
tags:
- v*.*.*
permissions:
contents: write
jobs:
release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-13, windows-latest]
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install Dependencies
run: npm install
- name: build-linux
if: matrix.os == 'ubuntu-latest'
run: npm run build:linux
- name: build-mac
if: matrix.os == 'macos-13'
run: npm run build:mac
- name: build-win
if: matrix.os == 'windows-latest'
run: npm run build:win
- name: release
uses: softprops/action-gh-release@v2
with:
draft: true
files: |
dist/*.exe
dist/*.zip
dist/*.dmg
dist/*.AppImage
dist/*.snap
dist/*.deb
dist/*.rpm
dist/*.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}

27
.gitignore vendored
View File

@@ -1,28 +1,5 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
dev-dist
*.local
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
out
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vercel
*.log*

View File

@@ -2,4 +2,5 @@ out
dist
pnpm-lock.yaml
LICENSE.md
*.json
tsconfig.json
tsconfig.*.json

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint"]
}

39
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,39 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
},
"runtimeArgs": ["--sourcemap"],
"env": {
"REMOTE_DEBUGGING_PORT": "9222"
}
},
{
"name": "Debug Renderer Process",
"port": 9222,
"request": "attach",
"type": "chrome",
"webRoot": "${workspaceFolder}/src/renderer",
"timeout": 60000,
"presentation": {
"hidden": true
}
}
],
"compounds": [
{
"name": "Debug All",
"configurations": ["Debug Main Process", "Debug Renderer Process"],
"presentation": {
"order": 1
}
}
]
}

11
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

410
AGENTS.md
View File

@@ -1,410 +0,0 @@
# AGENTS.md
This document is designed to help AI Agents better understand and modify the Jumble project.
## Project Overview
Jumble is a user-friendly Nostr client for exploring relay feeds.
- **Project Name**: Jumble
- **Main Tech Stack**: React 18 + TypeScript + Vite
- **UI Framework**: Tailwind CSS + Radix UI
- **State Management**: Jotai
- **Core Protocol**: Nostr (using nostr-tools)
## Technical Architecture
### Core Dependencies
- **Build Tool**: Vite 5.x
- **Frontend Framework**: React 18.3.x + TypeScript
- **Styling Solution**:
- Tailwind CSS (primary styling framework)
- Radix UI (unstyled component library)
- next-themes (theme management)
- tailwindcss-animate (animations)
- **State Management**: Jotai 2.x
- **Routing**: path-to-regexp (custom routing solution)
- **Rich Text Editor**: TipTap 2.x
- **Nostr Protocol**: nostr-tools 2.x
- **Other Key Libraries**:
- i18next (internationalization)
- dayjs (date handling)
- flexsearch (search)
- qr-code-styling (QR codes)
- yet-another-react-lightbox (image viewer)
### Project Structure
```
jumble/
├── src/
│ ├── components/ # React components
│ │ ├── ui/ # Base UI components (shadcn/ui style)
│ │ └── ... # Other feature components
│ ├── providers/ # React Context Providers
│ ├── services/ # Business logic service layer
│ ├── hooks/ # Custom React Hooks
│ ├── lib/ # Utility functions and libraries
│ ├── types/ # TypeScript type definitions
│ ├── pages/ # Page components
| | ├── primary # Primary page components (Left column)
│ │ └── secondary # secondary page components (Right column)
│ ├── layouts/ # Layout components
│ ├── i18n/ # Internationalization resources
| | ├── locales # Localization files
│ │ └── index.tx # Basic i18n setup
│ ├── assets/ # Static assets
│ ├── App.tsx # App root component
│ ├── PageManager.tsx # Page manager (custom routing logic)
│ ├── routes # Route configuration
| | ├── primary.tsx # Primary routes (Left column)
│ │ └── secondary.tsx # Secondary routes (Right column)
│ └── constants.ts # Constants definition
├── public/ # Public static assets
└── resources/ # Design resources
```
## Development Guide
### Environment Setup
### Environment Setup
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
# Lint code
npm run lint
# Format code
npm run format
```
## Code Conventions
### Component Development
1. **Component Structure**: Each major feature component is typically in its own folder, containing index.tsx and related sub-components
2. **Styling**: Use Tailwind CSS utility classes, complex components can use class-variance-authority (cva)
3. **Type Safety**: All components should have explicit TypeScript type definitions
4. **State Management**:
- Use Jotai atoms for global state management
- Use Context Providers for cross-component data
### Service Layer (Services)
Service files located in `src/services/` encapsulate business logic:
- `client.service.ts` - Nostr client core logic for fetching and publishing events
- `indexed-db.service.ts` - IndexedDB data storage
- `local-storage.service.ts` - LocalStorage management
- `media-upload.service.ts` - Media upload service
- `translation.service.ts` - Translation service
- `lightning.service.ts` - Lightning Network integration
- `relay-info.service.ts` - Relay information management
- `blossom.service.ts` - Blossom integration
- `custom-emoji.service.ts` - Custom emoji management
- `libre-translate.service.ts` - LibreTranslate API integration
- `media-manager.service.ts` - Managing media play state
- `modal-manager.service.ts` - Managing modal stack for back navigation (ensures modals close one by one before actual page navigation)
- `note-stats.service.ts` - Note statistics storage and retrieval (likes, zaps, reposts)
- `poll-results.service.ts` - Poll results storage and retrieval
- `post-editor-cache.service.ts` - Caching post editor content to prevent data loss
- `web.push.service.ts` - Web metadata fetching for link previews
### Providers Architecture
The app uses a multi-layered Provider nesting structure (see `App.tsx`):
```
ScreenSizeProvider
└─ UserPreferencesProvider
└─ ThemeProvider
└─ ContentPolicyProvider
└─ NostrProvider
└─ ... (more providers)
```
Pay attention to Provider dependencies when modifying functionality.
And some Providers are placed in `PageManager.tsx` because they need to use the `usePrimaryPage` and `useSecondaryPage` hooks.
### Routing System
- Route configuration in `src/routes/primary.tsx` and `src/routes/secondary.tsx`
- Using `PageManager.tsx` to manage page navigation, rendering, and state. Normally, you don't need to modify this file.
- Primary pages (left column) use key-based navigation
- Secondary pages (right column) use path-based navigation with stack support
- More details in "Adding a New Page" section below
### Internationalization (i18n)
Jumble is a multi-language application. When you add new text content, please ensure to add translations for all supported languages as much as possible. Append new translations to the end of each translation file without modifying or removing existing keys.
- Translation files located in `src/i18n/locales/`
- Using `react-i18next` for internationalization
- Supported languages: ar, de, en, es, fa, fr, hi, hu, it, ja, ko, pl, pt-BR, pt-PT, ru, th, zh
#### Adding New Language
1. Create a new file in `src/i18n/locales/` with the language code (e.g., `th.ts` for Thai)
2. According to `src/i18n/locales/en.ts`, add translation key-value pairs
3. Update `src/i18n/index.ts` to include the new language resource
4. Update `detectLanguage` function in `src/lib/utils.ts` to support detecting the new language
## Nostr Protocol Integration
### Core Concepts
- **Events**: Nostr events (notes, profile updates, etc.). All data in Nostr is represented as events. They have different kinds (kinds) to represent different types of data.
- **Relays**: Relay servers, which are WebSocket servers that store and forward Nostr events.
- **NIPs**: Nostr Implementation Proposals
### Supported Event Kinds
I mean kinds that are supported to be displayed in the feed.
- Kind 1: Short Text Note
- Kind 6: Repost
- Kind 20: Picture Note
- Kind 21: Video Note
- Kind 22: Short Video Note
- Kind 1068: Poll
- Kind 1111: Comment
- Kind 1222: Voice Note
- Kind 1244: Voice Comment
- Kind 9802: Highlight
- Kind 30023: Long-Form Article
- Kind 31987: Relay Review
- Kind 34550: Community Definition
- Kind 30311: Live Event
- Kind 39000: Group Metadata
- Kind 30030: Emoji Pack
More details you can find in `src/components/Note/`. If you want to add support for new kinds, you need to create new components under `src/components/Note/` and update `src/components/Note/index.tsx`.
And also you need to update `src/components/ContentPreview/` to support preview rendering for the new kinds. `ContentPreview` is used in various places like parent notes, notifications, highlight sources, etc. It only has one line of text space, so you need to figure out a suitable preview display method for different types of content. Use text only as much as possible.
Please avoid modifying the framework, such as avatars, usernames, timestamps, and action buttons in the `Note` component. Only add content rendering logic for new types.
## Common Components
### src/components/Note
Used to display a Nostr event (note).
Properties:
- `event`: `NoteEvent` - The Nostr event to display
- `hideParentNotePreview`: `boolean` - Whether to hide the parent note preview
- `showFull`: `boolean` - Whether to show the full content of the note. Default is `false`, which shows a truncated version with "Show more" option when content is long.
### src/components/NoteList
Used to display a list of notes with infinite scrolling support.
Properties:
- `subRequests`: `{ urls: string[]; filter: Omit<Filter, 'since' | 'until'> }[]` - Array of Nostr subscription requests to fetch notes
- `urls`: Relay URLs for the subscription
- `filter`: Nostr filter for the subscription (without `since`, `until` and `limit`, which are managed internally)
- `showKinds`: `number[]` - Array of event kinds to display
- `filterMutedNotes`: `boolean` - Whether to filter out muted notes
- `hideReplies`: `boolean` - Whether to hide reply notes
- `hideUntrustedNotes`: `boolean` - Whether to hide notes from untrusted authors
- `filterFn`: `(note: NoteEvent) => boolean` - Custom filter function for notes. Return `true` to display the note, `false` to hide it.
### src/components/Tabs
A tab component for switching between different views.
Properties:
- `tabs`: `{ value: string; label: string }[]` - Array of tab definitions. `value` is the unique identifier for the tab, `label` is the display text. `label` will be passed through `t()` for translation.
- `value`: `string` - Currently selected tab value.
- `onChange`: `(value: string) => void` - Callback function when the selected tab changes.
- `threshold`: `number` - Height threshold for hiding the tab bar on scroll down. Default is `800`. It should larger than the height of the area above the tab bar. Normally you don't need to change this value.
- `options`: `React.ReactNode` - Additional options to display on the right side of the tab bar.
## Common Modification Scenarios
### Adding a New Component
1. Create a component folder in `src/components/`
2. Create `index.tsx` and necessary sub-components
3. Write styles using Tailwind CSS
4. If needed, add base UI components in `src/components/ui/`
### Adding a New Page
#### Adding a Primary Page (Left Column)
Primary pages are the main navigation pages that appear in the left column (or full screen on mobile).
1. **Create the page component**:
```bash
# Create a new folder under src/pages/primary/
mkdir src/pages/primary/YourNewPage
```
2. **Implement the component** (`src/pages/primary/YourNewPage/index.tsx`):
```tsx
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { TPageRef } from '@/types'
import { forwardRef } from 'react'
const YourNewPage = forwardRef<TPageRef>((_, ref) => {
return (
<PrimaryPageLayout ref={ref} title="Your Page Title" icon={<YourIcon />}>
{/* Your page content */}
</PrimaryPageLayout>
)
})
export default YourNewPage
```
**Important**:
- Primary pages MUST use `forwardRef<TPageRef>`
- Wrap content with `PrimaryPageLayout`
- The ref is used by PageManager for navigation control
3. **Register the route** in `src/routes/primary.tsx`:
```tsx
import YourNewPage from '@/pages/primary/YourNewPage'
const PRIMARY_ROUTE_CONFIGS: RouteConfig[] = [
// ... existing routes
{ key: 'yourNewPage', component: YourNewPage }
]
```
4. **Navigate to the page** using the `usePrimaryPage` hook:
```tsx
import { usePrimaryPage } from '@/PageManager'
const { navigate } = usePrimaryPage()
navigate('yourNewPage')
```
#### Adding a Secondary Page (Right Column)
Secondary pages appear in the right column (or full screen on mobile) and support stack-based navigation.
1. **Create the page component**:
```bash
# Create a new folder under src/pages/secondary/
mkdir src/pages/secondary/YourNewPage
```
2. **Implement the component** (`src/pages/secondary/YourNewPage/index.tsx`):
```tsx
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef } from 'react'
const YourNewPage = forwardRef(({ index }: { index?: number }, ref) => {
return (
<SecondaryPageLayout ref={ref} index={index} title="Your Page Title">
{/* Your page content */}
</SecondaryPageLayout>
)
})
export default YourNewPage
```
**Important**:
- Secondary pages receive an `index` prop for stack navigation
- Use `SecondaryPageLayout` for consistent styling
- The ref enables navigation control
3. **Register the route** in `src/routes/secondary.tsx`:
```tsx
import YourNewPage from '@/pages/secondary/YourNewPage'
const SECONDARY_ROUTE_CONFIGS = [
// ... existing routes
{ path: '/your-path/:id', element: <YourNewPage /> }
]
```
Add the corresponding path generation function in `src/lib/link.ts` for the new route:
```tsx
export const toYourNewPage = (id: string) => `/your-path/${id}`
```
4. **Navigate to the page**:
```tsx
import { useSecondaryPage } from '@/PageManager'
import { toYourNewPage } from '@/lib/link'
const { push, pop } = useSecondaryPage()
// Navigate to new page
push(toYourNewPage('some-id'))
// Navigate back
pop()
```
5. **Access route parameters**:
```tsx
const YourNewPage = forwardRef(({ id, index }: { id?: string; index?: number }, ref) => {
console.log('Route param id:', id)
// ...
})
```
#### Key Differences
| Aspect | Primary Pages | Secondary Pages |
| -------------- | ----------------------------------- | ------------------------------- |
| **Location** | Left column (main navigation) | Right column (detail view) |
| **Navigation** | Replace-based (`navigate`) | Stack-based (`push`/`pop`) |
| **Layout** | `PrimaryPageLayout` | `SecondaryPageLayout` |
| **Routes** | Key-based (e.g., 'home', 'explore') | Path-based (e.g., '/notes/:id') |
On mobile devices or single-column layouts, primary pages occupy the full screen, while secondary pages are accessed via stack navigation. When navigating to another primary page, it will clear the secondary page stack.
### How to Parse and Render Content
First, use the `parseContent` method in `src/lib/content-parser.ts` to parse the content. It supports passing different parsers to parse only the needed content for different scenarios. You will get an array of `TEmbeddedNode[]`, and render the content according to the type of these nodes in order. If you need to support new node types, you can add new parsing methods in `src/lib/content-parser.ts`. If you want to recognize specific URLs as special types of nodes, you can extend the `EmbeddedUrlParser` method in `src/lib/content-parser.ts`. A complete usage example can be found in `src/components/Content/index.tsx`.
### Adding New State Management
1. For global state, create a new Provider in `src/providers/`
2. Add Provider in `App.tsx` in the correct dependency order
Or create a singleton service in `src/services/` and use Jotai atoms for state management.
### Adding New Business Logic
1. Create a new service file in `src/services/`
2. Export singleton instance
3. Import and use in anywhere needed
### Style Modifications
- Global styles: `src/index.css`
- Tailwind configuration: `tailwind.config.js`
- Component styles: Use Tailwind class names directly

View File

@@ -1,49 +0,0 @@
# Step 1: Build the application
FROM node:20-alpine as builder
ARG VITE_PROXY_SERVER
ENV VITE_PROXY_SERVER=${VITE_PROXY_SERVER}
WORKDIR /app
# Copy package files first
COPY package*.json ./
RUN npm install
# Copy the source code to prevent invaliding cache whenever there is a change in the code
COPY . .
RUN npm run build
# Step 2: Final container with Nginx and embedded config
FROM nginx:alpine
# Copy only the generated static files
COPY --from=builder /app/dist /usr/share/nginx/html
# Embed Nginx configuration directly
RUN printf "server {\n\
listen 80;\n\
server_name localhost;\n\
root /usr/share/nginx/html;\n\
index index.html;\n\
\n\
location / {\n\
try_files \$uri \$uri/ /index.html;\n\
}\n\
\n\
location ~* \\.(?:js|css|woff2?|ttf|otf|eot|ico|jpg|jpeg|png|gif|svg|webp)\$ {\n\
expires 30d;\n\
access_log off;\n\
add_header Cache-Control \"public\";\n\
}\n\
\n\
gzip on;\n\
gzip_types text/plain application/javascript application/x-javascript text/javascript text/css application/json;\n\
gzip_proxied any;\n\
gzip_min_length 1024;\n\
gzip_comp_level 6;\n\
}\n" > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,6 +1,6 @@
MIT LICENSE
MIT License
Copyright (c) 2025 Cody Tseng
Copyright (c) 2024 Cody Tseng
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,5 +1,7 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./resources/logo-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="./resources/logo-light.svg">
<img src="./resources/logo-light.svg" alt="Jumble Logo" width="400" />
</picture>
<p>logo designed by <a href="http://wolfertdan.com/">Daniel David</a></p>
@@ -7,19 +9,31 @@
# Jumble
A user-friendly Nostr client for exploring relay feeds
A beautiful nostr client focused on browsing relay feeds
Experience Jumble at [https://jumble.social](https://jumble.social)
## Features
## Forks
- **Relay-Based Browsing:** Explore content directly through relays without following specific users
- **Relay-Friendly Design:** Minimized and simplified requests ensure efficient communication with relays
- **Relay Groups:** Easily manage and switch between relay groups
- **Clean Interface:** Enjoy a minimalist design and intuitive interactions
- **Cross-Platform:** Available on macOS, Windows, Linux, and web browsers
> Some interesting forks of Jumble.
## Web Version
- [https://fevela.me/](https://fevela.me/) - by [@daniele](https://jumble.social/users/npub10000003zmk89narqpczy4ff6rnuht2wu05na7kpnh3mak7z2tqzsv8vwqk)
- [https://x21.com/](https://x21.com/) - by [@Karnage](https://jumble.social/users/npub1r0rs5q2gk0e3dk3nlc7gnu378ec6cnlenqp8a3cjhyzu6f8k5sgs4sq9ac)
- [https://jumble.imwald.eu/](https://jumble.imwald.eu/) Repo: [Silberengel/jumble](https://github.com/Silberengel/jumble) - by [@Silberengel](https://jumble.social/users/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z)
You can use the web version of Jumble at [jumble.social](https://jumble.social).
## Run Locally
## Desktop Version
You can download the desktop version from the [release page](https://github.com/CodyTseng/jumble/releases). If you want to use Apple Silicon version, you need to build it from the source code.
Because the app is not signed, you may need to allow it to run in the system settings.
## Build from source
You can also build the app from the source code.
> Note: Node.js >= 20 is required.
```bash
# Clone this repository
@@ -31,38 +45,18 @@ cd jumble
# Install dependencies
npm install
# Run the app
npm run dev
# Build the app
npm run build:mac
# or npm run build:win
# or npm run build:linux
# or npm run build:web
```
## Run Docker
```bash
# Clone this repository
git clone https://github.com/CodyTseng/jumble.git
# Go into the repository
cd jumble
# Run the docker compose
docker compose up --build -d
```
After finishing, access: http://localhost:8089
## Sponsors
<a target="_blank" href="https://opensats.org/">
<img alt="open-sats-logo" src="./resources/open-sats-logo.svg" height="44">
</a>
The executable file will be in the `dist` folder.
## Donate
If you like this project, you can buy me a coffee :)
- **Lightning:** ⚡️ codytseng@getalby.com ⚡️
- **Bitcoin:** bc1qwp2uqjd2dy32qfe39kehnlgx3hyey0h502fvht
- **Geyser:** https://geyser.fund/project/jumble
If you like this project, you can buy me a coffee :) ⚡️ codytseng@getalby.com ⚡️
## License

BIN
build/icon.icns Normal file

Binary file not shown.

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
build/icon@2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -1,21 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "zinc",
"css": "src/renderer/src/assets/main.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"components": "@renderer/components",
"utils": "@renderer/lib/utils"
}
}

View File

@@ -1,44 +0,0 @@
services:
jumble:
container_name: jumble-nginx
build:
context: .
dockerfile: Dockerfile
args:
VITE_PROXY_SERVER: ${JUMBLE_PROXY_SERVER_URL:-http://localhost:8090}
ports:
- '8089:80'
restart: unless-stopped
networks:
- jumble
proxy-server:
image: ghcr.io/danvergara/jumble-proxy-server:latest
environment:
- ALLOW_ORIGIN=${JUMBLE_SOCIAL_URL:-http://localhost:8089}
- JUMBLE_PROXY_GITHUB_TOKEN=${JUMBLE_PROXY_GITHUB_TOKEN}
- ENABLE_PPROF=true
- PORT=8080
ports:
- '8090:8080'
networks:
- jumble
nostr-relay:
image: scsibug/nostr-rs-relay:latest
container_name: jumble-nostr-relay
ports:
- '7000:8080'
environment:
- RUST_LOG=warn,nostr_rs_relay=info
volumes:
- relay-data:/usr/src/app/db
networks:
- jumble
restart: unless-stopped
volumes:
relay-data:
networks:
jumble:

View File

@@ -1,30 +0,0 @@
version: '3.8'
services:
jumble:
container_name: jumble-nginx
build:
context: .
dockerfile: Dockerfile
args:
VITE_PROXY_SERVER: ${JUMBLE_PROXY_SERVER_URL:-http://localhost:8090}
ports:
- '8089:80'
restart: unless-stopped
networks:
- jumble
proxy-server:
image: ghcr.io/danvergara/jumble-proxy-server:latest
environment:
- ALLOW_ORIGIN=${JUMBLE_SOCIAL_URL:-http://localhost:8089}
- JUMBLE_PROXY_GITHUB_TOKEN=${JUMBLE_PROXY_GITHUB_TOKEN}
- ENABLE_PPROF=true
- PORT=8080
ports:
- '8090:8080'
networks:
- jumble
networks:
jumble:

34
electron-builder.yml Normal file
View File

@@ -0,0 +1,34 @@
appId: com.jumble.app
productName: jumble
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
win:
executableName: jumble
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- snap
- deb
maintainer: codytseng
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false

21
electron.vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
},
renderer: {
resolve: {
alias: {
'@renderer': resolve('src/renderer/src'),
'@common': resolve('src/common')
}
},
plugins: [react()]
}
})

View File

@@ -1,30 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/explicit-function-return-type': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'react-refresh/only-export-components': 'off',
'react-hooks/exhaustive-deps': 'off'
}
}
)

View File

@@ -1,36 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Jumble</title>
<meta name="description" content="A user-friendly Nostr client for exploring relay feeds" />
<meta
name="keywords"
content="jumble, nostr, web, client, relay, feed, social, pwa, simple, clean"
/>
<meta name="apple-mobile-web-app-title" content="Jumble" />
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
<meta name="theme-color" content="#171717" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
<meta property="og:url" content="https://jumble.social" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Jumble" />
<meta
property="og:description"
content="A user-friendly Nostr client for exploring relay feeds"
/>
<meta
property="og:image"
content="https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

12586
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,8 @@
{
"name": "jumble",
"version": "0.1.0",
"description": "A user-friendly Nostr client for exploring relay feeds",
"private": true,
"type": "module",
"description": "Yet another Nostr desktop client",
"main": "./out/main/index.js",
"author": "codytseng",
"license": "MIT",
"repository": {
@@ -12,103 +11,80 @@
},
"homepage": "https://github.com/CodyTseng/jumble",
"scripts": {
"dev": "vite --host",
"build": "tsc -b && vite build",
"lint": "eslint .",
"format": "prettier --write .",
"preview": "vite preview"
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"dev:web": "vite --config web.vite.config.ts",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win -p never",
"build:mac": "electron-vite build && electron-builder --mac -p never",
"build:linux": "electron-vite build && electron-builder --linux -p never",
"build:web": "vite build --config web.vite.config.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@getalby/bitcoin-connect-react": "^3.10.0",
"@noble/hashes": "^1.6.1",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-hover-card": "^1.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "1.2.0",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@tailwindcss/typography": "^0.5.16",
"@tiptap/extension-emoji": "^2.26.1",
"@tiptap/extension-history": "^2.12.0",
"@tiptap/extension-mention": "^2.12.0",
"@tiptap/extension-placeholder": "^2.12.0",
"@tiptap/pm": "^2.12.0",
"@tiptap/react": "^2.12.0",
"@tiptap/starter-kit": "^2.12.0",
"@tiptap/suggestion": "^2.12.0",
"@webbtc/webln-types": "^3.0.0",
"blossom-client-sdk": "^4.1.0",
"blurhash": "^2.0.5",
"class-variance-authority": "^0.7.1",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@nextui-org/image": "^2.0.32",
"@nextui-org/system": "^2.2.6",
"@nextui-org/theme": "^2.2.11",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-hover-card": "^1.1.2",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"dataloader": "^2.2.3",
"dataloader": "^2.2.2",
"dayjs": "^1.11.13",
"embla-carousel-react": "^8.6.0",
"embla-carousel-wheel-gestures": "^8.1.0",
"emoji-picker-react": "^4.12.2",
"flexsearch": "^0.7.43",
"franc-min": "^6.2.0",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.4",
"jotai": "^2.15.0",
"lru-cache": "^11.0.2",
"lucide-react": "^0.469.0",
"next-themes": "^0.4.6",
"nostr-tools": "^2.17.0",
"nstart-modal": "^2.0.0",
"framer-motion": "^11.11.17",
"i18next": "^23.16.5",
"i18next-browser-languagedetector": "^8.0.0",
"lru-cache": "^11.0.1",
"lucide-react": "^0.453.0",
"nostr-tools": "^2.9.1",
"path-to-regexp": "^8.2.0",
"qr-code-styling": "^1.9.2",
"qr-scanner": "^1.4.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.2.0",
"react-markdown": "^10.1.0",
"react-simple-pull-to-refresh": "^1.3.3",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.5",
"tailwind-merge": "^2.5.5",
"qrcode.react": "^4.1.0",
"react-i18next": "^15.1.1",
"react-resizable-panels": "^2.1.5",
"react-string-replace": "^1.1.1",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"thumbhash": "^0.1.1",
"tippy.js": "^6.3.7",
"uri-templates": "^0.2.0",
"vaul": "^1.1.2",
"yet-another-react-lightbox": "^3.21.7",
"zod": "^3.24.1"
"yet-another-react-lightbox": "^3.21.6"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/node": "^22.10.2",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@types/uri-templates": "^0.1.34",
"@vitejs/plugin-react": "^4.3.4",
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
"@electron-toolkit/eslint-config-ts": "^2.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@types/node": "^20.14.8",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.13.0",
"postcss": "^8.4.49",
"prettier": "3.4.2",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.1",
"vite": "^6.0.3",
"vite-plugin-pwa": "^0.21.1"
"electron": "^31.0.2",
"electron-builder": "^24.13.3",
"electron-vite": "^2.3.0",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.3",
"prettier": "^3.3.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.14",
"typescript": "^5.5.2",
"vite": "^5.3.1"
}
}

View File

@@ -1,6 +1,3 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
module.exports = {
plugins: [require('tailwindcss'), require('autoprefixer')]
}

View File

@@ -1,7 +0,0 @@
{
"names": {
"_": "f4eb8e62add1340b9cadcd9861e669b2e907cea534e0f7f3ac974c11c758a51a",
"cody": "8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883",
"cody2": "24462930821b45f530ec0063eca0a6522e5a577856f982fa944df0ef3caf03ab"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -1,2 +0,0 @@
User-agent: *
Allow: /

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 47 KiB

BIN
resources/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,72 +0,0 @@
import 'yet-another-react-lightbox/styles.css'
import './index.css'
import { Toaster } from '@/components/ui/sonner'
import { BookmarksProvider } from '@/providers/BookmarksProvider'
import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider'
import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
import { EmojiPackProvider } from '@/providers/EmojiPackProvider'
import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
import { FeedProvider } from '@/providers/FeedProvider'
import { FollowListProvider } from '@/providers/FollowListProvider'
import { KindFilterProvider } from '@/providers/KindFilterProvider'
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
import { MuteListProvider } from '@/providers/MuteListProvider'
import { NostrProvider } from '@/providers/NostrProvider'
import { PinListProvider } from '@/providers/PinListProvider'
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
import { ReplyProvider } from '@/providers/ReplyProvider'
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
import { ThemeProvider } from '@/providers/ThemeProvider'
import { TranslationServiceProvider } from '@/providers/TranslationServiceProvider'
import { UserPreferencesProvider } from '@/providers/UserPreferencesProvider'
import { UserTrustProvider } from '@/providers/UserTrustProvider'
import { ZapProvider } from '@/providers/ZapProvider'
import { PageManager } from './PageManager'
export default function App(): JSX.Element {
return (
<ScreenSizeProvider>
<UserPreferencesProvider>
<ThemeProvider>
<ContentPolicyProvider>
<DeletedEventProvider>
<NostrProvider>
<ZapProvider>
<TranslationServiceProvider>
<FavoriteRelaysProvider>
<FollowListProvider>
<MuteListProvider>
<UserTrustProvider>
<BookmarksProvider>
<EmojiPackProvider>
<PinListProvider>
<PinnedUsersProvider>
<FeedProvider>
<ReplyProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<PageManager />
<Toaster />
</KindFilterProvider>
</MediaUploadServiceProvider>
</ReplyProvider>
</FeedProvider>
</PinnedUsersProvider>
</PinListProvider>
</EmojiPackProvider>
</BookmarksProvider>
</UserTrustProvider>
</MuteListProvider>
</FollowListProvider>
</FavoriteRelaysProvider>
</TranslationServiceProvider>
</ZapProvider>
</NostrProvider>
</DeletedEventProvider>
</ContentPolicyProvider>
</ThemeProvider>
</UserPreferencesProvider>
</ScreenSizeProvider>
)
}

View File

@@ -1,534 +0,0 @@
import Sidebar from '@/components/Sidebar'
import { cn } from '@/lib/utils'
import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider'
import { TPageRef } from '@/types'
import {
cloneElement,
createContext,
createRef,
ReactNode,
RefObject,
useContext,
useEffect,
useRef,
useState
} from 'react'
import BackgroundAudio from './components/BackgroundAudio'
import BottomNavigationBar from './components/BottomNavigationBar'
import CreateWalletGuideToast from './components/CreateWalletGuideToast'
import TooManyRelaysAlertDialog from './components/TooManyRelaysAlertDialog'
import { normalizeUrl } from './lib/url'
import { NotificationProvider } from './providers/NotificationProvider'
import { useScreenSize } from './providers/ScreenSizeProvider'
import { useTheme } from './providers/ThemeProvider'
import { useUserPreferences } from './providers/UserPreferencesProvider'
import { PRIMARY_PAGE_MAP, PRIMARY_PAGE_REF_MAP, TPrimaryPageName } from './routes/primary'
import { SECONDARY_ROUTES } from './routes/secondary'
import modalManager from './services/modal-manager.service'
type TPrimaryPageContext = {
navigate: (page: TPrimaryPageName, props?: object) => void
current: TPrimaryPageName | null
display: boolean
}
type TSecondaryPageContext = {
push: (url: string) => void
pop: () => void
currentIndex: number
}
type TStackItem = {
index: number
url: string
element: React.ReactElement | null
ref: RefObject<TPageRef> | null
}
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(undefined)
export function usePrimaryPage() {
const context = useContext(PrimaryPageContext)
if (!context) {
throw new Error('usePrimaryPage must be used within a PrimaryPageContext.Provider')
}
return context
}
export function useSecondaryPage() {
const context = useContext(SecondaryPageContext)
if (!context) {
throw new Error('usePrimaryPage must be used within a SecondaryPageContext.Provider')
}
return context
}
export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const [currentPrimaryPage, setCurrentPrimaryPage] = useState<TPrimaryPageName>('home')
const [primaryPages, setPrimaryPages] = useState<
{ name: TPrimaryPageName; element: ReactNode; props?: any }[]
>([
{
name: 'home',
element: PRIMARY_PAGE_MAP.home
}
])
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
const { isSmallScreen } = useScreenSize()
const { themeSetting } = useTheme()
const { enableSingleColumnLayout } = useUserPreferences()
const ignorePopStateRef = useRef(false)
useEffect(() => {
if (isSmallScreen) return
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
navigatePrimaryPage('search')
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [isSmallScreen])
useEffect(() => {
if (['/npub1', '/nprofile1'].some((prefix) => window.location.pathname.startsWith(prefix))) {
window.history.replaceState(
null,
'',
'/users' + window.location.pathname + window.location.search + window.location.hash
)
} else if (
['/note1', '/nevent1', '/naddr1'].some((prefix) =>
window.location.pathname.startsWith(prefix)
)
) {
window.history.replaceState(
null,
'',
'/notes' + window.location.pathname + window.location.search + window.location.hash
)
}
window.history.pushState(null, '', window.location.href)
if (window.location.pathname !== '/') {
const url = window.location.pathname + window.location.search + window.location.hash
setSecondaryStack((prevStack) => {
if (isCurrentPage(prevStack, url)) return prevStack
const { newStack, newItem } = pushNewPageToStack(
prevStack,
url,
maxStackSize,
window.history.state?.index
)
if (newItem) {
window.history.replaceState({ index: newItem.index, url }, '', url)
}
return newStack
})
} else {
const searchParams = new URLSearchParams(window.location.search)
const r = searchParams.get('r')
if (r) {
const url = normalizeUrl(r)
if (url) {
navigatePrimaryPage('relay', { url })
}
}
}
const onPopState = (e: PopStateEvent) => {
if (ignorePopStateRef.current) {
ignorePopStateRef.current = false
return
}
const closeModal = modalManager.pop()
if (closeModal) {
ignorePopStateRef.current = true
window.history.forward()
return
}
let state = e.state as { index: number; url: string } | null
setSecondaryStack((pre) => {
const currentItem = pre[pre.length - 1] as TStackItem | undefined
const currentIndex = currentItem?.index
if (!state) {
if (window.location.pathname + window.location.search + window.location.hash !== '/') {
// Just change the URL
return pre
} else {
// Back to root
state = { index: -1, url: '/' }
}
}
// Go forward
if (currentIndex === undefined || state.index > currentIndex) {
const { newStack } = pushNewPageToStack(pre, state.url, maxStackSize)
return newStack
}
if (state.index === currentIndex) {
return pre
}
// Go back
const newStack = pre.filter((item) => item.index <= state!.index)
const topItem = newStack[newStack.length - 1] as TStackItem | undefined
if (!topItem) {
// Create a new stack item if it's not exist (e.g. when the user refreshes the page, the stack will be empty)
const { element, ref } = findAndCloneElement(state.url, state.index)
if (element) {
newStack.push({
index: state.index,
url: state.url,
element,
ref
})
}
} else if (!topItem.element) {
// Load the element if it's not cached
const { element, ref } = findAndCloneElement(topItem.url, state.index)
if (element) {
topItem.element = element
topItem.ref = ref
}
}
if (newStack.length === 0) {
window.history.replaceState(null, '', '/')
}
return newStack
})
}
window.addEventListener('popstate', onPopState)
return () => {
window.removeEventListener('popstate', onPopState)
}
}, [])
const navigatePrimaryPage = (page: TPrimaryPageName, props?: any) => {
const needScrollToTop = page === currentPrimaryPage
setPrimaryPages((prev) => {
const exists = prev.find((p) => p.name === page)
if (exists && props) {
exists.props = props
return [...prev]
} else if (!exists) {
return [...prev, { name: page, element: PRIMARY_PAGE_MAP[page], props }]
}
return prev
})
setCurrentPrimaryPage(page)
if (needScrollToTop) {
PRIMARY_PAGE_REF_MAP[page].current?.scrollToTop('smooth')
}
if (enableSingleColumnLayout) {
clearSecondaryPages()
}
}
const pushSecondaryPage = (url: string, index?: number) => {
setSecondaryStack((prevStack) => {
if (isCurrentPage(prevStack, url)) {
const currentItem = prevStack[prevStack.length - 1]
if (currentItem?.ref?.current) {
currentItem.ref.current.scrollToTop('instant')
}
return prevStack
}
const { newStack, newItem } = pushNewPageToStack(prevStack, url, maxStackSize, index)
if (newItem) {
window.history.pushState({ index: newItem.index, url }, '', url)
}
return newStack
})
}
const popSecondaryPage = (delta = -1) => {
if (secondaryStack.length <= -delta) {
// back to home page
window.history.replaceState(null, '', '/')
setSecondaryStack([])
} else {
window.history.go(delta)
}
}
const clearSecondaryPages = () => {
if (secondaryStack.length === 0) return
popSecondaryPage(-secondaryStack.length)
}
if (isSmallScreen) {
return (
<PrimaryPageContext.Provider
value={{
navigate: navigatePrimaryPage,
current: currentPrimaryPage,
display: secondaryStack.length === 0
}}
>
<SecondaryPageContext.Provider
value={{
push: pushSecondaryPage,
pop: popSecondaryPage,
currentIndex: secondaryStack.length
? secondaryStack[secondaryStack.length - 1].index
: 0
}}
>
<CurrentRelaysProvider>
<NotificationProvider>
{!!secondaryStack.length &&
secondaryStack.map((item, index) => (
<div
key={item.index}
style={{
display: index === secondaryStack.length - 1 ? 'block' : 'none'
}}
>
{item.element}
</div>
))}
{primaryPages.map(({ name, element, props }) => (
<div
key={name}
style={{
display:
secondaryStack.length === 0 && currentPrimaryPage === name ? 'block' : 'none'
}}
>
{props ? cloneElement(element as React.ReactElement, props) : element}
</div>
))}
<BottomNavigationBar />
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
</NotificationProvider>
</CurrentRelaysProvider>
</SecondaryPageContext.Provider>
</PrimaryPageContext.Provider>
)
}
if (enableSingleColumnLayout) {
return (
<PrimaryPageContext.Provider
value={{
navigate: navigatePrimaryPage,
current: currentPrimaryPage,
display: secondaryStack.length === 0
}}
>
<SecondaryPageContext.Provider
value={{
push: pushSecondaryPage,
pop: popSecondaryPage,
currentIndex: secondaryStack.length
? secondaryStack[secondaryStack.length - 1].index
: 0
}}
>
<CurrentRelaysProvider>
<NotificationProvider>
<div className="flex lg:justify-around w-full">
<div className="sticky top-0 lg:w-full flex justify-end self-start h-[var(--vh)]">
<Sidebar />
</div>
<div className="flex-1 w-0 bg-background border-x lg:flex-auto lg:w-[640px] lg:shrink-0">
{!!secondaryStack.length &&
secondaryStack.map((item, index) => (
<div
key={item.index}
style={{
display: index === secondaryStack.length - 1 ? 'block' : 'none'
}}
>
{item.element}
</div>
))}
{primaryPages.map(({ name, element, props }) => (
<div
key={name}
style={{
display:
secondaryStack.length === 0 && currentPrimaryPage === name
? 'block'
: 'none'
}}
>
{props ? cloneElement(element as React.ReactElement, props) : element}
</div>
))}
</div>
<div className="hidden lg:w-full lg:block" />
</div>
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
</NotificationProvider>
</CurrentRelaysProvider>
</SecondaryPageContext.Provider>
</PrimaryPageContext.Provider>
)
}
return (
<PrimaryPageContext.Provider
value={{
navigate: navigatePrimaryPage,
current: currentPrimaryPage,
display: true
}}
>
<SecondaryPageContext.Provider
value={{
push: pushSecondaryPage,
pop: popSecondaryPage,
currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0
}}
>
<CurrentRelaysProvider>
<NotificationProvider>
<div className="flex flex-col items-center bg-surface-background">
<div
className="flex h-[var(--vh)] w-full bg-surface-background"
style={{
maxWidth: '1920px'
}}
>
<Sidebar />
<div
className={cn(
'grid grid-cols-2 w-full',
themeSetting === 'pure-black' ? '' : 'gap-2 pr-2 py-2'
)}
>
<div
className={cn(
'bg-background overflow-hidden',
themeSetting === 'pure-black' ? 'border-l' : 'rounded-2xl shadow-lg'
)}
>
{primaryPages.map(({ name, element, props }) => (
<div
key={name}
className="flex flex-col h-full w-full"
style={{
display: currentPrimaryPage === name ? 'block' : 'none'
}}
>
{props ? cloneElement(element as React.ReactElement, props) : element}
</div>
))}
</div>
<div
className={cn(
'bg-background overflow-hidden',
themeSetting === 'pure-black' ? 'border-l' : 'rounded-2xl',
themeSetting !== 'pure-black' && secondaryStack.length > 0 && 'shadow-lg',
secondaryStack.length === 0 ? 'bg-surface' : ''
)}
>
{secondaryStack.map((item, index) => (
<div
key={item.index}
className="flex flex-col h-full w-full"
style={{ display: index === secondaryStack.length - 1 ? 'block' : 'none' }}
>
{item.element}
</div>
))}
</div>
</div>
</div>
</div>
<TooManyRelaysAlertDialog />
<CreateWalletGuideToast />
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
</NotificationProvider>
</CurrentRelaysProvider>
</SecondaryPageContext.Provider>
</PrimaryPageContext.Provider>
)
}
export function SecondaryPageLink({
to,
children,
className,
onClick
}: {
to: string
children: React.ReactNode
className?: string
onClick?: (e: React.MouseEvent) => void
}) {
const { push } = useSecondaryPage()
return (
<span
className={cn('cursor-pointer', className)}
onClick={(e) => {
if (onClick) {
onClick(e)
}
push(to)
}}
>
{children}
</span>
)
}
function isCurrentPage(stack: TStackItem[], url: string) {
const currentPage = stack[stack.length - 1]
if (!currentPage) return false
return currentPage.url === url
}
function findAndCloneElement(url: string, index: number) {
const path = url.split('?')[0].split('#')[0]
for (const { matcher, element } of SECONDARY_ROUTES) {
const match = matcher(path)
if (!match) continue
if (!element) return {}
const ref = createRef<TPageRef>()
return { element: cloneElement(element, { ...match.params, index, ref } as any), ref }
}
return {}
}
function pushNewPageToStack(
stack: TStackItem[],
url: string,
maxStackSize = 5,
specificIndex?: number
) {
const currentItem = stack[stack.length - 1]
const currentIndex = specificIndex ?? (currentItem ? currentItem.index + 1 : 0)
const { element, ref } = findAndCloneElement(url, currentIndex)
if (!element) return { newStack: stack, newItem: null }
const newItem = { element, ref, url, index: currentIndex }
const newStack = [...stack, newItem]
const lastCachedIndex = newStack.findIndex((stack) => stack.element)
// Clear the oldest cached element if there are too many cached elements
if (newStack.length - lastCachedIndex > maxStackSize) {
newStack[lastCachedIndex].element = null
}
return { newStack, newItem }
}

View File

@@ -1,24 +0,0 @@
export default function Icon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 1080 1228"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
xmlSpace="preserve"
style={{
fill: 'currentcolor',
fillRule: 'evenodd',
clipRule: 'evenodd',
strokeLinejoin: 'round',
strokeMiterlimit: 2
}}
className={className}
>
<path
id="Icon-Curve-Cut"
d="M360.047,1225.75c-31.046,-3.901 -75.11,-14.46 -106.756,-25.58c-101.676,-35.727 -175.164,-93.066 -215.387,-168.055c-12.079,-22.521 -30.071,-71.422 -27.297,-74.195c0.736,-0.736 11.648,5.578 24.249,14.031c135.436,90.86 301.047,169.043 465.056,219.547l32.77,10.091l-20.27,7.416c-43.455,15.896 -105.159,22.678 -152.365,16.745Zm166.293,-59.234c-168.523,-50.004 -331.475,-126.514 -481.755,-226.196c-37.737,-25.031 -41.489,-28.372 -43.419,-38.663c-3.585,-19.109 1.498,-83.894 9.798,-124.886c7.343,-36.266 27.664,-106.034 32.278,-110.818c2.023,-2.099 217.924,48.207 221.274,51.557c0.975,0.975 -1.132,11.339 -4.682,23.032c-24.542,80.842 -27.217,127.586 -9.935,173.593c22.507,59.917 114.521,99.888 177.281,77.012c29.23,-10.654 56.593,-41.085 82.629,-91.894c29.288,-57.155 32.348,-64.988 196.483,-503.076c81.138,-216.562 148.499,-394.821 149.692,-396.131c2.1,-2.304 217.949,76.926 223.076,81.884c2.056,1.988 -262.476,712.505 -307.806,826.747c-18.422,46.426 -56.939,123.045 -77.918,154.993c-10.157,15.469 -30.753,40.901 -45.769,56.515c-27.821,28.93 -66.46,58.952 -75.447,58.621c-2.738,-0.106 -23.339,-5.631 -45.78,-12.29Z"
/>
</svg>
)
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 47 KiB

5
src/common/constants.ts Normal file
View File

@@ -0,0 +1,5 @@
export const StorageKey = {
THEME_SETTING: 'themeSetting',
RELAY_GROUPS: 'relayGroups',
ACCOUNT: 'account'
}

56
src/common/types.ts Normal file
View File

@@ -0,0 +1,56 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import { Event } from 'nostr-tools'
export type TRelayGroup = {
groupName: string
relayUrls: string[]
isActive: boolean
}
export type TConfig = {
relayGroups: TRelayGroup[]
theme: TThemeSetting
}
export type TThemeSetting = 'light' | 'dark' | 'system'
export type TTheme = 'light' | 'dark'
export type TDraftEvent = Pick<Event, 'content' | 'created_at' | 'kind' | 'tags'>
export interface ISigner {
getPublicKey: () => Promise<string | null>
signEvent: (draftEvent: TDraftEvent) => Promise<Event | null>
}
export type TElectronWindow = {
electron: ElectronAPI
api: {
system: {
isEncryptionAvailable: () => Promise<boolean>
getSelectedStorageBackend: () => Promise<string>
}
theme: {
addChangeListener: (listener: (theme: TTheme) => void) => void
removeChangeListener: () => void
current: () => Promise<TTheme>
}
storage: {
getItem: (key: string) => Promise<string>
setItem: (key: string, value: string) => Promise<void>
removeItem: (key: string) => Promise<void>
}
nostr: {
login: (nsec: string) => Promise<{
pubkey?: string
reason?: string
}>
logout: () => Promise<void>
}
}
nostr: ISigner
}
export type TAccount = {
signerType: 'nsec' | 'browser-nsec' | 'nip-07'
nsec?: string
}

View File

@@ -1,55 +0,0 @@
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
import { CODY_PUBKEY } from '@/constants'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useState } from 'react'
import Username from '../Username'
export default function AboutInfoDialog({ children }: { children: React.ReactNode }) {
const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false)
const content = (
<>
<div className="text-xl font-semibold">Jumble</div>
<div className="text-muted-foreground">
A user-friendly Nostr client for exploring relay feeds
</div>
<div>
Made by <Username userId={CODY_PUBKEY} className="inline-block text-primary" showAt />
</div>
<div>
Source code:{' '}
<a
href="https://github.com/CodyTseng/jumble"
target="_blank"
rel="noreferrer"
className="text-primary hover:underline"
>
GitHub
</a>
<div className="text-sm text-muted-foreground">
If you like Jumble, please consider giving it a star
</div>
</div>
</>
)
if (isSmallScreen) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent>
<div className="p-4 space-y-4">{content}</div>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>{content}</DialogContent>
</Dialog>
)
}

View File

@@ -1,76 +0,0 @@
import { Button } from '@/components/ui/button'
import { isSameAccount } from '@/lib/account'
import { formatPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { TAccountPointer } from '@/types'
import { Loader, Trash2 } from 'lucide-react'
import { useState } from 'react'
import SignerTypeBadge from '../SignerTypeBadge'
import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username'
export default function AccountList({
className,
afterSwitch
}: {
className?: string
afterSwitch: () => void
}) {
const { accounts, account, switchAccount, removeAccount } = useNostr()
const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null)
return (
<div className={cn('space-y-2', className)}>
{accounts.map((act) => (
<div
key={`${act.pubkey}-${act.signerType}`}
className={cn(
'relative rounded-lg',
isSameAccount(act, account) ? 'border border-primary' : 'clickable'
)}
onClick={() => {
if (isSameAccount(act, account)) return
setSwitchingAccount(act)
switchAccount(act)
.then(() => afterSwitch())
.finally(() => setSwitchingAccount(null))
}}
>
<div className="flex justify-between items-center p-2">
<div className="flex-1 flex items-center gap-2 relative">
<SimpleUserAvatar userId={act.pubkey} />
<div className="flex-1 w-0">
<SimpleUsername userId={act.pubkey} className="font-semibold truncate" />
<div className="text-sm rounded-full bg-muted px-2 w-fit">
{formatPubkey(act.pubkey)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex gap-2 items-center">
<SignerTypeBadge signerType={act.signerType} />
</div>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation()
removeAccount(act)
}}
>
<Trash2 />
</Button>
</div>
</div>
{switchingAccount && isSameAccount(act, switchingAccount) && (
<div className="absolute top-0 left-0 flex w-full h-full items-center justify-center rounded-lg bg-muted/60">
<Loader size={16} className="animate-spin" />
</div>
)}
</div>
))}
</div>
)
}

View File

@@ -1,56 +0,0 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useNostr } from '@/providers/NostrProvider'
import { Loader } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function BunkerLogin({
back,
onLoginSuccess
}: {
back: () => void
onLoginSuccess: () => void
}) {
const { t } = useTranslation()
const { bunkerLogin } = useNostr()
const [pending, setPending] = useState(false)
const [bunkerInput, setBunkerInput] = useState('')
const [errMsg, setErrMsg] = useState<string | null>(null)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setBunkerInput(e.target.value)
setErrMsg(null)
}
const handleLogin = () => {
if (bunkerInput === '') return
setPending(true)
bunkerLogin(bunkerInput)
.then(() => onLoginSuccess())
.catch((err) => setErrMsg(err.message))
.finally(() => setPending(false))
}
return (
<>
<div className="space-y-1">
<Input
placeholder="bunker://..."
value={bunkerInput}
onChange={handleInputChange}
className={errMsg ? 'border-destructive' : ''}
/>
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
</div>
<Button onClick={handleLogin} disabled={pending}>
<Loader className={pending ? 'animate-spin' : 'hidden'} />
{t('Login')}
</Button>
<Button variant="secondary" onClick={back}>
{t('Back')}
</Button>
</>
)
}

View File

@@ -1,85 +0,0 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, RefreshCcw } from 'lucide-react'
import { generateSecretKey } from 'nostr-tools'
import { nsecEncode } from 'nostr-tools/nip19'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function GenerateNewAccount({
back,
onLoginSuccess
}: {
back: () => void
onLoginSuccess: () => void
}) {
const { t } = useTranslation()
const { nsecLogin } = useNostr()
const [nsec, setNsec] = useState(generateNsec())
const [copied, setCopied] = useState(false)
const [password, setPassword] = useState('')
const handleLogin = () => {
nsecLogin(nsec, password, true).then(() => onLoginSuccess())
}
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
handleLogin()
}}
>
<div className="text-orange-400">
{t(
'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.'
)}
</div>
<div className="grid gap-2">
<Label>nsec</Label>
<div className="flex gap-2">
<Input value={nsec} />
<Button type="button" variant="secondary" onClick={() => setNsec(generateNsec())}>
<RefreshCcw />
</Button>
<Button
type="button"
onClick={() => {
navigator.clipboard.writeText(nsec)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
>
{copied ? <Check /> : <Copy />}
</Button>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="password-input">{t('password')}</Label>
<Input
id="password-input"
type="password"
placeholder={t('optional: encrypt nsec')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button className="w-fit px-8" variant="secondary" type="button" onClick={back}>
{t('Back')}
</Button>
<Button className="flex-1" type="submit">
{t('Login')}
</Button>
</div>
</form>
)
}
function generateNsec() {
const sk = generateSecretKey()
return nsecEncode(sk)
}

View File

@@ -1,270 +0,0 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { DEFAULT_NOSTRCONNECT_RELAY } from '@/constants'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, Loader, ScanQrCode } from 'lucide-react'
import { generateSecretKey, getPublicKey } from 'nostr-tools'
import { createNostrConnectURI, NostrConnectParams } from 'nostr-tools/nip46'
import QrScanner from 'qr-scanner'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import QrCode from '../QrCode'
export default function NostrConnectLogin({
back,
onLoginSuccess
}: {
back: () => void
onLoginSuccess: () => void
}) {
const { t } = useTranslation()
const { nostrConnectionLogin, bunkerLogin } = useNostr()
const [pending, setPending] = useState(false)
const [bunkerInput, setBunkerInput] = useState('')
const [copied, setCopied] = useState(false)
const [errMsg, setErrMsg] = useState<string | null>(null)
const [nostrConnectionErrMsg, setNostrConnectionErrMsg] = useState<string | null>(null)
const qrContainerRef = useRef<HTMLDivElement>(null)
const [qrCodeSize, setQrCodeSize] = useState(100)
const [isScanning, setIsScanning] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
const qrScannerRef = useRef<QrScanner | null>(null)
const qrScannerCheckTimerRef = useRef<NodeJS.Timeout | null>(null)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setBunkerInput(e.target.value)
if (errMsg) setErrMsg(null)
}
const handleLogin = (bunker: string = bunkerInput) => {
const _bunker = bunker.trim()
if (_bunker.trim() === '') return
setPending(true)
bunkerLogin(_bunker)
.then(() => onLoginSuccess())
.catch((err) => setErrMsg(err.message || 'Login failed'))
.finally(() => setPending(false))
}
const [loginDetails] = useState(() => {
const newPrivKey = generateSecretKey()
const newMeta: NostrConnectParams = {
clientPubkey: getPublicKey(newPrivKey),
relays: DEFAULT_NOSTRCONNECT_RELAY,
secret: Math.random().toString(36).substring(7),
name: document.location.host,
url: document.location.origin
}
const newConnectionString = createNostrConnectURI(newMeta)
return {
privKey: newPrivKey,
connectionString: newConnectionString
}
})
useLayoutEffect(() => {
const calculateQrSize = () => {
if (qrContainerRef.current) {
const containerWidth = qrContainerRef.current.offsetWidth
const desiredSizeBasedOnWidth = Math.min(containerWidth - 8, containerWidth * 0.9)
const newSize = Math.max(100, Math.min(desiredSizeBasedOnWidth, 360))
setQrCodeSize(newSize)
}
}
calculateQrSize()
const resizeObserver = new ResizeObserver(calculateQrSize)
if (qrContainerRef.current) {
resizeObserver.observe(qrContainerRef.current)
}
return () => {
if (qrContainerRef.current) {
resizeObserver.unobserve(qrContainerRef.current)
}
resizeObserver.disconnect()
}
}, [])
useEffect(() => {
if (!loginDetails.privKey || !loginDetails.connectionString) return
setNostrConnectionErrMsg(null)
nostrConnectionLogin(loginDetails.privKey, loginDetails.connectionString)
.then(() => onLoginSuccess())
.catch((err) => {
console.error('NostrConnectionLogin Error:', err)
setNostrConnectionErrMsg(
err.message ? `${err.message}. Please reload.` : 'Connection failed. Please reload.'
)
})
}, [loginDetails, nostrConnectionLogin, onLoginSuccess])
const copyConnectionString = async () => {
if (!loginDetails.connectionString) return
navigator.clipboard.writeText(loginDetails.connectionString)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const startQrScan = async () => {
try {
setIsScanning(true)
setErrMsg(null)
// Wait for next render cycle to ensure video element is in DOM
await new Promise((resolve) => setTimeout(resolve, 100))
if (!videoRef.current) {
throw new Error('Video element not found')
}
const hasCamera = await QrScanner.hasCamera()
if (!hasCamera) {
throw new Error('No camera found')
}
const qrScanner = new QrScanner(
videoRef.current,
(result) => {
setBunkerInput(result.data)
stopQrScan()
handleLogin(result.data)
},
{
highlightScanRegion: true,
highlightCodeOutline: true,
preferredCamera: 'environment'
}
)
qrScannerRef.current = qrScanner
await qrScanner.start()
// Check video feed after a delay
qrScannerCheckTimerRef.current = setTimeout(() => {
if (
videoRef.current &&
(videoRef.current.videoWidth === 0 || videoRef.current.videoHeight === 0)
) {
setErrMsg('Camera feed not available')
}
}, 1000)
} catch (error) {
setErrMsg(
`Failed to start camera: ${error instanceof Error ? error.message : 'Unknown error'}. Please check permissions.`
)
setIsScanning(false)
if (qrScannerCheckTimerRef.current) {
clearTimeout(qrScannerCheckTimerRef.current)
qrScannerCheckTimerRef.current = null
}
}
}
const stopQrScan = () => {
if (qrScannerRef.current) {
qrScannerRef.current.stop()
qrScannerRef.current.destroy()
qrScannerRef.current = null
}
setIsScanning(false)
if (qrScannerCheckTimerRef.current) {
clearTimeout(qrScannerCheckTimerRef.current)
qrScannerCheckTimerRef.current = null
}
}
useEffect(() => {
return () => {
stopQrScan()
}
}, [])
return (
<div className="relative flex flex-col gap-4">
<div ref={qrContainerRef} className="flex flex-col items-center w-full space-y-3 mb-3">
<a href={loginDetails.connectionString} aria-label="Open with Nostr signer app">
<QrCode size={qrCodeSize} value={loginDetails.connectionString} />
</a>
{nostrConnectionErrMsg && (
<div className="text-xs text-destructive text-center pt-1">{nostrConnectionErrMsg}</div>
)}
</div>
<div className="flex justify-center w-full mb-3">
<div
className="flex items-center gap-2 text-sm text-muted-foreground bg-muted px-3 py-2 rounded-full cursor-pointer transition-all hover:bg-muted/80"
style={{
width: qrCodeSize > 0 ? `${Math.max(150, Math.min(qrCodeSize, 320))}px` : 'auto'
}}
onClick={copyConnectionString}
role="button"
tabIndex={0}
>
<div className="flex-grow min-w-0 truncate select-none">
{loginDetails.connectionString}
</div>
<div className="flex-shrink-0">{copied ? <Check size={14} /> : <Copy size={14} />}</div>
</div>
</div>
<div className="flex items-center w-full my-4">
<div className="flex-grow border-t border-border/40"></div>
<span className="px-3 text-xs text-muted-foreground">OR</span>
<div className="flex-grow border-t border-border/40"></div>
</div>
<div className="w-full space-y-1">
<div className="flex items-start space-x-2">
<div className="flex-1 relative">
<Input
placeholder="bunker://..."
value={bunkerInput}
onChange={handleInputChange}
className={errMsg ? 'border-destructive pr-10' : 'pr-10'}
/>
<Button
size="sm"
variant="ghost"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0"
onClick={startQrScan}
disabled={pending}
>
<ScanQrCode />
</Button>
</div>
<Button onClick={() => handleLogin()} disabled={pending}>
<Loader className={pending ? 'animate-spin mr-2' : 'hidden'} />
{t('Login')}
</Button>
</div>
{errMsg && <div className="text-xs text-destructive pl-3 pt-1">{errMsg}</div>}
</div>
<Button variant="secondary" onClick={back} className="w-full">
{t('Back')}
</Button>
<div className={cn('w-full h-full flex justify-center', isScanning ? '' : 'hidden')}>
<video
ref={videoRef}
className="absolute inset-0 w-full h-full bg-background"
autoPlay
playsInline
muted
/>
<Button
variant="secondary"
size="sm"
className="absolute top-2 right-2"
onClick={stopQrScan}
>
Cancel
</Button>
</div>
</div>
)
}

View File

@@ -1,56 +0,0 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useNostr } from '@/providers/NostrProvider'
import { Loader } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function NpubLogin({
back,
onLoginSuccess
}: {
back: () => void
onLoginSuccess: () => void
}) {
const { t } = useTranslation()
const { npubLogin } = useNostr()
const [pending, setPending] = useState(false)
const [npubInput, setNpubInput] = useState('')
const [errMsg, setErrMsg] = useState<string | null>(null)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNpubInput(e.target.value)
setErrMsg(null)
}
const handleLogin = () => {
if (npubInput === '') return
setPending(true)
npubLogin(npubInput)
.then(() => onLoginSuccess())
.catch((err) => setErrMsg(err.message))
.finally(() => setPending(false))
}
return (
<>
<div className="space-y-1">
<Input
placeholder="npub..."
value={npubInput}
onChange={handleInputChange}
className={errMsg ? 'border-destructive' : ''}
/>
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
</div>
<Button onClick={handleLogin} disabled={pending}>
<Loader className={pending ? 'animate-spin' : 'hidden'} />
{t('Login')}
</Button>
<Button variant="secondary" onClick={back}>
{t('Back')}
</Button>
</>
)
}

View File

@@ -1,158 +0,0 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useNostr } from '@/providers/NostrProvider'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function PrivateKeyLogin({
back,
onLoginSuccess
}: {
back: () => void
onLoginSuccess: () => void
}) {
return (
<Tabs defaultValue="nsec">
<TabsList>
<TabsTrigger value="nsec">nsec</TabsTrigger>
<TabsTrigger value="ncryptsec">ncryptsec</TabsTrigger>
</TabsList>
<TabsContent value="nsec">
<NsecLogin back={back} onLoginSuccess={onLoginSuccess} />
</TabsContent>
<TabsContent value="ncryptsec">
<NcryptsecLogin back={back} onLoginSuccess={onLoginSuccess} />
</TabsContent>
</Tabs>
)
}
function NsecLogin({ back, onLoginSuccess }: { back: () => void; onLoginSuccess: () => void }) {
const { t } = useTranslation()
const { nsecLogin } = useNostr()
const [nsecOrHex, setNsecOrHex] = useState('')
const [errMsg, setErrMsg] = useState<string | null>(null)
const [password, setPassword] = useState('')
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNsecOrHex(e.target.value)
setErrMsg(null)
}
const handleLogin = () => {
if (nsecOrHex === '') return
nsecLogin(nsecOrHex, password)
.then(() => onLoginSuccess())
.catch((err) => {
setErrMsg(err.message)
})
}
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
handleLogin()
}}
>
<div className="text-orange-400">
{t(
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x. If you must use a private key, please set a password for encryption at minimum.'
)}
</div>
<div className="grid gap-2">
<Label htmlFor="nsec-input">nsec or hex</Label>
<Input
id="nsec-input"
type="password"
placeholder="nsec1.. or hex"
value={nsecOrHex}
onChange={handleInputChange}
className={errMsg ? 'border-destructive' : ''}
/>
{errMsg && <div className="text-xs text-destructive">{errMsg}</div>}
</div>
<div className="grid gap-2">
<Label htmlFor="password-input">{t('password')}</Label>
<Input
id="password-input"
type="password"
placeholder={t('optional: encrypt nsec')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button className="w-fit px-8" variant="secondary" type="button" onClick={back}>
{t('Back')}
</Button>
<Button className="flex-1" type="submit">
{t('Login')}
</Button>
</div>
</form>
)
}
function NcryptsecLogin({
back,
onLoginSuccess
}: {
back: () => void
onLoginSuccess: () => void
}) {
const { t } = useTranslation()
const { ncryptsecLogin } = useNostr()
const [ncryptsec, setNcryptsec] = useState('')
const [errMsg, setErrMsg] = useState<string | null>(null)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNcryptsec(e.target.value)
setErrMsg(null)
}
const handleLogin = () => {
if (ncryptsec === '') return
ncryptsecLogin(ncryptsec)
.then(() => onLoginSuccess())
.catch((err) => {
setErrMsg(err.message)
})
}
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
handleLogin()
}}
>
<div className="grid gap-2">
<Label htmlFor="ncryptsec-input">ncryptsec</Label>
<Input
id="ncryptsec-input"
type="password"
placeholder="ncryptsec1.."
value={ncryptsec}
onChange={handleInputChange}
className={errMsg ? 'border-destructive' : ''}
/>
{errMsg && <div className="text-xs text-destructive">{errMsg}</div>}
</div>
<div className="flex gap-2">
<Button className="w-fit px-8" variant="secondary" type="button" onClick={back}>
{t('Back')}
</Button>
<Button className="flex-1" type="submit">
{t('Login')}
</Button>
</div>
</form>
)
}

View File

@@ -1,125 +0,0 @@
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { isDevEnv } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useTheme } from '@/providers/ThemeProvider'
import { NstartModal } from 'nstart-modal'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AccountList from '../AccountList'
import GenerateNewAccount from './GenerateNewAccount'
import NostrConnectLogin from './NostrConnectionLogin'
import NpubLogin from './NpubLogin'
import PrivateKeyLogin from './PrivateKeyLogin'
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | 'npub' | null
export default function AccountManager({ close }: { close?: () => void }) {
const [page, setPage] = useState<TAccountManagerPage>(null)
return (
<>
{page === 'nsec' ? (
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'bunker' ? (
<NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'generate' ? (
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'npub' ? (
<NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : (
<AccountManagerNav setPage={setPage} close={close} />
)}
</>
)
}
function AccountManagerNav({
setPage,
close
}: {
setPage: (page: TAccountManagerPage) => void
close?: () => void
}) {
const { t, i18n } = useTranslation()
const { themeSetting } = useTheme()
const { nip07Login, bunkerLogin, nsecLogin, ncryptsecLogin, accounts } = useNostr()
return (
<div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-8">
<div>
<div className="text-center text-muted-foreground text-sm font-semibold">
{t('Add an Account')}
</div>
<div className="space-y-2 mt-4">
{!!window.nostr && (
<Button onClick={() => nip07Login().then(() => close?.())} className="w-full">
{t('Login with Browser Extension')}
</Button>
)}
<Button variant="secondary" onClick={() => setPage('bunker')} className="w-full">
{t('Login with Bunker')}
</Button>
<Button variant="secondary" onClick={() => setPage('nsec')} className="w-full">
{t('Login with Private Key')}
</Button>
{isDevEnv() && (
<Button variant="secondary" onClick={() => setPage('npub')} className="w-full">
Login with Public key (for development)
</Button>
)}
</div>
</div>
<Separator />
<div>
<div className="text-center text-muted-foreground text-sm font-semibold">
{t("Don't have an account yet?")}
</div>
<Button
onClick={() => {
const wizard = new NstartModal({
baseUrl: 'https://nstart.me',
an: 'Jumble',
am: themeSetting === 'pure-black' ? 'dark' : themeSetting,
al: i18n.language.slice(0, 2),
onComplete: ({ nostrLogin }) => {
if (!nostrLogin) return
if (nostrLogin.startsWith('bunker://')) {
bunkerLogin(nostrLogin)
} else if (nostrLogin.startsWith('ncryptsec')) {
ncryptsecLogin(nostrLogin)
} else if (nostrLogin.startsWith('nsec')) {
nsecLogin(nostrLogin)
}
}
})
close?.()
wizard.open()
}}
className="w-full mt-4"
>
{t('Sign up')}
</Button>
<Button
variant="link"
onClick={() => setPage('generate')}
className="w-full text-muted-foreground py-0 h-fit mt-1"
>
{t('or simply generate a private key')}
</Button>
</div>
{accounts.length > 0 && (
<>
<Separator />
<div>
<div className="text-center text-muted-foreground text-sm font-semibold">
{t('Logged in Accounts')}
</div>
<AccountList className="mt-4" afterSwitch={() => close?.()} />
</div>
</>
)}
</div>
)
}

View File

@@ -1,13 +0,0 @@
import { TriangleAlert } from 'lucide-react'
export default function AlertCard({ title, content }: { title: string; content: string }) {
return (
<div className="p-3 rounded-lg text-sm bg-amber-100/20 dark:bg-amber-950/20 border border-amber-500 text-amber-500 [&_svg]:size-4">
<div className="flex items-center gap-2">
<TriangleAlert />
<div className="font-medium">{title}</div>
</div>
<div className="pl-6">{content}</div>
</div>
)
}

View File

@@ -1,189 +0,0 @@
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { cn } from '@/lib/utils'
import mediaManager from '@/services/media-manager.service'
import { Minimize2, Pause, Play, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import ExternalLink from '../ExternalLink'
interface AudioPlayerProps {
src: string
autoPlay?: boolean
startTime?: number
isMinimized?: boolean
className?: string
}
export default function AudioPlayer({
src,
autoPlay = false,
startTime,
isMinimized = false,
className
}: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [error, setError] = useState(false)
const seekTimeoutRef = useRef<NodeJS.Timeout>()
const isSeeking = useRef(false)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const audio = audioRef.current
if (!audio) return
if (startTime) {
setCurrentTime(startTime)
audio.currentTime = startTime
}
if (autoPlay) {
togglePlay()
}
const updateTime = () => {
if (!isSeeking.current) {
setCurrentTime(audio.currentTime)
}
}
const updateDuration = () => setDuration(audio.duration)
const handleEnded = () => setIsPlaying(false)
const handlePause = () => setIsPlaying(false)
const handlePlay = () => setIsPlaying(true)
audio.addEventListener('timeupdate', updateTime)
audio.addEventListener('loadedmetadata', updateDuration)
audio.addEventListener('ended', handleEnded)
audio.addEventListener('pause', handlePause)
audio.addEventListener('play', handlePlay)
return () => {
audio.removeEventListener('timeupdate', updateTime)
audio.removeEventListener('loadedmetadata', updateDuration)
audio.removeEventListener('ended', handleEnded)
audio.removeEventListener('pause', handlePause)
audio.removeEventListener('play', handlePlay)
}
}, [])
useEffect(() => {
const audio = audioRef.current
const container = containerRef.current
if (!audio || !container) return
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) {
audio.pause()
}
},
{ threshold: 1 }
)
observer.observe(container)
return () => {
observer.unobserve(container)
}
}, [])
const togglePlay = () => {
const audio = audioRef.current
if (!audio) return
if (isPlaying) {
audio.pause()
setIsPlaying(false)
} else {
audio.play()
setIsPlaying(true)
mediaManager.play(audio)
}
}
const handleSeek = (value: number[]) => {
const audio = audioRef.current
if (!audio) return
isSeeking.current = true
setCurrentTime(value[0])
if (seekTimeoutRef.current) {
clearTimeout(seekTimeoutRef.current)
}
seekTimeoutRef.current = setTimeout(() => {
audio.currentTime = value[0]
isSeeking.current = false
}, 300)
}
if (error) {
return <ExternalLink url={src} />
}
return (
<div
ref={containerRef}
className={cn(
'flex items-center gap-3 py-2 px-2 border rounded-full max-w-md bg-background',
className
)}
onClick={(e) => e.stopPropagation()}
>
<audio ref={audioRef} src={src} preload="metadata" onError={() => setError(false)} />
{/* Play/Pause Button */}
<Button size="icon" className="rounded-full shrink-0" onClick={togglePlay}>
{isPlaying ? <Pause fill="currentColor" /> : <Play fill="currentColor" />}
</Button>
{/* Progress Section */}
<div className="flex-1 relative">
<Slider
value={[currentTime]}
max={duration || 100}
step={1}
onValueChange={handleSeek}
hideThumb
enableHoverAnimation
/>
</div>
<div className="text-sm font-mono text-muted-foreground">
{formatTime(Math.max(duration - currentTime, 0))}
</div>
{isMinimized ? (
<Button
variant="ghost"
size="icon"
className="rounded-full shrink-0 text-muted-foreground"
onClick={() => mediaManager.stopAudioBackground()}
>
<X />
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="rounded-full shrink-0 text-muted-foreground"
onClick={() => mediaManager.playAudioBackground(src, audioRef.current?.currentTime || 0)}
>
<Minimize2 />
</Button>
)}
</div>
)
}
const formatTime = (time: number) => {
if (time === Infinity || isNaN(time)) {
return '-:--'
}
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}

View File

@@ -1,46 +0,0 @@
import mediaManager from '@/services/media-manager.service'
import { useEffect, useState } from 'react'
import AudioPlayer from '../AudioPlayer'
export default function BackgroundAudio({ className }: { className?: string }) {
const [backgroundAudioSrc, setBackgroundAudioSrc] = useState<string | null>(null)
const [backgroundAudio, setBackgroundAudio] = useState<React.ReactNode>(null)
useEffect(() => {
const handlePlayAudioBackground = (event: Event) => {
const { src, time } = (event as CustomEvent).detail
if (backgroundAudioSrc === src) return
setBackgroundAudio(
<FloatingAudioPlayer key={src + time} src={src} time={time} className={className} />
)
setBackgroundAudioSrc(src)
}
const handleStopAudioBackground = () => {
setBackgroundAudio(null)
}
mediaManager.addEventListener('playAudioBackground', handlePlayAudioBackground)
mediaManager.addEventListener('stopAudioBackground', handleStopAudioBackground)
return () => {
mediaManager.removeEventListener('playAudioBackground', handlePlayAudioBackground)
mediaManager.removeEventListener('stopAudioBackground', handleStopAudioBackground)
}
}, [])
return backgroundAudio
}
function FloatingAudioPlayer({
src,
time,
className
}: {
src: string
time?: number
className?: string
}) {
return <AudioPlayer src={src} className={className} startTime={time} autoPlay isMinimized />
}

View File

@@ -1,78 +0,0 @@
import { useStuff } from '@/hooks/useStuff'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { useBookmarks } from '@/providers/BookmarksProvider'
import { useNostr } from '@/providers/NostrProvider'
import { BookmarkIcon, Loader } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export default function BookmarkButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation()
const { pubkey: accountPubkey, bookmarkListEvent, checkLogin } = useNostr()
const { addBookmark, removeBookmark } = useBookmarks()
const [updating, setUpdating] = useState(false)
const { event } = useStuff(stuff)
const isBookmarked = useMemo(() => {
if (!event) return false
const isReplaceable = isReplaceableEvent(event.kind)
const eventKey = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id
return bookmarkListEvent?.tags.some((tag) =>
isReplaceable ? tag[0] === 'a' && tag[1] === eventKey : tag[0] === 'e' && tag[1] === eventKey
)
}, [bookmarkListEvent, event])
if (!accountPubkey) return null
const handleBookmark = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (isBookmarked || !event) return
setUpdating(true)
try {
await addBookmark(event)
} catch (error) {
toast.error(t('Bookmark failed') + ': ' + (error as Error).message)
} finally {
setUpdating(false)
}
})
}
const handleRemoveBookmark = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!isBookmarked || !event) return
setUpdating(true)
try {
await removeBookmark(event)
} catch (error) {
toast.error(t('Remove bookmark failed') + ': ' + (error as Error).message)
} finally {
setUpdating(false)
}
})
}
return (
<button
className={`flex items-center gap-1 ${
isBookmarked ? 'text-rose-400' : 'text-muted-foreground'
} enabled:hover:text-rose-400 px-3 h-full disabled:text-muted-foreground/40 disabled:cursor-default`}
onClick={isBookmarked ? handleRemoveBookmark : handleBookmark}
disabled={!event || updating}
title={isBookmarked ? t('Remove bookmark') : t('Bookmark')}
>
{updating ? (
<Loader className="animate-spin" />
) : (
<BookmarkIcon className={isBookmarked ? 'fill-rose-400' : ''} />
)}
</button>
)
}

View File

@@ -1,102 +0,0 @@
import { useFetchEvent } from '@/hooks'
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
import { useNostr } from '@/providers/NostrProvider'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
const SHOW_COUNT = 10
export default function BookmarkList() {
const { t } = useTranslation()
const { bookmarkListEvent } = useNostr()
const eventIds = useMemo(() => {
if (!bookmarkListEvent) return []
return (
bookmarkListEvent.tags
.map((tag) =>
tag[0] === 'e'
? generateBech32IdFromETag(tag)
: tag[0] === 'a'
? generateBech32IdFromATag(tag)
: null
)
.filter(Boolean) as (`nevent1${string}` | `naddr1${string}`)[]
).reverse()
}, [bookmarkListEvent])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 0.1
}
const loadMore = () => {
if (showCount < eventIds.length) {
setShowCount((prev) => prev + SHOW_COUNT)
}
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMore()
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [showCount, eventIds])
if (eventIds.length === 0) {
return (
<div className="mt-2 text-sm text-center text-muted-foreground">
{t('no bookmarks found')}
</div>
)
}
return (
<div>
{eventIds.slice(0, showCount).map((eventId) => (
<BookmarkedNote key={eventId} eventId={eventId} />
))}
{showCount < eventIds.length ? (
<div ref={bottomRef}>
<NoteCardLoadingSkeleton />
</div>
) : (
<div className="text-center text-sm text-muted-foreground mt-2">
{t('no more bookmarks')}
</div>
)}
</div>
)
}
function BookmarkedNote({ eventId }: { eventId: string }) {
const { event, isFetching } = useFetchEvent(eventId)
if (isFetching) {
return <NoteCardLoadingSkeleton className="border-b" />
}
if (!event) {
return null
}
return <NoteCard event={event} className="w-full" />
}

View File

@@ -1,57 +0,0 @@
import { Skeleton } from '@/components/ui/skeleton'
import { LONG_PRESS_THRESHOLD } from '@/constants'
import { cn } from '@/lib/utils'
import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { UserRound } from 'lucide-react'
import { useMemo, useRef, useState } from 'react'
import LoginDialog from '../LoginDialog'
import { SimpleUserAvatar } from '../UserAvatar'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function AccountButton() {
const { navigate, current, display } = usePrimaryPage()
const { pubkey, profile } = useNostr()
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
const active = useMemo(() => current === 'me' && display, [display, current])
const pressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handlePointerDown = () => {
pressTimerRef.current = setTimeout(() => {
setLoginDialogOpen(true)
pressTimerRef.current = null
}, LONG_PRESS_THRESHOLD)
}
const handlePointerUp = () => {
if (pressTimerRef.current) {
clearTimeout(pressTimerRef.current)
navigate('me')
pressTimerRef.current = null
}
}
return (
<>
<BottomNavigationBarItem
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
active={active}
>
{pubkey ? (
profile ? (
<SimpleUserAvatar
userId={pubkey}
className={cn('size-6', active ? 'ring-primary ring-2' : '')}
/>
) : (
<Skeleton className={cn('size-6 rounded-full', active ? 'ring-primary ring-2' : '')} />
)
) : (
<UserRound />
)}
</BottomNavigationBarItem>
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
</>
)
}

View File

@@ -1,32 +0,0 @@
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { MouseEventHandler } from 'react'
export default function BottomNavigationBarItem({
children,
active = false,
onClick,
onPointerDown,
onPointerUp
}: {
children: React.ReactNode
active?: boolean
onClick?: MouseEventHandler
onPointerDown?: MouseEventHandler
onPointerUp?: MouseEventHandler
}) {
return (
<Button
className={cn(
'flex shadow-none items-center bg-transparent w-full h-12 p-3 m-0 rounded-lg [&_svg]:size-6',
active && 'text-primary hover:text-primary'
)}
variant="ghost"
onClick={onClick}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
>
{children}
</Button>
)
}

View File

@@ -1,16 +0,0 @@
import { usePrimaryPage } from '@/PageManager'
import { Compass } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function ExploreButton() {
const { navigate, current, display } = usePrimaryPage()
return (
<BottomNavigationBarItem
active={current === 'explore' && display}
onClick={() => navigate('explore')}
>
<Compass />
</BottomNavigationBarItem>
)
}

View File

@@ -1,16 +0,0 @@
import { usePrimaryPage } from '@/PageManager'
import { Home } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function HomeButton() {
const { navigate, current, display } = usePrimaryPage()
return (
<BottomNavigationBarItem
active={current === 'home' && display}
onClick={() => navigate('home')}
>
<Home />
</BottomNavigationBarItem>
)
}

View File

@@ -1,25 +0,0 @@
import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useNotification } from '@/providers/NotificationProvider'
import { Bell } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function NotificationsButton() {
const { checkLogin } = useNostr()
const { navigate, current, display } = usePrimaryPage()
const { hasNewNotification } = useNotification()
return (
<BottomNavigationBarItem
active={current === 'notifications' && display}
onClick={() => checkLogin(() => navigate('notifications'))}
>
<div className="relative">
<Bell />
{hasNewNotification && (
<div className="absolute -top-0.5 right-0.5 w-2 h-2 ring-2 ring-background bg-primary rounded-full" />
)}
</div>
</BottomNavigationBarItem>
)
}

View File

@@ -1,25 +0,0 @@
import { cn } from '@/lib/utils'
import BackgroundAudio from '../BackgroundAudio'
import AccountButton from './AccountButton'
import ExploreButton from './ExploreButton'
import HomeButton from './HomeButton'
import NotificationsButton from './NotificationsButton'
export default function BottomNavigationBar() {
return (
<div
className={cn('fixed bottom-0 w-full z-40 bg-background border-t')}
style={{
paddingBottom: 'env(safe-area-inset-bottom)'
}}
>
<BackgroundAudio className="rounded-none border-x-0 border-t-0 border-b bg-background" />
<div className="w-full flex justify-around items-center [&_svg]:size-4 [&_svg]:shrink-0">
<HomeButton />
<ExploreButton />
<NotificationsButton />
<AccountButton />
</div>
</div>
)
}

View File

@@ -1,246 +0,0 @@
import { Button, ButtonProps } from '@/components/ui/button'
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
import { Drawer, DrawerContent, DrawerOverlay, DrawerTrigger } from '@/components/ui/drawer'
import { Separator } from '@/components/ui/separator'
import { ExtendedKind } from '@/constants'
import { getReplaceableEventIdentifier, getNoteBech32Id } from '@/lib/event'
import { toChachiChat } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import clientService from '@/services/client.service'
import { ExternalLink } from 'lucide-react'
import { Event, kinds, nip19 } from 'nostr-tools'
import { Dispatch, SetStateAction, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
const clients: Record<string, { name: string; getUrl: (id: string) => string }> = {
nosta: {
name: 'Nosta',
getUrl: (id: string) => `https://nosta.me/${id}`
},
snort: {
name: 'Snort',
getUrl: (id: string) => `https://snort.social/${id}`
},
olas: {
name: 'Olas',
getUrl: (id: string) => `https://olas.app/e/${id}`
},
primal: {
name: 'Primal',
getUrl: (id: string) => `https://primal.net/e/${id}`
},
nostrudel: {
name: 'Nostrudel',
getUrl: (id: string) => `https://nostrudel.ninja/l/${id}`
},
nostter: {
name: 'Nostter',
getUrl: (id: string) => `https://nostter.app/${id}`
},
coracle: {
name: 'Coracle',
getUrl: (id: string) => `https://coracle.social/${id}`
},
iris: {
name: 'Iris',
getUrl: (id: string) => `https://iris.to/${id}`
},
lumilumi: {
name: 'Lumilumi',
getUrl: (id: string) => `https://lumilumi.app/${id}`
},
zapStream: {
name: 'zap.stream',
getUrl: (id: string) => `https://zap.stream/${id}`
},
yakihonne: {
name: 'YakiHonne',
getUrl: (id: string) => `https://yakihonne.com/${id}`
},
habla: {
name: 'Habla',
getUrl: (id: string) => `https://habla.news/a/${id}`
},
pareto: {
name: 'Pareto',
getUrl: (id: string) => `https://pareto.space/a/${id}`
},
njump: {
name: 'Njump',
getUrl: (id: string) => `https://njump.me/${id}`
}
}
export default function ClientSelect({
event,
originalNoteId,
...props
}: ButtonProps & {
event?: Event
originalNoteId?: string
}) {
const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false)
const { t } = useTranslation()
const supportedClients = useMemo(() => {
let kind: number | undefined
if (event) {
kind = event.kind
} else if (originalNoteId) {
try {
const pointer = nip19.decode(originalNoteId)
if (pointer.type === 'naddr') {
kind = pointer.data.kind
}
} catch (error) {
console.error('Failed to decode NIP-19 pointer:', error)
return ['njump']
}
}
if (!kind) {
return ['njump']
}
switch (kind) {
case kinds.LongFormArticle:
case kinds.DraftLong:
return ['yakihonne', 'coracle', 'habla', 'lumilumi', 'pareto', 'njump']
case kinds.LiveEvent:
return ['zapStream', 'nostrudel', 'njump']
case kinds.Date:
case kinds.Time:
return ['coracle', 'njump']
case kinds.CommunityDefinition:
return ['coracle', 'snort', 'njump']
default:
return ['njump']
}
}, [event])
if (!originalNoteId && !event) {
return null
}
const content = (
<div className="space-y-2">
{event?.kind === ExtendedKind.GROUP_METADATA ? (
<RelayBasedGroupChatSelector
event={event}
originalNoteId={originalNoteId}
setOpen={setOpen}
/>
) : (
supportedClients.map((clientId) => {
const client = clients[clientId]
if (!client) return null
return (
<ClientSelectItem
key={clientId}
onClick={() => setOpen(false)}
href={client.getUrl(originalNoteId ?? getNoteBech32Id(event!))}
name={client.name}
/>
)
})
)}
<Separator />
<Button
variant="ghost"
className="w-full py-6 font-semibold"
onClick={() => {
navigator.clipboard.writeText(originalNoteId ?? getNoteBech32Id(event!))
setOpen(false)
}}
>
{t('Copy event ID')}
</Button>
</div>
)
const trigger = (
<Button variant="outline" {...props}>
<ExternalLink /> {t('Open in another client')}
</Button>
)
if (isSmallScreen) {
return (
<div onClick={(e) => e.stopPropagation()}>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerOverlay
onClick={(e) => {
e.stopPropagation()
setOpen(false)
}}
/>
<DrawerContent hideOverlay>{content}</DrawerContent>
</Drawer>
</div>
)
}
return (
<div onClick={(e) => e.stopPropagation()}>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="px-8" onOpenAutoFocus={(e) => e.preventDefault()}>
{content}
</DialogContent>
</Dialog>
</div>
)
}
function RelayBasedGroupChatSelector({
event,
originalNoteId,
setOpen
}: {
event: Event
setOpen: Dispatch<SetStateAction<boolean>>
originalNoteId?: string
}) {
const { relay, id } = useMemo(() => {
let relay: string | undefined
if (originalNoteId) {
const pointer = nip19.decode(originalNoteId)
if (pointer.type === 'naddr' && pointer.data.relays?.length) {
relay = pointer.data.relays[0]
}
}
if (!relay) {
relay = clientService.getEventHint(event.id)
}
return { relay, id: getReplaceableEventIdentifier(event) }
}, [event, originalNoteId])
return (
<ClientSelectItem
onClick={() => setOpen(false)}
href={toChachiChat(relay, id)}
name="Chachi Chat"
/>
)
}
function ClientSelectItem({
onClick,
href,
name
}: {
onClick: () => void
href: string
name: string
}) {
return (
<Button asChild variant="ghost" className="w-full py-6 font-semibold" onClick={onClick}>
<a href={href} target="_blank" rel="noopener noreferrer">
{name}
</a>
</Button>
)
}

View File

@@ -1,17 +0,0 @@
import { getUsingClient } from '@/lib/event'
import { NostrEvent } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function ClientTag({ event }: { event: NostrEvent }) {
const { t } = useTranslation()
const usingClient = useMemo(() => getUsingClient(event), [event])
if (!usingClient) return null
return (
<span className="text-sm text-muted-foreground shrink-0">
{t('via {{client}}', { client: usingClient })}
</span>
)
}

View File

@@ -1,76 +0,0 @@
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function Collapsible({
alwaysExpand = false,
children,
className,
threshold = 1000,
collapsedHeight = 600,
...props
}: {
alwaysExpand?: boolean
threshold?: number
collapsedHeight?: number
} & React.HTMLProps<HTMLDivElement>) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const [expanded, setExpanded] = useState(false)
const [shouldCollapse, setShouldCollapse] = useState(false)
useEffect(() => {
if (alwaysExpand || shouldCollapse) return
const contentEl = containerRef.current
if (!contentEl) return
const checkHeight = () => {
const fullHeight = contentEl.scrollHeight
if (fullHeight > threshold) {
setShouldCollapse(true)
}
}
checkHeight()
const observer = new ResizeObserver(() => {
checkHeight()
})
observer.observe(contentEl)
return () => {
observer.disconnect()
}
}, [alwaysExpand, shouldCollapse])
return (
<div
className={cn('relative text-left overflow-hidden', className)}
ref={containerRef}
{...props}
style={{
maxHeight: !shouldCollapse || expanded ? 'none' : `${collapsedHeight}px`
}}
>
{children}
{shouldCollapse && !expanded && (
<div className="absolute bottom-0 h-40 w-full z-10 bg-gradient-to-b from-transparent to-background/90 flex items-end justify-center pb-4">
<div className="bg-background rounded-lg">
<Button
className="bg-foreground hover:bg-foreground/80"
onClick={(e) => {
e.stopPropagation()
setExpanded(!expanded)
}}
>
{t('Show more')}
</Button>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,175 +0,0 @@
import { useTranslatedEvent } from '@/hooks'
import {
EmbeddedEmojiParser,
EmbeddedEventParser,
EmbeddedHashtagParser,
EmbeddedLNInvoiceParser,
EmbeddedMentionParser,
EmbeddedUrlParser,
EmbeddedWebsocketUrlParser,
parseContent
} from '@/lib/content-parser'
import { getImetaInfosFromEvent } from '@/lib/event'
import { getEmojiInfosFromEmojiTags, getImetaInfoFromImetaTag } from '@/lib/tag'
import { cn } from '@/lib/utils'
import mediaUpload from '@/services/media-upload.service'
import { TImetaInfo } from '@/types'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import {
EmbeddedHashtag,
EmbeddedLNInvoice,
EmbeddedMention,
EmbeddedNote,
EmbeddedWebsocketUrl
} from '../Embedded'
import Emoji from '../Emoji'
import ExternalLink from '../ExternalLink'
import ImageGallery from '../ImageGallery'
import MediaPlayer from '../MediaPlayer'
import WebPreview from '../WebPreview'
import XEmbeddedPost from '../XEmbeddedPost'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
export default function Content({
event,
content,
className,
mustLoadMedia
}: {
event?: Event
content?: string
className?: string
mustLoadMedia?: boolean
}) {
const translatedEvent = useTranslatedEvent(event?.id)
const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => {
const _content = translatedEvent?.content ?? event?.content ?? content
if (!_content) return {}
const nodes = parseContent(_content, [
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedUrlParser,
EmbeddedLNInvoiceParser,
EmbeddedWebsocketUrlParser,
EmbeddedHashtagParser,
EmbeddedEmojiParser
])
const imetaInfos = event ? getImetaInfosFromEvent(event) : []
const allImages = nodes
.map((node) => {
if (node.type === 'image') {
const imageInfo = imetaInfos.find((image) => image.url === node.data)
if (imageInfo) {
return imageInfo
}
const tag = mediaUpload.getImetaTagByUrl(node.data)
return tag
? getImetaInfoFromImetaTag(tag, event?.pubkey)
: { url: node.data, pubkey: event?.pubkey }
}
if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
return urls.map((url) => {
const imageInfo = imetaInfos.find((image) => image.url === url)
return imageInfo ?? { url, pubkey: event?.pubkey }
})
}
return null
})
.filter(Boolean)
.flat() as TImetaInfo[]
const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
const lastNormalUrl =
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
return { nodes, allImages, emojiInfos, lastNormalUrl }
}, [event, translatedEvent, content])
if (!nodes || nodes.length === 0) {
return null
}
let imageIndex = 0
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
{nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
if (node.type === 'image' || node.type === 'images') {
const start = imageIndex
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
imageIndex = end
return (
<ImageGallery
className="mt-2"
key={index}
images={allImages}
start={start}
end={end}
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'media') {
return (
<MediaPlayer className="mt-2" key={index} src={node.data} mustLoad={mustLoadMedia} />
)
}
if (node.type === 'url') {
return <ExternalLink url={node.data} key={index} />
}
if (node.type === 'invoice') {
return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" />
}
if (node.type === 'websocket-url') {
return <EmbeddedWebsocketUrl url={node.data} key={index} />
}
if (node.type === 'event') {
const id = node.data.split(':')[1]
return <EmbeddedNote key={index} noteId={id} className="mt-2" />
}
if (node.type === 'mention') {
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
}
if (node.type === 'hashtag') {
return <EmbeddedHashtag hashtag={node.data} key={index} />
}
if (node.type === 'emoji') {
const shortcode = node.data.split(':')[1]
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
if (!emoji) return node.data
return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
}
if (node.type === 'youtube') {
return (
<YoutubeEmbeddedPlayer
key={index}
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'x-post') {
return (
<XEmbeddedPost
key={index}
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
/>
)
}
return null
})}
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
</div>
)
}

View File

@@ -1,22 +0,0 @@
import { getCommunityDefinitionFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function CommunityDefinitionPreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const metadata = useMemo(() => getCommunityDefinitionFromEvent(event), [event])
return (
<div className={cn('pointer-events-none', className)}>
[{t('Community')}] <span className="italic pr-0.5">{metadata.name}</span>
</div>
)
}

View File

@@ -1,59 +0,0 @@
import {
EmbeddedEmojiParser,
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedUrlParser,
parseContent
} from '@/lib/content-parser'
import { cn } from '@/lib/utils'
import { TEmoji } from '@/types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { EmbeddedMentionText } from '../Embedded'
import Emoji from '../Emoji'
export default function Content({
content,
className,
emojiInfos
}: {
content: string
className?: string
emojiInfos?: TEmoji[]
}) {
const { t } = useTranslation()
const nodes = useMemo(() => {
return parseContent(content, [
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedUrlParser,
EmbeddedEmojiParser
])
}, [content])
return (
<span className={cn('pointer-events-none', className)}>
{nodes.map((node, index) => {
if (node.type === 'image' || node.type === 'images') {
return index > 0 ? ` [${t('Image')}]` : `[${t('Image')}]`
}
if (node.type === 'media') {
return index > 0 ? ` [${t('Media')}]` : `[${t('Media')}]`
}
if (node.type === 'event') {
return index > 0 ? ` [${t('Note')}]` : `[${t('Note')}]`
}
if (node.type === 'mention') {
return <EmbeddedMentionText key={index} userId={node.data.split(':')[1]} />
}
if (node.type === 'emoji') {
const shortcode = node.data.split(':')[1]
const emoji = emojiInfos?.find((e) => e.shortcode === shortcode)
if (!emoji) return node.data
return <Emoji key={index} emoji={emoji} classNames={{ img: 'size-4' }} />
}
return node.data
})}
</span>
)
}

View File

@@ -1,23 +0,0 @@
import { getEmojiPackInfoFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function EmojiPackPreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const { title, emojis } = useMemo(() => getEmojiPackInfoFromEvent(event), [event])
return (
<div className={cn('pointer-events-none', className)}>
[{t('Emoji Pack')}] <span className="italic pr-0.5">{title}</span>
{emojis.length > 0 && <span>({emojis.length})</span>}
</div>
)
}

View File

@@ -1,22 +0,0 @@
import { getFollowPackInfoFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function FollowPackPreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const { title } = useMemo(() => getFollowPackInfoFromEvent(event), [event])
return (
<div className={cn('truncate', className)}>
[{t('Follow Pack')}] <span className="italic pr-0.5">{title}</span>
</div>
)
}

View File

@@ -1,22 +0,0 @@
import { getGroupMetadataFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function GroupMetadataPreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const metadata = useMemo(() => getGroupMetadataFromEvent(event), [event])
return (
<div className={cn('pointer-events-none', className)}>
[{t('Group')}] <span className="italic pr-0.5">{metadata.name}</span>
</div>
)
}

View File

@@ -1,30 +0,0 @@
import { useTranslatedEvent } from '@/hooks'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Content from './Content'
export default function HighlightPreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const translatedEvent = useTranslatedEvent(event.id)
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event])
return (
<div className={cn('pointer-events-none', className)}>
[{t('Highlight')}]{' '}
<Content
content={translatedEvent?.content ?? event.content}
emojiInfos={emojiInfos}
className="italic pr-0.5"
/>
</div>
)
}

View File

@@ -1,22 +0,0 @@
import { getLiveEventMetadataFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function LiveEventPreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const metadata = useMemo(() => getLiveEventMetadataFromEvent(event), [event])
return (
<div className={cn('pointer-events-none', className)}>
[{t('Live event')}] <span className="italic pr-0.5">{metadata.title}</span>
</div>
)
}

View File

@@ -1,22 +0,0 @@
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function LongFormArticlePreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
return (
<div className={cn('pointer-events-none', className)}>
[{t('Article')}] <span className="italic pr-0.5">{metadata.title}</span>
</div>
)
}

View File

@@ -1,24 +0,0 @@
import { useTranslatedEvent } from '@/hooks'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import Content from './Content'
export default function NormalContentPreview({
event,
className
}: {
event: Event
className?: string
}) {
const translatedEvent = useTranslatedEvent(event?.id)
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event?.tags), [event])
return (
<Content
content={translatedEvent?.content ?? event.content}
className={className}
emojiInfos={emojiInfos}
/>
)
}

View File

@@ -1,19 +0,0 @@
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
export default function PictureNotePreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
return (
<div className={cn('pointer-events-none', className)}>
[{t('Image')}] <span className="italic pr-0.5">{event.content}</span>
</div>
)
}

View File

@@ -1,24 +0,0 @@
import { useTranslatedEvent } from '@/hooks'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Content from './Content'
export default function PollPreview({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation()
const translatedEvent = useTranslatedEvent(event.id)
const emojiInfos = useMemo(() => getEmojiInfosFromEmojiTags(event.tags), [event])
return (
<div className={cn('pointer-events-none', className)}>
[{t('Poll')}]{' '}
<Content
content={translatedEvent?.content ?? event.content}
emojiInfos={emojiInfos}
className="italic pr-0.5"
/>
</div>
)
}

View File

@@ -1,19 +0,0 @@
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
export default function VideoNotePreview({
event,
className
}: {
event: Event
className?: string
}) {
const { t } = useTranslation()
return (
<div className={cn('pointer-events-none', className)}>
[{t('Media')}] <span className="italic pr-0.5">{event.content}</span>
</div>
)
}

View File

@@ -1,114 +0,0 @@
import { ExtendedKind } from '@/constants'
import { isMentioningMutedUsers } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import CommunityDefinitionPreview from './CommunityDefinitionPreview'
import EmojiPackPreview from './EmojiPackPreview'
import FollowPackPreview from './FollowPackPreview'
import GroupMetadataPreview from './GroupMetadataPreview'
import HighlightPreview from './HighlightPreview'
import LiveEventPreview from './LiveEventPreview'
import LongFormArticlePreview from './LongFormArticlePreview'
import NormalContentPreview from './NormalContentPreview'
import PictureNotePreview from './PictureNotePreview'
import PollPreview from './PollPreview'
import VideoNotePreview from './VideoNotePreview'
export default function ContentPreview({
event,
className
}: {
event?: Event
className?: string
}) {
const { t } = useTranslation()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const isMuted = useMemo(
() => (event ? mutePubkeySet.has(event.pubkey) : false),
[mutePubkeySet, event]
)
const isMentioningMuted = useMemo(
() =>
hideContentMentioningMutedUsers && event
? isMentioningMutedUsers(event, mutePubkeySet)
: false,
[event, mutePubkeySet]
)
if (!event) {
return <div className={cn('pointer-events-none', className)}>{`[${t('Note not found')}]`}</div>
}
if (isMuted) {
return (
<div className={cn('pointer-events-none', className)}>[{t('This user has been muted')}]</div>
)
}
if (isMentioningMuted) {
return (
<div className={cn('pointer-events-none', className)}>
[{t('This note mentions a user you muted')}]
</div>
)
}
if (
[
kinds.ShortTextNote,
ExtendedKind.COMMENT,
ExtendedKind.VOICE,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.RELAY_REVIEW
].includes(event.kind)
) {
return <NormalContentPreview event={event} className={className} />
}
if (event.kind === kinds.Highlights) {
return <HighlightPreview event={event} className={className} />
}
if (event.kind === ExtendedKind.POLL) {
return <PollPreview event={event} className={className} />
}
if (event.kind === kinds.LongFormArticle) {
return <LongFormArticlePreview event={event} className={className} />
}
if (event.kind === ExtendedKind.VIDEO || event.kind === ExtendedKind.SHORT_VIDEO) {
return <VideoNotePreview event={event} className={className} />
}
if (event.kind === ExtendedKind.PICTURE) {
return <PictureNotePreview event={event} className={className} />
}
if (event.kind === ExtendedKind.GROUP_METADATA) {
return <GroupMetadataPreview event={event} className={className} />
}
if (event.kind === kinds.CommunityDefinition) {
return <CommunityDefinitionPreview event={event} className={className} />
}
if (event.kind === kinds.LiveEvent) {
return <LiveEventPreview event={event} className={className} />
}
if (event.kind === kinds.Emojisets) {
return <EmojiPackPreview event={event} className={className} />
}
if (event.kind === ExtendedKind.FOLLOW_PACK) {
return <FollowPackPreview event={event} className={className} />
}
return <div className={className}>[{t('Cannot handle event of kind k', { k: event.kind })}]</div>
}

View File

@@ -1,31 +0,0 @@
import { toWallet } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import storage from '@/services/local-storage.service'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export default function CreateWalletGuideToast() {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { profile } = useNostr()
useEffect(() => {
if (
profile &&
!profile.lightningAddress &&
!storage.hasShownCreateWalletGuideToast(profile.pubkey)
) {
toast(t('Set up your wallet to send and receive sats!'), {
action: {
label: t('Set up'),
onClick: () => push(toWallet())
}
})
storage.markCreateWalletGuideToastAsShown(profile.pubkey)
}
}, [profile])
return null
}

View File

@@ -1,27 +0,0 @@
import { useTranslation } from 'react-i18next'
import Image from '../Image'
import OpenSatsLogo from './open-sats-logo.svg'
export default function PlatinumSponsors() {
const { t } = useTranslation()
return (
<div className="space-y-2">
<div className="font-semibold text-center">{t('Platinum Sponsors')}</div>
<div className="flex flex-col gap-2 items-center">
<div
className="flex items-center gap-4 cursor-pointer"
onClick={() => window.open('https://opensats.org/', '_blank')}
>
<Image
image={{
url: OpenSatsLogo
}}
className="h-11"
/>
<div className="text-2xl font-semibold">OpenSats</div>
</div>
</div>
</div>
)
}

View File

@@ -1,48 +0,0 @@
import { formatAmount } from '@/lib/lightning'
import lightning, { TRecentSupporter } from '@/services/lightning.service'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
export default function RecentSupporters() {
const { t } = useTranslation()
const [supporters, setSupporters] = useState<TRecentSupporter[]>([])
useEffect(() => {
const init = async () => {
const items = await lightning.fetchRecentSupporters()
setSupporters(items)
}
init()
}, [])
if (!supporters.length) return null
return (
<div className="space-y-2">
<div className="font-semibold text-center">{t('Recent Supporters')}</div>
<div className="flex flex-col gap-2">
{supporters.map((item, index) => (
<div
key={index}
className="flex items-center justify-between rounded-md border p-2 sm:p-4 gap-2"
>
<div className="flex items-center gap-2 flex-1 w-0">
<UserAvatar userId={item.pubkey} />
<div className="flex-1 w-0">
<Username className="font-semibold w-fit" userId={item.pubkey} />
<div className="text-xs text-muted-foreground line-clamp-3 select-text">
{item.comment}
</div>
</div>
</div>
<div className="font-semibold text-yellow-400 shrink-0">
{formatAmount(item.amount)} {t('sats')}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -1,53 +0,0 @@
import { Button } from '@/components/ui/button'
import { JUMBLE_PUBKEY } from '@/constants'
import { cn } from '@/lib/utils'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ZapDialog from '../ZapDialog'
import PlatinumSponsors from './PlatinumSponsors'
import RecentSupporters from './RecentSupporters'
export default function Donation({ className }: { className?: string }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [donationAmount, setDonationAmount] = useState<number | undefined>(undefined)
return (
<div className={cn('p-4 border rounded-lg space-y-4', className)}>
<div className="text-center font-semibold">{t('Enjoying Jumble?')}</div>
<div className="text-center text-muted-foreground">
{t('Your donation helps me maintain Jumble and make it better! 😊')}
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ amount: 1000, text: '☕️ 1k' },
{ amount: 10000, text: '🍜 10k' },
{ amount: 100000, text: '🍣 100k' },
{ amount: 1000000, text: '✈️ 1M' }
].map(({ amount, text }) => {
return (
<Button
variant="secondary"
className=""
key={amount}
onClick={() => {
setDonationAmount(amount)
setOpen(true)
}}
>
{text}
</Button>
)
})}
</div>
<PlatinumSponsors />
<RecentSupporters />
<ZapDialog
open={open}
setOpen={setOpen}
pubkey={JUMBLE_PUBKEY}
defaultAmount={donationAmount}
/>
</div>
)
}

View File

@@ -1 +0,0 @@
<svg viewBox="344.564 330.278 111.737 91.218" width="53.87" height="43.61" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><defs><radialGradient xlink:href="#logo_svg__a" id="logo_svg__b" cx="31.833" cy="29.662" fx="31.833" fy="29.662" r="42.553" gradientTransform="matrix(2 0 0 1.99696 -74.45 12.982)" gradientUnits="userSpaceOnUse"></radialGradient><radialGradient xlink:href="#logo_svg__a" id="logo_svg__c" cx="31.833" cy="29.662" fx="31.833" fy="29.662" r="42.553" gradientTransform="matrix(2 0 0 1.99696 -74.45 12.982)" gradientUnits="userSpaceOnUse"></radialGradient><linearGradient id="logo_svg__a"><stop style="stop-color:#ffb200;stop-opacity:1" offset="0"></stop><stop style="stop-color:#ff6b01;stop-opacity:1" offset="0.493"></stop></linearGradient></defs><path style="font-variation-settings:'wght' 700;opacity:1;fill:url(#logo_svg__b);fill-opacity:1;stroke-width:10.5833;stroke-linecap:round;stroke-linejoin:round" d="M32.574 39.319v3.81h16.11v-3.81z" transform="translate(324.22 304.883) scale(2.39915)"></path><path style="font-variation-settings:'wght' 700;fill:url(#logo_svg__c);fill-opacity:1;stroke-width:10.5833;stroke-linecap:round;stroke-linejoin:round" d="M14.85 16.062v4.551l8.944 5.681v.137l-8.945 5.68v4.551l13.029-8.555v-3.49Z" transform="translate(324.22 304.883) scale(2.39915)"></path></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,25 +0,0 @@
import { Button } from '@/components/ui/button'
import { DrawerClose } from '@/components/ui/drawer'
import { cn } from '@/lib/utils'
export default function DrawerMenuItem({
children,
className,
onClick
}: {
children: React.ReactNode
className?: string
onClick?: (e: React.MouseEvent) => void
}) {
return (
<DrawerClose className="w-full">
<Button
onClick={onClick}
className={cn('w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5', className)}
variant="ghost"
>
{children}
</Button>
</DrawerClose>
)
}

View File

@@ -1,14 +0,0 @@
import { toNoteList } from '@/lib/link'
import { SecondaryPageLink } from '@/PageManager'
export function EmbeddedHashtag({ hashtag }: { hashtag: string }) {
return (
<SecondaryPageLink
className="text-primary hover:underline"
to={toNoteList({ hashtag: hashtag.replace('#', '') })}
onClick={(e) => e.stopPropagation()}
>
{hashtag}
</SecondaryPageLink>
)
}

View File

@@ -1,64 +0,0 @@
import { Button } from '@/components/ui/button'
import { formatAmount, getInvoiceDetails } from '@/lib/lightning'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import lightning from '@/services/lightning.service'
import { Loader, Zap } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export function EmbeddedLNInvoice({ invoice, className }: { invoice: string; className?: string }) {
const { t } = useTranslation()
const { checkLogin, pubkey } = useNostr()
const [paying, setPaying] = useState(false)
const { amount, description } = useMemo(() => {
return getInvoiceDetails(invoice)
}, [invoice])
const handlePay = async () => {
try {
if (!pubkey) {
throw new Error('You need to be logged in to zap')
}
setPaying(true)
const invoiceResult = await lightning.payInvoice(invoice)
// user canceled
if (!invoiceResult) {
return
}
} catch (error) {
toast.error(t('Lightning payment failed') + ': ' + (error as Error).message)
} finally {
setPaying(false)
}
}
const handlePayClick = (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(() => handlePay())
}
return (
<div
className={cn('p-3 border rounded-lg cursor-default flex flex-col gap-3 max-w-sm', className)}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-400" />
<div className="font-semibold text-sm">{t('Lightning Invoice')}</div>
</div>
{description && (
<div className="text-sm text-muted-foreground break-words">{description}</div>
)}
<div className="text-lg font-bold">
{formatAmount(amount)} {t('sats')}
</div>
<Button onClick={handlePayClick}>
{paying && <Loader className="w-4 h-4 animate-spin" />}
{t('Pay')}
</Button>
</div>
)
}

View File

@@ -1,19 +0,0 @@
import { cn } from '@/lib/utils'
import Username, { SimpleUsername } from '../Username'
export function EmbeddedMention({ userId, className }: { userId: string; className?: string }) {
return (
<Username
userId={userId}
showAt
className={cn('text-primary font-normal inline', className)}
withoutSkeleton
/>
)
}
export function EmbeddedMentionText({ userId, className }: { userId: string; className?: string }) {
return (
<SimpleUsername userId={userId} showAt className={cn('inline', className)} withoutSkeleton />
)
}

View File

@@ -1,59 +0,0 @@
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchEvent } from '@/hooks'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import ClientSelect from '../ClientSelect'
import MainNoteCard from '../NoteCard/MainNoteCard'
export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) {
const { event, isFetching } = useFetchEvent(noteId)
if (isFetching) {
return <EmbeddedNoteSkeleton className={className} />
}
if (!event) {
return <EmbeddedNoteNotFound className={className} noteId={noteId} />
}
return (
<MainNoteCard
className={cn('w-full', className)}
event={event}
embedded
originalNoteId={noteId}
/>
)
}
function EmbeddedNoteSkeleton({ className }: { className?: string }) {
return (
<div
className={cn('text-left p-2 sm:p-3 border rounded-xl bg-card', className)}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center space-x-2">
<Skeleton className="w-9 h-9 rounded-full" />
<div>
<Skeleton className="h-3 w-16 my-1" />
<Skeleton className="h-3 w-16 my-1" />
</div>
</div>
<Skeleton className="w-full h-4 my-1 mt-2" />
<Skeleton className="w-2/3 h-4 my-1" />
</div>
)
}
function EmbeddedNoteNotFound({ noteId, className }: { noteId: string; className?: string }) {
const { t } = useTranslation()
return (
<div className={cn('text-left p-2 sm:p-3 border rounded-xl bg-card', className)}>
<div className="flex flex-col items-center text-muted-foreground font-medium gap-2">
<div>{t('Sorry! The note cannot be found 😔')}</div>
<ClientSelect className="w-full mt-2" originalNoteId={noteId} />
</div>
</div>
)
}

View File

@@ -1,18 +0,0 @@
import { useSecondaryPage } from '@/PageManager'
import { toRelay } from '@/lib/link'
export function EmbeddedWebsocketUrl({ url }: { url: string }) {
const { push } = useSecondaryPage()
return (
<span
className="cursor-pointer px-1 text-primary hover:bg-primary/20"
onClick={(e) => {
e.stopPropagation()
push(toRelay(url))
}}
>
[ {url} ]
<span className="w-2 h-1 bg-primary" />
</span>
)
}

View File

@@ -1,5 +0,0 @@
export * from './EmbeddedHashtag'
export * from './EmbeddedLNInvoice'
export * from './EmbeddedMention'
export * from './EmbeddedNote'
export * from './EmbeddedWebsocketUrl'

View File

@@ -1,46 +0,0 @@
import { cn } from '@/lib/utils'
import { TEmoji } from '@/types'
import { Heart } from 'lucide-react'
import { HTMLAttributes, useState } from 'react'
export default function Emoji({
emoji,
classNames
}: Omit<HTMLAttributes<HTMLDivElement>, 'className'> & {
emoji: TEmoji | string
classNames?: {
text?: string
img?: string
}
}) {
const [hasError, setHasError] = useState(false)
if (typeof emoji === 'string') {
return emoji === '+' ? (
<Heart className={cn('size-5 text-red-400 fill-red-400', classNames?.img)} />
) : (
<span className={cn('whitespace-nowrap', classNames?.text)}>{emoji}</span>
)
}
if (hasError) {
return (
<span className={cn('whitespace-nowrap', classNames?.text)}>{`:${emoji.shortcode}:`}</span>
)
}
return (
<img
src={emoji.url}
alt={emoji.shortcode}
draggable={false}
className={cn('inline-block size-5 rounded-sm pointer-events-none', classNames?.img)}
onLoad={() => {
setHasError(false)
}}
onError={() => {
setHasError(true)
}}
/>
)
}

View File

@@ -1,86 +0,0 @@
import { useFetchEvent } from '@/hooks'
import { generateBech32IdFromATag } from '@/lib/tag'
import { useNostr } from '@/providers/NostrProvider'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
const SHOW_COUNT = 10
export default function EmojiPackList() {
const { t } = useTranslation()
const { userEmojiListEvent } = useNostr()
const eventIds = useMemo(() => {
if (!userEmojiListEvent) return []
return (
userEmojiListEvent.tags
.map((tag) => (tag[0] === 'a' ? generateBech32IdFromATag(tag) : null))
.filter(Boolean) as `naddr1${string}`[]
).reverse()
}, [userEmojiListEvent])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 0.1
}
const loadMore = () => {
if (showCount < eventIds.length) {
setShowCount((prev) => prev + SHOW_COUNT)
}
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMore()
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [showCount, eventIds])
if (eventIds.length === 0) {
return (
<div className="mt-2 text-sm text-center text-muted-foreground">
{t('no emoji packs found')}
</div>
)
}
return (
<div>
{eventIds.slice(0, showCount).map((eventId) => (
<EmojiPackNote key={eventId} eventId={eventId} />
))}
</div>
)
}
function EmojiPackNote({ eventId }: { eventId: string }) {
const { event, isFetching } = useFetchEvent(eventId)
if (isFetching) {
return <NoteCardLoadingSkeleton className="border-b" />
}
if (!event) {
return null
}
return <NoteCard event={event} className="w-full" />
}

View File

@@ -1,48 +0,0 @@
import { parseEmojiPickerUnified } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useTheme } from '@/providers/ThemeProvider'
import customEmojiService from '@/services/custom-emoji.service'
import { TEmoji } from '@/types'
import EmojiPickerReact, {
EmojiStyle,
SkinTonePickerLocation,
SuggestionMode,
Theme
} from 'emoji-picker-react'
export default function EmojiPicker({
onEmojiClick
}: {
onEmojiClick: (emoji: string | TEmoji | undefined, event: MouseEvent) => void
}) {
const { themeSetting } = useTheme()
const { isSmallScreen } = useScreenSize()
return (
<EmojiPickerReact
theme={
themeSetting === 'system' ? Theme.AUTO : themeSetting === 'dark' ? Theme.DARK : Theme.LIGHT
}
width={isSmallScreen ? '100%' : 350}
autoFocusSearch={false}
emojiStyle={EmojiStyle.NATIVE}
skinTonePickerLocation={SkinTonePickerLocation.PREVIEW}
style={
{
'--epr-bg-color': 'hsl(var(--background))',
'--epr-category-label-bg-color': 'hsl(var(--background))',
'--epr-text-color': 'hsl(var(--foreground))',
'--epr-hover-bg-color': 'hsl(var(--muted) / 0.5)',
'--epr-picker-border-color': 'transparent',
'--epr-search-input-bg-color': 'hsl(var(--muted) / 0.5)'
} as React.CSSProperties
}
suggestedEmojisMode={SuggestionMode.FREQUENT}
onEmojiClick={(data, e) => {
const emoji = parseEmojiPickerUnified(data.unified)
onEmojiClick(emoji, e)
}}
customEmojis={customEmojiService.getAllCustomEmojisForPicker()}
/>
)
}

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