Compare commits
1 Commits
refactor-f
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
599c000aa5 |
@@ -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
|
||||
2
.gitignore
vendored
@@ -24,5 +24,3 @@ dev-dist
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.vercel
|
||||
|
||||
410
AGENTS.md
@@ -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
|
||||
49
Dockerfile
@@ -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;"]
|
||||
21
LICENSE
@@ -1,21 +0,0 @@
|
||||
MIT LICENSE
|
||||
|
||||
Copyright (c) 2025 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
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
51
README.md
@@ -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,17 +9,23 @@
|
||||
|
||||
# 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 Feeds:** Explore content directly through relays without following specific users
|
||||
- **Relay-Friendly Design:** Minimized and simplified requests ensure efficient communication with relays
|
||||
- **Relay Sets:** Easily manage and switch between relay sets
|
||||
- **Clean Interface:** Enjoy a minimalist design and intuitive interactions
|
||||
|
||||
> Some interesting forks of Jumble.
|
||||
## Screenshots
|
||||
|
||||
- [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)
|
||||
<img src="./screenshots/01.png" alt="Jumble Screenshot 01" width="650" />
|
||||
<div>
|
||||
<img src="./screenshots/02.png" alt="Jumble Screenshot 02" width="200" />
|
||||
<img src="./screenshots/03.png" alt="Jumble Screenshot 03" width="200" />
|
||||
<img src="./screenshots/04.png" alt="Jumble Screenshot 04" width="200" />
|
||||
</div>
|
||||
|
||||
## Run Locally
|
||||
|
||||
@@ -35,34 +43,15 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 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>
|
||||
|
||||
## Donate
|
||||
|
||||
If you like this project, you can buy me a coffee :)
|
||||
|
||||
- **Lightning:** ⚡️ codytseng@getalby.com ⚡️
|
||||
- **Bitcoin:** bc1qwp2uqjd2dy32qfe39kehnlgx3hyey0h502fvht
|
||||
- **Geyser:** https://geyser.fund/project/jumble
|
||||
lightning: ⚡️ codytseng@getalby.com ⚡️
|
||||
|
||||
bitcoin: bc1qx8kvutghdhejx7vuvatmvw2ghypdungu0qm7ds
|
||||
|
||||
geyser: https://geyser.fund/project/jumble
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -5,16 +5,17 @@
|
||||
<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="description" content="A beautiful nostr client focused on browsing 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="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<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="#09090b" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
|
||||
|
||||
<meta property="og:url" content="https://jumble.social" />
|
||||
@@ -22,7 +23,7 @@
|
||||
<meta property="og:title" content="Jumble" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A user-friendly Nostr client for exploring relay feeds"
|
||||
content="A beautiful nostr client focused on browsing relay feeds"
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
|
||||
4104
package-lock.json
generated
47
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jumble",
|
||||
"version": "0.1.0",
|
||||
"description": "A user-friendly Nostr client for exploring relay feeds",
|
||||
"description": "A beautiful nostr client focused on browsing relay feeds",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"author": "codytseng",
|
||||
@@ -19,74 +19,46 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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",
|
||||
"@getalby/bitcoin-connect-react": "^3.7.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-slot": "^1.1.1",
|
||||
"@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",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"dataloader": "^2.2.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"embla-carousel-wheel-gestures": "^8.1.0",
|
||||
"emoji-picker-react": "^4.12.2",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
"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",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"nstart-modal": "^1.4.0",
|
||||
"path-to-regexp": "^8.2.0",
|
||||
"qr-code-styling": "^1.9.2",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"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",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"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"
|
||||
@@ -96,7 +68,6 @@
|
||||
"@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",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
@@ -108,7 +79,7 @@
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.18.1",
|
||||
"vite": "^6.0.3",
|
||||
"vite": "^6.2.1",
|
||||
"vite-plugin-pwa": "^0.21.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"names": {
|
||||
"_": "f4eb8e62add1340b9cadcd9861e669b2e907cea534e0f7f3ac974c11c758a51a",
|
||||
"cody": "8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883",
|
||||
"cody2": "24462930821b45f530ec0063eca0a6522e5a577856f982fa944df0ef3caf03ab"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 778 B |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 668 B |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 3.3 KiB |
BIN
public/pwa-maskable-192x192.png
Normal file
|
After Width: | Height: | Size: 669 B |
BIN
public/pwa-maskable-512x512.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 10 KiB |
BIN
screenshots/01.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
screenshots/02.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
screenshots/03.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
screenshots/04.png
Normal file
|
After Width: | Height: | Size: 3.8 MiB |
91
src/App.tsx
@@ -1,72 +1,39 @@
|
||||
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 { Toaster } from '@/components/ui/toaster'
|
||||
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'
|
||||
import { FeedProvider } from './providers/FeedProvider'
|
||||
import { FollowListProvider } from './providers/FollowListProvider'
|
||||
import { MuteListProvider } from './providers/MuteListProvider'
|
||||
import { NostrProvider } from './providers/NostrProvider'
|
||||
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
||||
import { RelaySetsProvider } from './providers/RelaySetsProvider'
|
||||
import { ScreenSizeProvider } from './providers/ScreenSizeProvider'
|
||||
import { ZapProvider } from './providers/ZapProvider'
|
||||
|
||||
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>
|
||||
<ThemeProvider>
|
||||
<ScreenSizeProvider>
|
||||
<NostrProvider>
|
||||
<ZapProvider>
|
||||
<RelaySetsProvider>
|
||||
<FollowListProvider>
|
||||
<MuteListProvider>
|
||||
<FeedProvider>
|
||||
<NoteStatsProvider>
|
||||
<PageManager />
|
||||
<Toaster />
|
||||
</NoteStatsProvider>
|
||||
</FeedProvider>
|
||||
</MuteListProvider>
|
||||
</FollowListProvider>
|
||||
</RelaySetsProvider>
|
||||
</ZapProvider>
|
||||
</NostrProvider>
|
||||
</ScreenSizeProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Sidebar from '@/components/Sidebar'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider'
|
||||
import NoteListPage from '@/pages/primary/NoteListPage'
|
||||
import HomePage from '@/pages/secondary/HomePage'
|
||||
import { TPageRef } from '@/types'
|
||||
import {
|
||||
cloneElement,
|
||||
@@ -10,26 +12,20 @@ import {
|
||||
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 ExplorePage from './pages/primary/ExplorePage'
|
||||
import MePage from './pages/primary/MePage'
|
||||
import NotificationListPage from './pages/primary/NotificationListPage'
|
||||
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'
|
||||
import { routes } from './routes'
|
||||
|
||||
export type TPrimaryPageName = keyof typeof PRIMARY_PAGE_MAP
|
||||
|
||||
type TPrimaryPageContext = {
|
||||
navigate: (page: TPrimaryPageName, props?: object) => void
|
||||
navigate: (page: TPrimaryPageName) => void
|
||||
current: TPrimaryPageName | null
|
||||
display: boolean
|
||||
}
|
||||
|
||||
type TSecondaryPageContext = {
|
||||
@@ -41,10 +37,24 @@ type TSecondaryPageContext = {
|
||||
type TStackItem = {
|
||||
index: number
|
||||
url: string
|
||||
element: React.ReactElement | null
|
||||
component: React.ReactElement | null
|
||||
ref: RefObject<TPageRef> | null
|
||||
}
|
||||
|
||||
const PRIMARY_PAGE_REF_MAP = {
|
||||
home: createRef<TPageRef>(),
|
||||
explore: createRef<TPageRef>(),
|
||||
notifications: createRef<TPageRef>(),
|
||||
me: createRef<TPageRef>()
|
||||
}
|
||||
|
||||
const PRIMARY_PAGE_MAP = {
|
||||
home: <NoteListPage ref={PRIMARY_PAGE_REF_MAP.home} />,
|
||||
explore: <ExplorePage ref={PRIMARY_PAGE_REF_MAP.explore} />,
|
||||
notifications: <NotificationListPage ref={PRIMARY_PAGE_REF_MAP.notifications} />,
|
||||
me: <MePage ref={PRIMARY_PAGE_REF_MAP.me} />
|
||||
}
|
||||
|
||||
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
|
||||
|
||||
const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(undefined)
|
||||
@@ -68,7 +78,7 @@ export function useSecondaryPage() {
|
||||
export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
const [currentPrimaryPage, setCurrentPrimaryPage] = useState<TPrimaryPageName>('home')
|
||||
const [primaryPages, setPrimaryPages] = useState<
|
||||
{ name: TPrimaryPageName; element: ReactNode; props?: any }[]
|
||||
{ name: TPrimaryPageName; element: ReactNode }[]
|
||||
>([
|
||||
{
|
||||
name: 'home',
|
||||
@@ -77,44 +87,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
])
|
||||
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) => {
|
||||
@@ -131,30 +105,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
}
|
||||
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
|
||||
@@ -176,7 +129,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
}
|
||||
|
||||
if (state.index === currentIndex) {
|
||||
return pre
|
||||
if (currentIndex !== 0) return pre
|
||||
|
||||
window.history.replaceState(null, '', '/')
|
||||
return []
|
||||
}
|
||||
|
||||
// Go back
|
||||
@@ -184,20 +140,20 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
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) {
|
||||
const { component, ref } = findAndCreateComponent(state.url, state.index)
|
||||
if (component) {
|
||||
newStack.push({
|
||||
index: state.index,
|
||||
url: state.url,
|
||||
element,
|
||||
component,
|
||||
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
|
||||
} else if (!topItem.component) {
|
||||
// Load the component if it's not cached
|
||||
const { component, ref } = findAndCreateComponent(topItem.url, state.index)
|
||||
if (component) {
|
||||
topItem.component = component
|
||||
topItem.ref = ref
|
||||
}
|
||||
}
|
||||
@@ -215,23 +171,14 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
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')
|
||||
const navigatePrimaryPage = (page: TPrimaryPageName) => {
|
||||
const exists = primaryPages.find((p) => p.name === page)
|
||||
if (!exists) {
|
||||
setPrimaryPages((prev) => [...prev, { name: page, element: PRIMARY_PAGE_MAP[page] }])
|
||||
}
|
||||
if (enableSingleColumnLayout) {
|
||||
setCurrentPrimaryPage(page)
|
||||
PRIMARY_PAGE_REF_MAP[page].current?.scrollToTop()
|
||||
if (isSmallScreen) {
|
||||
clearSecondaryPages()
|
||||
}
|
||||
}
|
||||
@@ -241,7 +188,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
if (isCurrentPage(prevStack, url)) {
|
||||
const currentItem = prevStack[prevStack.length - 1]
|
||||
if (currentItem?.ref?.current) {
|
||||
currentItem.ref.current.scrollToTop('instant')
|
||||
currentItem.ref.current.scrollToTop()
|
||||
}
|
||||
return prevStack
|
||||
}
|
||||
@@ -254,19 +201,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
})
|
||||
}
|
||||
|
||||
const popSecondaryPage = (delta = -1) => {
|
||||
if (secondaryStack.length <= -delta) {
|
||||
// back to home page
|
||||
window.history.replaceState(null, '', '/')
|
||||
setSecondaryStack([])
|
||||
} else {
|
||||
window.history.go(delta)
|
||||
}
|
||||
const popSecondaryPage = () => {
|
||||
window.history.go(-1)
|
||||
}
|
||||
|
||||
const clearSecondaryPages = () => {
|
||||
if (secondaryStack.length === 0) return
|
||||
popSecondaryPage(-secondaryStack.length)
|
||||
window.history.go(-secondaryStack.length)
|
||||
}
|
||||
|
||||
if (isSmallScreen) {
|
||||
@@ -274,8 +215,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
<PrimaryPageContext.Provider
|
||||
value={{
|
||||
navigate: navigatePrimaryPage,
|
||||
current: currentPrimaryPage,
|
||||
display: secondaryStack.length === 0
|
||||
current: secondaryStack.length === 0 ? currentPrimaryPage : null
|
||||
}}
|
||||
>
|
||||
<SecondaryPageContext.Provider
|
||||
@@ -287,97 +227,30 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
: 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 }) => (
|
||||
<NotificationProvider>
|
||||
{!!secondaryStack.length &&
|
||||
secondaryStack.map((item, index) => (
|
||||
<div
|
||||
key={name}
|
||||
key={item.index}
|
||||
style={{
|
||||
display:
|
||||
secondaryStack.length === 0 && currentPrimaryPage === name ? 'block' : 'none'
|
||||
display: index === secondaryStack.length - 1 ? 'block' : 'none'
|
||||
}}
|
||||
>
|
||||
{props ? cloneElement(element as React.ReactElement, props) : element}
|
||||
{item.component}
|
||||
</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" />
|
||||
{primaryPages.map(({ name, element }) => (
|
||||
<div
|
||||
key={name}
|
||||
style={{
|
||||
display:
|
||||
secondaryStack.length === 0 && currentPrimaryPage === name ? 'block' : 'none'
|
||||
}}
|
||||
>
|
||||
{element}
|
||||
</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>
|
||||
))}
|
||||
</NotificationProvider>
|
||||
</SecondaryPageContext.Provider>
|
||||
</PrimaryPageContext.Provider>
|
||||
)
|
||||
@@ -387,8 +260,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
<PrimaryPageContext.Provider
|
||||
value={{
|
||||
navigate: navigatePrimaryPage,
|
||||
current: currentPrimaryPage,
|
||||
display: true
|
||||
current: currentPrimaryPage
|
||||
}}
|
||||
>
|
||||
<SecondaryPageContext.Provider
|
||||
@@ -398,66 +270,40 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<NotificationProvider>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar />
|
||||
<Separator orientation="vertical" />
|
||||
<div className="grid grid-cols-2 w-full">
|
||||
<div className="flex border-r">
|
||||
{primaryPages.map(({ name, element }) => (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background overflow-hidden',
|
||||
themeSetting === 'pure-black' ? 'border-l' : 'rounded-2xl shadow-lg'
|
||||
)}
|
||||
key={name}
|
||||
className="w-full"
|
||||
style={{
|
||||
display: currentPrimaryPage === name ? 'block' : 'none'
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
{element}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
{secondaryStack.map((item, index) => (
|
||||
<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' : ''
|
||||
)}
|
||||
key={item.index}
|
||||
style={{ display: index === secondaryStack.length - 1 ? 'block' : 'none' }}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
{item.component}
|
||||
</div>
|
||||
))}
|
||||
<div key="home" style={{ display: secondaryStack.length === 0 ? 'block' : 'none' }}>
|
||||
<HomePage />
|
||||
</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>
|
||||
</div>
|
||||
</NotificationProvider>
|
||||
</SecondaryPageContext.Provider>
|
||||
</PrimaryPageContext.Provider>
|
||||
)
|
||||
@@ -498,15 +344,15 @@ function isCurrentPage(stack: TStackItem[], url: string) {
|
||||
return currentPage.url === url
|
||||
}
|
||||
|
||||
function findAndCloneElement(url: string, index: number) {
|
||||
function findAndCreateComponent(url: string, index: number) {
|
||||
const path = url.split('?')[0].split('#')[0]
|
||||
for (const { matcher, element } of SECONDARY_ROUTES) {
|
||||
for (const { matcher, element } of 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 { component: cloneElement(element, { ...match.params, index, ref } as any), ref }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
@@ -520,15 +366,15 @@ function pushNewPageToStack(
|
||||
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 { component, ref } = findAndCreateComponent(url, currentIndex)
|
||||
if (!component) return { newStack: stack, newItem: null }
|
||||
|
||||
const newItem = { element, ref, url, index: currentIndex }
|
||||
const newItem = { component, 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
|
||||
const lastCachedIndex = newStack.findIndex((stack) => stack.component)
|
||||
// Clear the oldest cached component if there are too many cached components
|
||||
if (newStack.length - lastCachedIndex > maxStackSize) {
|
||||
newStack[lastCachedIndex].element = null
|
||||
newStack[lastCachedIndex].component = null
|
||||
}
|
||||
return { newStack, newItem }
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 47 KiB |
@@ -13,7 +13,7 @@ export default function AboutInfoDialog({ children }: { children: React.ReactNod
|
||||
<>
|
||||
<div className="text-xl font-semibold">Jumble</div>
|
||||
<div className="text-muted-foreground">
|
||||
A user-friendly Nostr client for exploring relay feeds
|
||||
A beautiful nostr client focused on browsing relay feeds
|
||||
</div>
|
||||
<div>
|
||||
Made by <Username userId={CODY_PUBKEY} className="inline-block text-primary" showAt />
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 { TAccountPointer, TSignerType } from '@/types'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import SignerTypeBadge from '../SignerTypeBadge'
|
||||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
import { SimpleUsername } from '../Username'
|
||||
|
||||
@@ -17,7 +16,7 @@ export default function AccountList({
|
||||
className?: string
|
||||
afterSwitch: () => void
|
||||
}) {
|
||||
const { accounts, account, switchAccount, removeAccount } = useNostr()
|
||||
const { accounts, account, switchAccount } = useNostr()
|
||||
const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null)
|
||||
|
||||
return (
|
||||
@@ -38,30 +37,17 @@ export default function AccountList({
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-center p-2">
|
||||
<div className="flex-1 flex items-center gap-2 relative">
|
||||
<div className="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>
|
||||
<SimpleUsername userId={act.pubkey} className="font-semibold" />
|
||||
<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 className="flex gap-2 items-center">
|
||||
<SignerTypeBadge signerType={act.signerType} />
|
||||
</div>
|
||||
</div>
|
||||
{switchingAccount && isSameAccount(act, switchingAccount) && (
|
||||
@@ -74,3 +60,15 @@ export default function AccountList({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SignerTypeBadge({ signerType }: { signerType: TSignerType }) {
|
||||
if (signerType === 'nip-07') {
|
||||
return <Badge className=" bg-green-400 hover:bg-green-400/80">NIP-07</Badge>
|
||||
} else if (signerType === 'bunker') {
|
||||
return <Badge className=" bg-blue-400 hover:bg-blue-400/80">Bunker</Badge>
|
||||
} else if (signerType === 'ncryptsec') {
|
||||
return <Badge>NCRYPTSEC</Badge>
|
||||
} else {
|
||||
return <Badge className=" bg-orange-400 hover:bg-orange-400/80">NSEC</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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'
|
||||
@@ -19,63 +18,38 @@ export default function GenerateNewAccount({
|
||||
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())
|
||||
nsecLogin(nsec).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')}
|
||||
<Input value={nsec} />
|
||||
<Button variant="secondary" onClick={() => setNsec(generateNsec())}>
|
||||
<RefreshCcw />
|
||||
</Button>
|
||||
<Button className="flex-1" type="submit">
|
||||
{t('Login')}
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(nsec)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<Button onClick={handleLogin}>{t('Login')}</Button>
|
||||
<Button variant="secondary" onClick={back}>
|
||||
{t('Back')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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'
|
||||
@@ -32,19 +31,19 @@ export default function PrivateKeyLogin({
|
||||
function NsecLogin({ back, onLoginSuccess }: { back: () => void; onLoginSuccess: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const { nsecLogin } = useNostr()
|
||||
const [nsecOrHex, setNsecOrHex] = useState('')
|
||||
const [nsec, setNsec] = useState('')
|
||||
const [errMsg, setErrMsg] = useState<string | null>(null)
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNsecOrHex(e.target.value)
|
||||
setNsec(e.target.value)
|
||||
setErrMsg(null)
|
||||
}
|
||||
|
||||
const handleLogin = () => {
|
||||
if (nsecOrHex === '') return
|
||||
if (nsec === '') return
|
||||
|
||||
nsecLogin(nsecOrHex, password)
|
||||
nsecLogin(nsec, password)
|
||||
.then(() => onLoginSuccess())
|
||||
.catch((err) => {
|
||||
setErrMsg(err.message)
|
||||
@@ -52,49 +51,39 @@ function NsecLogin({ back, onLoginSuccess }: { back: () => void; onLoginSuccess:
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleLogin()
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<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.'
|
||||
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.'
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="nsec-input">nsec or hex</Label>
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-sm font-semibold">nsec</div>
|
||||
<Input
|
||||
id="nsec-input"
|
||||
type="password"
|
||||
placeholder="nsec1.. or hex"
|
||||
value={nsecOrHex}
|
||||
placeholder="nsec1.."
|
||||
value={nsec}
|
||||
onChange={handleInputChange}
|
||||
className={errMsg ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errMsg && <div className="text-xs text-destructive">{errMsg}</div>}
|
||||
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password-input">{t('password')}</Label>
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-sm font-semibold">{t('password')}</div>
|
||||
<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>
|
||||
<Button className="w-full" onClick={handleLogin}>
|
||||
{t('Login')}
|
||||
</Button>
|
||||
<Button className="w-full" variant="secondary" onClick={back}>
|
||||
{t('Back')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -126,33 +115,28 @@ function NcryptsecLogin({
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleLogin()
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ncryptsec-input">ncryptsec</Label>
|
||||
<div className="space-y-4">
|
||||
<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.'
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<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>}
|
||||
{errMsg && <div className="text-xs text-destructive pl-3">{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>
|
||||
<Button className="w-full" onClick={handleLogin}>
|
||||
{t('Login')}
|
||||
</Button>
|
||||
<Button className="w-full" variant="secondary" onClick={back}>
|
||||
{t('Back')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
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 BunkerLogin from './BunkerLogin'
|
||||
import GenerateNewAccount from './GenerateNewAccount'
|
||||
import NostrConnectLogin from './NostrConnectionLogin'
|
||||
import NpubLogin from './NpubLogin'
|
||||
import PrivateKeyLogin from './PrivateKeyLogin'
|
||||
|
||||
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | 'npub' | null
|
||||
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | null
|
||||
|
||||
export default function AccountManager({ close }: { close?: () => void }) {
|
||||
const [page, setPage] = useState<TAccountManagerPage>(null)
|
||||
@@ -22,11 +20,9 @@ export default function AccountManager({ close }: { close?: () => void }) {
|
||||
{page === 'nsec' ? (
|
||||
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'bunker' ? (
|
||||
<NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
<BunkerLogin 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} />
|
||||
)}
|
||||
@@ -41,7 +37,7 @@ function AccountManagerNav({
|
||||
setPage: (page: TAccountManagerPage) => void
|
||||
close?: () => void
|
||||
}) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
const { themeSetting } = useTheme()
|
||||
const { nip07Login, bunkerLogin, nsecLogin, ncryptsecLogin, accounts } = useNostr()
|
||||
|
||||
@@ -63,11 +59,6 @@ function AccountManagerNav({
|
||||
<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 />
|
||||
@@ -78,10 +69,9 @@ function AccountManagerNav({
|
||||
<Button
|
||||
onClick={() => {
|
||||
const wizard = new NstartModal({
|
||||
baseUrl: 'https://nstart.me',
|
||||
baseUrl: 'https://start.njump.me',
|
||||
an: 'Jumble',
|
||||
am: themeSetting === 'pure-black' ? 'dark' : themeSetting,
|
||||
al: i18n.language.slice(0, 2),
|
||||
am: themeSetting,
|
||||
onComplete: ({ nostrLogin }) => {
|
||||
if (!nostrLogin) return
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')}`
|
||||
}
|
||||
22
src/components/BackButton/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function BackButton({ children }: { children?: React.ReactNode }) {
|
||||
const { t } = useTranslation()
|
||||
const { pop } = useSecondaryPage()
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="flex gap-1 items-center w-fit max-w-full justify-start pl-2 pr-3"
|
||||
variant="ghost"
|
||||
size="titlebar-icon"
|
||||
title={t('back')}
|
||||
onClick={() => pop()}
|
||||
>
|
||||
<ChevronLeft />
|
||||
<div className="truncate text-lg font-semibold">{children}</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
}
|
||||
@@ -1,57 +1,44 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { LONG_PRESS_THRESHOLD } from '@/constants'
|
||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||
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 { useMemo } from 'react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'
|
||||
import { Skeleton } from '../ui/skeleton'
|
||||
import BottomNavigationBarItem from './BottomNavigationBarItem'
|
||||
|
||||
export default function AccountButton() {
|
||||
const { navigate, current, display } = usePrimaryPage()
|
||||
const { navigate, current } = 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
|
||||
}
|
||||
}
|
||||
const defaultAvatar = useMemo(
|
||||
() => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''),
|
||||
[profile]
|
||||
)
|
||||
|
||||
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' : '')} />
|
||||
)
|
||||
<BottomNavigationBarItem
|
||||
onClick={() => {
|
||||
navigate('me')
|
||||
}}
|
||||
active={current === 'me'}
|
||||
>
|
||||
{pubkey ? (
|
||||
profile ? (
|
||||
<Avatar className={cn('w-7 h-7', current === 'me' ? 'ring-primary ring-1' : '')}>
|
||||
<AvatarImage src={profile.avatar} className="object-cover object-center" />
|
||||
<AvatarFallback>
|
||||
<img src={defaultAvatar} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
) : (
|
||||
<UserRound />
|
||||
)}
|
||||
</BottomNavigationBarItem>
|
||||
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
|
||||
</>
|
||||
<Skeleton
|
||||
className={cn('w-7 h-7 rounded-full', current === 'me' ? 'ring-primary ring-1' : '')}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<UserRound />
|
||||
)}
|
||||
</BottomNavigationBarItem>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '../ui/button'
|
||||
import { MouseEventHandler } from 'react'
|
||||
|
||||
export default function BottomNavigationBarItem({
|
||||
children,
|
||||
active = false,
|
||||
onClick,
|
||||
onPointerDown,
|
||||
onPointerUp
|
||||
onClick
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
active?: boolean
|
||||
onClick?: MouseEventHandler
|
||||
onPointerDown?: MouseEventHandler
|
||||
onPointerUp?: MouseEventHandler
|
||||
onClick: MouseEventHandler
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
@@ -23,8 +19,6 @@ export default function BottomNavigationBarItem({
|
||||
)}
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
|
||||
@@ -3,13 +3,10 @@ import { Compass } from 'lucide-react'
|
||||
import BottomNavigationBarItem from './BottomNavigationBarItem'
|
||||
|
||||
export default function ExploreButton() {
|
||||
const { navigate, current, display } = usePrimaryPage()
|
||||
const { navigate, current } = usePrimaryPage()
|
||||
|
||||
return (
|
||||
<BottomNavigationBarItem
|
||||
active={current === 'explore' && display}
|
||||
onClick={() => navigate('explore')}
|
||||
>
|
||||
<BottomNavigationBarItem active={current === 'explore'} onClick={() => navigate('explore')}>
|
||||
<Compass />
|
||||
</BottomNavigationBarItem>
|
||||
)
|
||||
|
||||
@@ -3,13 +3,10 @@ import { Home } from 'lucide-react'
|
||||
import BottomNavigationBarItem from './BottomNavigationBarItem'
|
||||
|
||||
export default function HomeButton() {
|
||||
const { navigate, current, display } = usePrimaryPage()
|
||||
const { navigate, current } = usePrimaryPage()
|
||||
|
||||
return (
|
||||
<BottomNavigationBarItem
|
||||
active={current === 'home' && display}
|
||||
onClick={() => navigate('home')}
|
||||
>
|
||||
<BottomNavigationBarItem active={current === 'home'} onClick={() => navigate('home')}>
|
||||
<Home />
|
||||
</BottomNavigationBarItem>
|
||||
)
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
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 { navigate, current } = usePrimaryPage()
|
||||
const { hasNewNotification } = useNotification()
|
||||
|
||||
return (
|
||||
<BottomNavigationBarItem
|
||||
active={current === 'notifications' && display}
|
||||
onClick={() => checkLogin(() => navigate('notifications'))}
|
||||
active={current === 'notifications'}
|
||||
onClick={() => 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 className="absolute -top-0.5 right-0.5 w-2 h-2 bg-primary rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</BottomNavigationBarItem>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import BackgroundAudio from '../BackgroundAudio'
|
||||
import AccountButton from './AccountButton'
|
||||
import ExploreButton from './ExploreButton'
|
||||
import HomeButton from './HomeButton'
|
||||
@@ -8,18 +7,18 @@ import NotificationsButton from './NotificationsButton'
|
||||
export default function BottomNavigationBar() {
|
||||
return (
|
||||
<div
|
||||
className={cn('fixed bottom-0 w-full z-40 bg-background border-t')}
|
||||
className={cn(
|
||||
'fixed bottom-0 w-full z-40 bg-background/80 backdrop-blur-xl flex items-center justify-around [&_svg]:size-4 [&_svg]:shrink-0'
|
||||
)}
|
||||
style={{
|
||||
height: 'calc(3rem + env(safe-area-inset-bottom))',
|
||||
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>
|
||||
<HomeButton />
|
||||
<ExploreButton />
|
||||
<NotificationsButton />
|
||||
<AccountButton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,175 +1,146 @@
|
||||
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 { URL_REGEX } from '@/constants'
|
||||
import { isNsfwEvent, isPictureEvent } from '@/lib/event'
|
||||
import { extractImageInfoFromTag } from '@/lib/tag'
|
||||
import { isImage, isVideo } from '@/lib/url'
|
||||
import { cn } from '@/lib/utils'
|
||||
import mediaUpload from '@/services/media-upload.service'
|
||||
import { TImetaInfo } from '@/types'
|
||||
import { TImageInfo } from '@/types'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
EmbeddedHashtag,
|
||||
EmbeddedLNInvoice,
|
||||
EmbeddedMention,
|
||||
embedded,
|
||||
embeddedHashtagRenderer,
|
||||
embeddedNormalUrlRenderer,
|
||||
embeddedNostrNpubRenderer,
|
||||
embeddedNostrProfileRenderer,
|
||||
EmbeddedNote,
|
||||
EmbeddedWebsocketUrl
|
||||
embeddedWebsocketUrlRenderer
|
||||
} from '../Embedded'
|
||||
import Emoji from '../Emoji'
|
||||
import ExternalLink from '../ExternalLink'
|
||||
import ImageGallery from '../ImageGallery'
|
||||
import MediaPlayer from '../MediaPlayer'
|
||||
import VideoPlayer from '../VideoPlayer'
|
||||
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 Content = memo(
|
||||
({
|
||||
event,
|
||||
className,
|
||||
size = 'normal',
|
||||
disableLightbox = false
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
size?: 'normal' | 'small'
|
||||
disableLightbox?: boolean
|
||||
}) => {
|
||||
const { content, images, videos, embeddedNotes, lastNonMediaUrl } = preprocess(event)
|
||||
const isNsfw = isNsfwEvent(event)
|
||||
const nodes = embedded(content, [
|
||||
embeddedNormalUrlRenderer,
|
||||
embeddedWebsocketUrlRenderer,
|
||||
embeddedHashtagRenderer,
|
||||
embeddedNostrNpubRenderer,
|
||||
embeddedNostrProfileRenderer
|
||||
])
|
||||
|
||||
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
|
||||
// Add images
|
||||
if (images.length) {
|
||||
nodes.push(
|
||||
<ImageGallery
|
||||
className={`${size === 'small' ? 'mt-1' : 'mt-2'}`}
|
||||
key={`image-gallery-${event.id}`}
|
||||
images={images}
|
||||
isNsfw={isNsfw}
|
||||
size={size}
|
||||
disableLightbox={disableLightbox}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Add videos
|
||||
if (videos.length) {
|
||||
videos.forEach((src, index) => {
|
||||
nodes.push(
|
||||
<VideoPlayer
|
||||
className={size === 'small' ? 'mt-1' : 'mt-2'}
|
||||
key={`video-${index}-${src}`}
|
||||
src={src}
|
||||
isNsfw={isNsfw}
|
||||
size={size}
|
||||
/>
|
||||
)
|
||||
})
|
||||
.filter(Boolean)
|
||||
.flat() as TImetaInfo[]
|
||||
}
|
||||
|
||||
const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
|
||||
// Add website preview
|
||||
if (lastNonMediaUrl) {
|
||||
nodes.push(
|
||||
<WebPreview
|
||||
className={size === 'small' ? 'mt-1' : 'mt-2'}
|
||||
key={`web-preview-${event.id}`}
|
||||
url={lastNonMediaUrl}
|
||||
size={size}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
|
||||
const lastNormalUrl =
|
||||
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
|
||||
// Add embedded notes
|
||||
if (embeddedNotes.length) {
|
||||
embeddedNotes.forEach((note, index) => {
|
||||
const id = note.split(':')[1]
|
||||
nodes.push(
|
||||
<EmbeddedNote
|
||||
key={`embedded-event-${index}`}
|
||||
noteId={id}
|
||||
className={size === 'small' ? 'mt-1' : 'mt-2'}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return { nodes, allImages, emojiInfos, lastNormalUrl }
|
||||
}, [event, translatedEvent, content])
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return null
|
||||
return <div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>{nodes}</div>
|
||||
}
|
||||
)
|
||||
Content.displayName = 'Content'
|
||||
export default Content
|
||||
|
||||
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>
|
||||
)
|
||||
function preprocess(event: Event) {
|
||||
const content = event.content
|
||||
const urls = content.match(URL_REGEX) || []
|
||||
let lastNonMediaUrl: string | undefined
|
||||
|
||||
let c = content
|
||||
const imageUrls: string[] = []
|
||||
const videos: string[] = []
|
||||
|
||||
urls.forEach((url) => {
|
||||
if (isImage(url)) {
|
||||
c = c.replace(url, '').trim()
|
||||
imageUrls.push(url)
|
||||
} else if (isVideo(url)) {
|
||||
c = c.replace(url, '').trim()
|
||||
videos.push(url)
|
||||
} else {
|
||||
lastNonMediaUrl = url
|
||||
}
|
||||
})
|
||||
|
||||
const imageInfos = event.tags
|
||||
.map((tag) => extractImageInfoFromTag(tag))
|
||||
.filter(Boolean) as TImageInfo[]
|
||||
const images = isPictureEvent(event)
|
||||
? imageInfos
|
||||
: imageUrls.map((url) => {
|
||||
const imageInfo = imageInfos.find((info) => info.url === url)
|
||||
return imageInfo ?? { url }
|
||||
})
|
||||
|
||||
const embeddedNotes: string[] = []
|
||||
const embeddedNoteRegex = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
|
||||
;(c.match(embeddedNoteRegex) || []).forEach((note) => {
|
||||
c = c.replace(note, '').trim()
|
||||
embeddedNotes.push(note)
|
||||
})
|
||||
|
||||
c = c.replace(/\n{3,}/g, '\n\n').trim()
|
||||
|
||||
return { content: c, images, videos, embeddedNotes, lastNonMediaUrl }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,13 @@
|
||||
import { ExtendedKind } from '@/constants'
|
||||
import { isMentioningMutedUsers } from '@/lib/event'
|
||||
import { extractEmbeddedNotesFromContent, extractImagesFromContent } 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 { Event } 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'
|
||||
import {
|
||||
embedded,
|
||||
embeddedNostrNpubTextRenderer,
|
||||
embeddedNostrProfileTextRenderer
|
||||
} from '../Embedded'
|
||||
|
||||
export default function ContentPreview({
|
||||
event,
|
||||
@@ -26,89 +17,24 @@ export default function ContentPreview({
|
||||
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>
|
||||
const content = useMemo(() => {
|
||||
if (!event) return `[${t('Not found the note')}]`
|
||||
const { contentWithoutEmbeddedNotes, embeddedNotes } = extractEmbeddedNotesFromContent(
|
||||
event.content
|
||||
)
|
||||
}
|
||||
const { contentWithoutImages, images } = extractImagesFromContent(contentWithoutEmbeddedNotes)
|
||||
const contents = [contentWithoutImages]
|
||||
if (images?.length) {
|
||||
contents.push(`[${t('image')}]`)
|
||||
}
|
||||
if (embeddedNotes.length) {
|
||||
contents.push(`[${t('note')}]`)
|
||||
}
|
||||
return embedded(contents.join(' '), [
|
||||
embeddedNostrProfileTextRenderer,
|
||||
embeddedNostrNpubTextRenderer
|
||||
])
|
||||
}, [event])
|
||||
|
||||
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>
|
||||
return <div className={cn('pointer-events-none', className)}>{content}</div>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
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'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function RecentSupporters() {
|
||||
const { t } = useTranslation()
|
||||
@@ -32,9 +32,7 @@ export default function RecentSupporters() {
|
||||
<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 className="text-xs text-muted-foreground line-clamp-3">{item.comment}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-semibold text-yellow-400 shrink-0">
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { JUMBLE_PUBKEY } from '@/constants'
|
||||
import { CODY_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 }) {
|
||||
@@ -40,12 +39,11 @@ export default function Donation({ className }: { className?: string }) {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<PlatinumSponsors />
|
||||
<RecentSupporters />
|
||||
<ZapDialog
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
pubkey={JUMBLE_PUBKEY}
|
||||
pubkey={CODY_PUBKEY}
|
||||
defaultAmount={donationAmount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import { toNoteList } from '@/lib/link'
|
||||
import { SecondaryPageLink } from '@/PageManager'
|
||||
import { TEmbeddedRenderer } from './types'
|
||||
|
||||
export function EmbeddedHashtag({ hashtag }: { hashtag: string }) {
|
||||
return (
|
||||
<SecondaryPageLink
|
||||
className="text-primary hover:underline"
|
||||
to={toNoteList({ hashtag: hashtag.replace('#', '') })}
|
||||
className="text-highlight hover:underline"
|
||||
to={toNoteList({ hashtag })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{hashtag}
|
||||
#{hashtag}
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
|
||||
export const embeddedHashtagRenderer: TEmbeddedRenderer = {
|
||||
regex: /#([\p{L}\p{N}\p{M}]+)/gu,
|
||||
render: (hashtag: string, index: number) => {
|
||||
return <EmbeddedHashtag key={`hashtag-${index}-${hashtag}`} hashtag={hashtag} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,61 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import Username, { SimpleUsername } from '../Username'
|
||||
import { TEmbeddedRenderer } from './types'
|
||||
|
||||
export function EmbeddedMention({ userId, className }: { userId: string; className?: string }) {
|
||||
export function EmbeddedMention({ userId }: { userId: string }) {
|
||||
return (
|
||||
<Username
|
||||
userId={userId}
|
||||
showAt
|
||||
className={cn('text-primary font-normal inline', className)}
|
||||
className="text-highlight font-normal inline"
|
||||
withoutSkeleton
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmbeddedMentionText({ userId, className }: { userId: string; className?: string }) {
|
||||
return (
|
||||
<SimpleUsername userId={userId} showAt className={cn('inline', className)} withoutSkeleton />
|
||||
)
|
||||
export function EmbeddedMentionText({ userId }: { userId: string }) {
|
||||
return <SimpleUsername userId={userId} showAt className="inline truncate" withoutSkeleton />
|
||||
}
|
||||
|
||||
export const embeddedNostrNpubRenderer: TEmbeddedRenderer = {
|
||||
regex: /(nostr:npub1[a-z0-9]{58})/g,
|
||||
render: (id: string, index: number) => {
|
||||
const npub1 = id.split(':')[1]
|
||||
return <EmbeddedMention key={`embedded-nostr-npub-${index}-${npub1}`} userId={npub1} />
|
||||
}
|
||||
}
|
||||
|
||||
export const embeddedNostrProfileRenderer: TEmbeddedRenderer = {
|
||||
regex: /(nostr:nprofile1[a-z0-9]+)/g,
|
||||
render: (id: string, index: number) => {
|
||||
const nprofile = id.split(':')[1]
|
||||
return <EmbeddedMention key={`embedded-nostr-profile-${index}-${nprofile}`} userId={nprofile} />
|
||||
}
|
||||
}
|
||||
|
||||
export const embeddedNpubRenderer: TEmbeddedRenderer = {
|
||||
regex: /(npub1[a-z0-9]{58})/g,
|
||||
render: (npub1: string, index: number) => {
|
||||
return <EmbeddedMention key={`embedded-npub-${index}-${npub1}`} userId={npub1} />
|
||||
}
|
||||
}
|
||||
|
||||
export const embeddedNostrNpubTextRenderer: TEmbeddedRenderer = {
|
||||
regex: /(nostr:npub1[a-z0-9]{58})/g,
|
||||
render: (id: string, index: number) => {
|
||||
const npub1 = id.split(':')[1]
|
||||
return <EmbeddedMentionText key={`embedded-nostr-npub-text-${index}-${npub1}`} userId={npub1} />
|
||||
}
|
||||
}
|
||||
|
||||
export const embeddedNostrProfileTextRenderer: TEmbeddedRenderer = {
|
||||
regex: /(nostr:nprofile1[a-z0-9]+)/g,
|
||||
render: (id: string, index: number) => {
|
||||
const nprofile = id.split(':')[1]
|
||||
return (
|
||||
<EmbeddedMentionText
|
||||
key={`embedded-nostr-profile-text-${index}-${nprofile}`}
|
||||
userId={nprofile}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
22
src/components/Embedded/EmbeddedNormalUrl.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { TEmbeddedRenderer } from './types'
|
||||
|
||||
export function EmbeddedNormalUrl({ url }: { url: string }) {
|
||||
return (
|
||||
<a
|
||||
className="text-highlight hover:underline"
|
||||
href={url}
|
||||
target="_blank"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export const embeddedNormalUrlRenderer: TEmbeddedRenderer = {
|
||||
regex: /(https?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_,:!~*]+)/gu,
|
||||
render: (url: string, index: number) => {
|
||||
return <EmbeddedNormalUrl key={`normal-url-${index}-${url}`} url={url} />
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useFetchEvent } from '@/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ClientSelect from '../ClientSelect'
|
||||
import MainNoteCard from '../NoteCard/MainNoteCard'
|
||||
import GenericNoteCard from '../NoteCard/GenericNoteCard'
|
||||
|
||||
export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) {
|
||||
const { event, isFetching } = useFetchEvent(noteId)
|
||||
@@ -17,7 +19,7 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className?
|
||||
}
|
||||
|
||||
return (
|
||||
<MainNoteCard
|
||||
<GenericNoteCard
|
||||
className={cn('w-full', className)}
|
||||
event={event}
|
||||
embedded
|
||||
@@ -29,15 +31,12 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className?
|
||||
function EmbeddedNoteSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn('text-left p-2 sm:p-3 border rounded-xl bg-card', className)}
|
||||
className={cn('text-left p-2 sm:p-3 border rounded-lg', 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>
|
||||
<Skeleton className="w-7 h-7 rounded-full" />
|
||||
<Skeleton className="h-3 w-16 my-1" />
|
||||
</div>
|
||||
<Skeleton className="w-full h-4 my-1 mt-2" />
|
||||
<Skeleton className="w-2/3 h-4 my-1" />
|
||||
@@ -47,12 +46,23 @@ function EmbeddedNoteSkeleton({ className }: { className?: string }) {
|
||||
|
||||
function EmbeddedNoteNotFound({ noteId, className }: { noteId: string; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
return (
|
||||
<div className={cn('text-left p-2 sm:p-3 border rounded-xl bg-card', className)}>
|
||||
<div className={cn('text-left p-2 sm:p-3 border rounded-lg', 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} />
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(noteId)
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 2000)
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
{isCopied ? <Check /> : <Copy />} {t('Copy event ID')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { toRelay } from '@/lib/link'
|
||||
import { TEmbeddedRenderer } from './types'
|
||||
|
||||
export function EmbeddedWebsocketUrl({ url }: { url: string }) {
|
||||
const { push } = useSecondaryPage()
|
||||
return (
|
||||
<span
|
||||
className="cursor-pointer px-1 text-primary hover:bg-primary/20"
|
||||
className="cursor-pointer px-1 text-highlight hover:bg-highlight/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
push(toRelay(url))
|
||||
}}
|
||||
>
|
||||
[ {url} ]
|
||||
<span className="w-2 h-1 bg-primary" />
|
||||
<span className="w-2 h-1 bg-highlight" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const embeddedWebsocketUrlRenderer: TEmbeddedRenderer = {
|
||||
regex: /(wss?:\/\/[\w\p{L}\p{N}\p{M}&.-/?=#\-@%+_,:!~*]+)/gu,
|
||||
render: (url: string, index: number) => {
|
||||
return <EmbeddedWebsocketUrl key={`websocket-url-${index}-${url}`} url={url} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
export * from './EmbeddedHashtag'
|
||||
export * from './EmbeddedLNInvoice'
|
||||
export * from './EmbeddedMention'
|
||||
export * from './EmbeddedNormalUrl'
|
||||
export * from './EmbeddedNote'
|
||||
export * from './EmbeddedWebsocketUrl'
|
||||
|
||||
import reactStringReplace from 'react-string-replace'
|
||||
import { TEmbeddedRenderer } from './types'
|
||||
|
||||
export function embedded(content: string, renderers: TEmbeddedRenderer[]) {
|
||||
let nodes: React.ReactNode[] = [content]
|
||||
|
||||
renderers.forEach((renderer) => {
|
||||
nodes = reactStringReplace(nodes, renderer.regex, renderer.render)
|
||||
})
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
4
src/components/Embedded/types.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
export type TEmbeddedRenderer = {
|
||||
regex: RegExp
|
||||
render: (match: string, index: number) => JSX.Element
|
||||
}
|
||||
@@ -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)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
}
|
||||
@@ -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()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { TEmoji } from '@/types'
|
||||
import { useState } from 'react'
|
||||
import EmojiPicker from '../EmojiPicker'
|
||||
|
||||
export default function EmojiPickerDialog({
|
||||
children,
|
||||
onEmojiClick
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onEmojiClick?: (emoji: string | TEmoji | undefined) => void
|
||||
}) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{children}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<EmojiPicker
|
||||
onEmojiClick={(emoji, e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
onEmojiClick?.(emoji)
|
||||
}}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" className="p-0 w-fit">
|
||||
<EmojiPicker
|
||||
onEmojiClick={(emoji, e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
onEmojiClick?.(emoji)
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RotateCw } from 'lucide-react'
|
||||
import React, { Component, ReactNode } from 'react'
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean
|
||||
error?: Error
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="w-screen h-screen flex flex-col items-center justify-center p-4 gap-4">
|
||||
<h1 className="text-2xl font-bold">Oops, something went wrong.</h1>
|
||||
<p className="text-lg text-center max-w-md">
|
||||
Sorry for the inconvenience. If you don't mind helping, you can{' '}
|
||||
<a
|
||||
href="https://github.com/CodyTseng/jumble/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
submit an issue on GitHub
|
||||
</a>{' '}
|
||||
with the error details, or{' '}
|
||||
<a
|
||||
href="https://jumble.social/npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
mention me
|
||||
</a>
|
||||
. Thank you for your support!
|
||||
</p>
|
||||
{this.state.error?.message && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(this.state.error!.message)
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
Copy Error Message
|
||||
</Button>
|
||||
<pre className="bg-destructive/10 text-destructive p-2 rounded text-wrap break-words whitespace-pre-wrap">
|
||||
Error: {this.state.error.message}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={() => window.location.reload()} className="mt-2">
|
||||
<RotateCw />
|
||||
Reload Page
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useFetchRelayInfo } from '@/hooks'
|
||||
import { toRelay } from '@/lib/link'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import relayInfoService from '@/services/relay-info.service'
|
||||
import { TAwesomeRelayCollection } from '@/types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo'
|
||||
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function Explore() {
|
||||
const [collections, setCollections] = useState<TAwesomeRelayCollection[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
relayInfoService.getAwesomeRelayCollections().then(setCollections)
|
||||
}, [])
|
||||
|
||||
if (!collections) {
|
||||
return (
|
||||
<div>
|
||||
<div className="p-4 max-md:border-b">
|
||||
<Skeleton className="h-6 w-20" />
|
||||
</div>
|
||||
<div className="grid md:px-4 md:grid-cols-2 md:gap-2">
|
||||
<RelaySimpleInfoSkeleton className="h-auto px-4 py-3 md:rounded-lg md:border" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{collections.map((collection) => (
|
||||
<RelayCollection key={collection.id} collection={collection} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayCollection({ collection }: { collection: TAwesomeRelayCollection }) {
|
||||
const { deepBrowsing } = useDeepBrowsing()
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
'sticky bg-background z-20 px-4 py-3 text-2xl font-semibold max-md:border-b',
|
||||
deepBrowsing ? 'top-12' : 'top-24'
|
||||
)}
|
||||
>
|
||||
{collection.name}
|
||||
</div>
|
||||
<div className="grid md:px-4 md:grid-cols-2 md:gap-3">
|
||||
{collection.relays.map((url) => (
|
||||
<RelayItem key={url} url={url} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayItem({ url }: { url: string }) {
|
||||
const { push } = useSecondaryPage()
|
||||
const { relayInfo, isFetching } = useFetchRelayInfo(url)
|
||||
|
||||
if (isFetching) {
|
||||
return <RelaySimpleInfoSkeleton className="h-auto px-4 py-3 border-b md:rounded-lg md:border" />
|
||||
}
|
||||
|
||||
if (!relayInfo) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<RelaySimpleInfo
|
||||
key={relayInfo.url}
|
||||
className="clickable h-auto px-4 py-3 border-b md:rounded-lg md:border"
|
||||
relayInfo={relayInfo}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
push(toRelay(relayInfo.url))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import {
|
||||
EmbeddedHashtagParser,
|
||||
EmbeddedUrlParser,
|
||||
EmbeddedWebsocketUrlParser,
|
||||
parseContent
|
||||
} from '@/lib/content-parser'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useMemo } from 'react'
|
||||
import { EmbeddedHashtag, EmbeddedLNInvoice, EmbeddedWebsocketUrl } from '../Embedded'
|
||||
import ImageGallery from '../ImageGallery'
|
||||
import MediaPlayer from '../MediaPlayer'
|
||||
import WebPreview from '../WebPreview'
|
||||
import XEmbeddedPost from '../XEmbeddedPost'
|
||||
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
|
||||
|
||||
export default function ExternalContent({
|
||||
content,
|
||||
className,
|
||||
mustLoadMedia
|
||||
}: {
|
||||
content?: string
|
||||
className?: string
|
||||
mustLoadMedia?: boolean
|
||||
}) {
|
||||
const nodes = useMemo(() => {
|
||||
if (!content) return []
|
||||
|
||||
return parseContent(content, [
|
||||
EmbeddedUrlParser,
|
||||
EmbeddedWebsocketUrlParser,
|
||||
EmbeddedHashtagParser
|
||||
])
|
||||
}, [content])
|
||||
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const node = nodes[0]
|
||||
|
||||
if (node.type === 'text') {
|
||||
return (
|
||||
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>{content}</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'url') {
|
||||
return <WebPreview url={node.data} className={className} mustLoad={mustLoadMedia} />
|
||||
}
|
||||
|
||||
if (node.type === 'x-post') {
|
||||
return (
|
||||
<XEmbeddedPost
|
||||
url={node.data}
|
||||
className={className}
|
||||
mustLoad={mustLoadMedia}
|
||||
embedded={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'youtube') {
|
||||
return <YoutubeEmbeddedPlayer url={node.data} className={className} mustLoad={mustLoadMedia} />
|
||||
}
|
||||
|
||||
if (node.type === 'image' || node.type === 'images') {
|
||||
const data = Array.isArray(node.data) ? node.data : [node.data]
|
||||
return (
|
||||
<ImageGallery
|
||||
className={className}
|
||||
images={data.map((url) => ({ url }))}
|
||||
mustLoad={mustLoadMedia}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'media') {
|
||||
return <MediaPlayer className={className} src={node.data} mustLoad={mustLoadMedia} />
|
||||
}
|
||||
|
||||
if (node.type === 'invoice') {
|
||||
return <EmbeddedLNInvoice invoice={node.data} className={className} />
|
||||
}
|
||||
|
||||
if (node.type === 'websocket-url') {
|
||||
return <EmbeddedWebsocketUrl url={node.data} />
|
||||
}
|
||||
|
||||
if (node.type === 'hashtag') {
|
||||
return <EmbeddedHashtag hashtag={node.data} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
|
||||
export type TTabValue = 'replies' | 'reactions' | 'quotes'
|
||||
const TABS = [
|
||||
{ value: 'replies', label: 'Replies' },
|
||||
{ value: 'reactions', label: 'Reactions' },
|
||||
{ value: 'quotes', label: 'Quotes' }
|
||||
] as { value: TTabValue; label: string }[]
|
||||
|
||||
export function Tabs({
|
||||
selectedTab,
|
||||
onTabChange
|
||||
}: {
|
||||
selectedTab: TTabValue
|
||||
onTabChange: (tab: TTabValue) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const tabRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
const activeIndex = TABS.findIndex((tab) => tab.value === selectedTab)
|
||||
if (activeIndex >= 0 && tabRefs.current[activeIndex]) {
|
||||
const activeTab = tabRefs.current[activeIndex]
|
||||
const { offsetWidth, offsetLeft } = activeTab
|
||||
const padding = 32 // 16px padding on each side
|
||||
setIndicatorStyle({
|
||||
width: offsetWidth - padding,
|
||||
left: offsetLeft + padding / 2
|
||||
})
|
||||
}
|
||||
}, 20) // ensure tabs are rendered before calculating
|
||||
}, [selectedTab])
|
||||
|
||||
return (
|
||||
<div className="w-fit">
|
||||
<div className="flex relative">
|
||||
{TABS.map((tab, index) => (
|
||||
<div
|
||||
key={tab.value}
|
||||
ref={(el) => (tabRefs.current[index] = el)}
|
||||
className={cn(
|
||||
`text-center px-4 py-2 font-semibold clickable cursor-pointer rounded-lg`,
|
||||
selectedTab === tab.value ? '' : 'text-muted-foreground'
|
||||
)}
|
||||
onClick={() => onTabChange(tab.value)}
|
||||
>
|
||||
{t(tab.label)}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="absolute bottom-0 h-1 bg-primary rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${indicatorStyle.width}px`,
|
||||
left: `${indicatorStyle.left}px`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useState } from 'react'
|
||||
import HideUntrustedContentButton from '../HideUntrustedContentButton'
|
||||
import QuoteList from '../QuoteList'
|
||||
import ReactionList from '../ReactionList'
|
||||
import ReplyNoteList from '../ReplyNoteList'
|
||||
import { Tabs, TTabValue } from './Tabs'
|
||||
|
||||
export default function ExternalContentInteractions({
|
||||
pageIndex,
|
||||
externalContent
|
||||
}: {
|
||||
pageIndex?: number
|
||||
externalContent: string
|
||||
}) {
|
||||
const [type, setType] = useState<TTabValue>('replies')
|
||||
let list
|
||||
switch (type) {
|
||||
case 'replies':
|
||||
list = <ReplyNoteList index={pageIndex} stuff={externalContent} />
|
||||
break
|
||||
case 'reactions':
|
||||
list = <ReactionList stuff={externalContent} />
|
||||
break
|
||||
case 'quotes':
|
||||
list = <QuoteList stuff={externalContent} />
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<ScrollArea className="flex-1 w-0">
|
||||
<Tabs selectedTab={type} onTabChange={setType} />
|
||||
<ScrollBar orientation="horizontal" className="opacity-0 pointer-events-none" />
|
||||
</ScrollArea>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<div className="size-10 flex items-center justify-center">
|
||||
<HideUntrustedContentButton type="interactions" />
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
{list}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { toExternalContent } from '@/lib/link'
|
||||
import { truncateUrl } from '@/lib/url'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { ExternalLink as ExternalLinkIcon, MessageSquare } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ExternalLink({
|
||||
url,
|
||||
className,
|
||||
justOpenLink
|
||||
}: {
|
||||
url: string
|
||||
className?: string
|
||||
justOpenLink?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { push } = useSecondaryPage()
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
const displayUrl = useMemo(() => truncateUrl(url), [url])
|
||||
|
||||
const handleOpenLink = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isSmallScreen) {
|
||||
setIsDrawerOpen(false)
|
||||
}
|
||||
window.open(url, '_blank', 'noreferrer')
|
||||
}
|
||||
|
||||
const handleViewDiscussions = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isSmallScreen) {
|
||||
setIsDrawerOpen(false)
|
||||
setTimeout(() => push(toExternalContent(url)), 100) // wait for drawer to close
|
||||
return
|
||||
}
|
||||
push(toExternalContent(url))
|
||||
}
|
||||
|
||||
if (justOpenLink) {
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn('cursor-pointer text-primary hover:underline', className)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{displayUrl}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const trigger = (
|
||||
<span
|
||||
className={cn('cursor-pointer text-primary hover:underline', className)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (isSmallScreen) {
|
||||
setIsDrawerOpen(true)
|
||||
}
|
||||
}}
|
||||
title={url}
|
||||
>
|
||||
{displayUrl}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<DrawerOverlay
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsDrawerOpen(false)
|
||||
}}
|
||||
/>
|
||||
<DrawerContent hideOverlay>
|
||||
<div className="py-2">
|
||||
<Button
|
||||
onClick={handleOpenLink}
|
||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
|
||||
variant="ghost"
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
{t('Open link')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleViewDiscussions}
|
||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
|
||||
variant="ghost"
|
||||
>
|
||||
<MessageSquare />
|
||||
{t('View Nostr discussions')}
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<span className={cn('cursor-pointer text-primary hover:underline', className)} title={url}>
|
||||
{displayUrl}
|
||||
</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem onClick={handleOpenLink}>
|
||||
<ExternalLinkIcon />
|
||||
{t('Open link')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleViewDiscussions}>
|
||||
<MessageSquare />
|
||||
{t('View Nostr discussions')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { faviconUrl } from '@/lib/faviconUrl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function Favicon({
|
||||
domain,
|
||||
className,
|
||||
fallback = null
|
||||
}: {
|
||||
domain: string
|
||||
className?: string
|
||||
fallback?: React.ReactNode
|
||||
}) {
|
||||
const { faviconUrlTemplate } = useContentPolicy()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
if (error) return fallback
|
||||
|
||||
const url = faviconUrl(faviconUrlTemplate, `https://${domain}`)
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
{loading && <div className={cn('absolute inset-0', className)}>{fallback}</div>}
|
||||
<img
|
||||
src={url}
|
||||
alt={domain}
|
||||
className={cn('absolute inset-0', loading && 'opacity-0', className)}
|
||||
onError={() => setError(true)}
|
||||
onLoad={() => setLoading(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { normalizeUrl } from '@/lib/url'
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function AddNewRelay() {
|
||||
const { t } = useTranslation()
|
||||
const { favoriteRelays, addFavoriteRelays } = useFavoriteRelays()
|
||||
const [input, setInput] = useState('')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
const saveRelay = async () => {
|
||||
if (!input) return
|
||||
const normalizedUrl = normalizeUrl(input)
|
||||
if (!normalizedUrl) {
|
||||
setErrorMsg(t('Invalid URL'))
|
||||
return
|
||||
}
|
||||
if (favoriteRelays.includes(normalizedUrl)) {
|
||||
setErrorMsg(t('Already saved'))
|
||||
return
|
||||
}
|
||||
await addFavoriteRelays([normalizedUrl])
|
||||
setInput('')
|
||||
}
|
||||
|
||||
const handleNewRelayInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInput(e.target.value)
|
||||
setErrorMsg('')
|
||||
}
|
||||
|
||||
const handleNewRelayInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
saveRelay()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder={t('Add a new relay')}
|
||||
value={input}
|
||||
onChange={handleNewRelayInputChange}
|
||||
onKeyDown={handleNewRelayInputKeyDown}
|
||||
className={errorMsg ? 'border-destructive' : ''}
|
||||
/>
|
||||
<Button onClick={saveRelay}>{t('Add')}</Button>
|
||||
</div>
|
||||
{errorMsg && <div className="text-destructive text-sm pl-8">{errorMsg}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function AddNewRelaySet() {
|
||||
const { t } = useTranslation()
|
||||
const { createRelaySet } = useFavoriteRelays()
|
||||
const [newRelaySetName, setNewRelaySetName] = useState('')
|
||||
|
||||
const saveRelaySet = () => {
|
||||
if (!newRelaySetName) return
|
||||
createRelaySet(newRelaySetName)
|
||||
setNewRelaySetName('')
|
||||
}
|
||||
|
||||
const handleNewRelaySetNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewRelaySetName(e.target.value)
|
||||
}
|
||||
|
||||
const handleNewRelaySetNameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
saveRelaySet()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder={t('Add a new relay set')}
|
||||
value={newRelaySetName}
|
||||
onChange={handleNewRelaySetNameChange}
|
||||
onKeyDown={handleNewRelaySetNameKeyDown}
|
||||
/>
|
||||
<Button onClick={saveRelaySet}>{t('Add')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core'
|
||||
import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy
|
||||
} from '@dnd-kit/sortable'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RelayItem from './RelayItem'
|
||||
|
||||
export default function FavoriteRelayList() {
|
||||
const { t } = useTranslation()
|
||||
const { favoriteRelays, reorderFavoriteRelays } = useFavoriteRelays()
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates
|
||||
})
|
||||
)
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = favoriteRelays.findIndex((relay) => relay === active.id)
|
||||
const newIndex = favoriteRelays.findIndex((relay) => relay === over.id)
|
||||
|
||||
const reorderedRelays = arrayMove(favoriteRelays, oldIndex, newIndex)
|
||||
reorderFavoriteRelays(reorderedRelays)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground font-semibold select-none">{t('Relays')}</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
>
|
||||
<SortableContext items={favoriteRelays} strategy={verticalListSortingStrategy}>
|
||||
<div className="grid gap-2">
|
||||
{favoriteRelays.map((relay) => (
|
||||
<RelayItem key={relay} relay={relay} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { toRelay } from '@/lib/link'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { GripVertical } from 'lucide-react'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
|
||||
|
||||
export default function RelayItem({ relay }: { relay: string }) {
|
||||
const { push } = useSecondaryPage()
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: relay
|
||||
})
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative group clickable flex gap-2 border rounded-lg p-2 pr-2.5 items-center justify-between select-none"
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
onClick={() => push(toRelay(relay))}
|
||||
>
|
||||
<div className="flex items-center gap-1 flex-1">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing p-2 hover:bg-muted rounded touch-none shrink-0"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex gap-2 items-center flex-1">
|
||||
<RelayIcon url={relay} />
|
||||
<div className="flex-1 w-0 truncate font-semibold">{relay}</div>
|
||||
</div>
|
||||
</div>
|
||||
<SaveRelayDropdownMenu urls={[relay]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { TRelaySet } from '@/types'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Edit,
|
||||
EllipsisVertical,
|
||||
FolderClosed,
|
||||
GripVertical,
|
||||
Link,
|
||||
Trash2
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DrawerMenuItem from '../DrawerMenuItem'
|
||||
import RelayUrls from './RelayUrl'
|
||||
import { useRelaySetsSettingComponent } from './provider'
|
||||
|
||||
export default function RelaySet({ relaySet }: { relaySet: TRelaySet }) {
|
||||
const { t } = useTranslation()
|
||||
const { expandedRelaySetId } = useRelaySetsSettingComponent()
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: relaySet.id
|
||||
})
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="relative group">
|
||||
<div className="w-full border rounded-lg px-2 py-2.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing p-2 hover:bg-muted rounded touch-none"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex justify-center items-center w-6 h-6 shrink-0">
|
||||
<FolderClosed className="size-4" />
|
||||
</div>
|
||||
<RelaySetName relaySet={relaySet} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<RelayUrlsExpandToggle relaySetId={relaySet.id}>
|
||||
{t('n relays', { n: relaySet.relayUrls.length })}
|
||||
</RelayUrlsExpandToggle>
|
||||
<RelaySetOptions relaySet={relaySet} />
|
||||
</div>
|
||||
</div>
|
||||
{expandedRelaySetId === relaySet.id && <RelayUrls relaySetId={relaySet.id} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelaySetName({ relaySet }: { relaySet: TRelaySet }) {
|
||||
const [newSetName, setNewSetName] = useState(relaySet.name)
|
||||
const { updateRelaySet } = useFavoriteRelays()
|
||||
const { renamingRelaySetId, setRenamingRelaySetId } = useRelaySetsSettingComponent()
|
||||
|
||||
const saveNewRelaySetName = () => {
|
||||
if (relaySet.name === newSetName) {
|
||||
return setRenamingRelaySetId(null)
|
||||
}
|
||||
updateRelaySet({ ...relaySet, name: newSetName })
|
||||
setRenamingRelaySetId(null)
|
||||
}
|
||||
|
||||
const handleRenameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewSetName(e.target.value)
|
||||
}
|
||||
|
||||
const handleRenameInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
saveNewRelaySetName()
|
||||
}
|
||||
}
|
||||
|
||||
return renamingRelaySetId === relaySet.id ? (
|
||||
<div className="flex gap-1 items-center">
|
||||
<Input
|
||||
value={newSetName}
|
||||
onChange={handleRenameInputChange}
|
||||
onBlur={saveNewRelaySetName}
|
||||
onKeyDown={handleRenameInputKeyDown}
|
||||
className="font-semibold w-28"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={saveNewRelaySetName}>
|
||||
<Check size={18} className="text-green-500" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-8 font-semibold flex items-center select-none">{relaySet.name}</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayUrlsExpandToggle({
|
||||
relaySetId,
|
||||
children
|
||||
}: {
|
||||
relaySetId: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { expandedRelaySetId, setExpandedRelaySetId } = useRelaySetsSettingComponent()
|
||||
return (
|
||||
<div
|
||||
className="text-sm text-muted-foreground flex items-center gap-1 cursor-pointer hover:text-foreground"
|
||||
onClick={() => setExpandedRelaySetId((pre) => (pre === relaySetId ? null : relaySetId))}
|
||||
>
|
||||
<div className="select-none">{children}</div>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`transition-transform duration-200 ${expandedRelaySetId === relaySetId ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelaySetOptions({ relaySet }: { relaySet: TRelaySet }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { deleteRelaySet } = useFavoriteRelays()
|
||||
const { setRenamingRelaySetId } = useRelaySetsSettingComponent()
|
||||
|
||||
const trigger = (
|
||||
<Button variant="ghost" size="icon">
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
)
|
||||
|
||||
const rename = () => {
|
||||
setRenamingRelaySetId(relaySet.id)
|
||||
}
|
||||
|
||||
const copyShareLink = () => {
|
||||
navigator.clipboard.writeText(
|
||||
`https://jumble.social/?${relaySet.relayUrls.map((url) => 'r=' + url).join('&')}`
|
||||
)
|
||||
}
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="py-2">
|
||||
<DrawerMenuItem onClick={rename}>
|
||||
<Edit />
|
||||
{t('Rename')}
|
||||
</DrawerMenuItem>
|
||||
<DrawerMenuItem onClick={copyShareLink}>
|
||||
<Link />
|
||||
{t('Copy share link')}
|
||||
</DrawerMenuItem>
|
||||
<DrawerMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => deleteRelaySet(relaySet.id)}
|
||||
>
|
||||
<Trash2 />
|
||||
{t('Delete')}
|
||||
</DrawerMenuItem>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={rename}>
|
||||
<Edit />
|
||||
{t('Rename')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={copyShareLink}>
|
||||
<Link />
|
||||
{t('Copy share link')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => deleteRelaySet(relaySet.id)}
|
||||
>
|
||||
<Trash2 />
|
||||
{t('Delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core'
|
||||
import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy
|
||||
} from '@dnd-kit/sortable'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PullRelaySetsButton from './PullRelaySetsButton'
|
||||
import RelaySet from './RelaySet'
|
||||
|
||||
export default function RelaySetList() {
|
||||
const { t } = useTranslation()
|
||||
const { relaySets, reorderRelaySets } = useFavoriteRelays()
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates
|
||||
})
|
||||
)
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = relaySets.findIndex((item) => item.id === active.id)
|
||||
const newIndex = relaySets.findIndex((item) => item.id === over.id)
|
||||
|
||||
const reorderedSets = arrayMove(relaySets, oldIndex, newIndex)
|
||||
reorderRelaySets(reorderedSets)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-muted-foreground font-semibold select-none shrink-0">
|
||||
{t('Relay sets')}
|
||||
</div>
|
||||
<PullRelaySetsButton />
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
>
|
||||
<SortableContext
|
||||
items={relaySets.map((set) => set.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="grid gap-2">
|
||||
{relaySets.map((relaySet) => (
|
||||
<RelaySet key={relaySet.id} relaySet={relaySet} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import AddNewRelay from './AddNewRelay'
|
||||
import AddNewRelaySet from './AddNewRelaySet'
|
||||
import FavoriteRelayList from './FavoriteRelayList'
|
||||
import { RelaySetsSettingComponentProvider } from './provider'
|
||||
import RelaySetList from './RelaySetList'
|
||||
|
||||
export default function FavoriteRelaysSetting() {
|
||||
return (
|
||||
<RelaySetsSettingComponentProvider>
|
||||
<div className="space-y-4">
|
||||
<RelaySetList />
|
||||
<AddNewRelaySet />
|
||||
<FavoriteRelayList />
|
||||
<AddNewRelay />
|
||||
</div>
|
||||
</RelaySetsSettingComponentProvider>
|
||||
)
|
||||
}
|
||||